Selling calm with physics.
Security marketing usually screams. Redoubt whispers: a hero that says “calm is a security posture,” a threat graph that settles instead of explodes, and a terminal that narrates an ordinary, competently-handled shift. It is a Next.js static export — no server, no backend — deployed to GitHub Pages as flat files.
The idea
The buyer of a SOC platform has read a hundred pages of red sirens and skull icons. FUD is the genre default, so the differentiated move is composure. Every design decision follows from that: a deep abyss-blue stage instead of black-and-red, violet for action and amber reserved for alerts, and copy that treats a credential-dump attempt with the tone of a flight log. The two signature pieces make the argument visually — the threat graph shows blast radius before panic, and the ops feed shows eight minutes of a shift where everything simply gets handled.
The stack
- Next.js 14 (App Router) with
output: 'export'. Copy is server-rendered into static HTML; the graph, terminal, counters, and theme toggle are small'use client'islands. - Zero dependencies beyond React. The force simulation is ~60 lines of hand-rolled physics on a 2D canvas; the terminal is a
setTimeoutchain. No chart library, no GSAP, no images — every visual is canvas, inline SVG, or CSS. - Space Grotesk / Inter / IBM Plex Mono. A geometric display face for headlines, a quiet body face, and a mono that does real work: log lines, eyebrows, spec rows, and every number (
tabular-numsso nothing jitters while counting).
As a project site served from /redoubt/, the config pins the base path so every asset resolves under that folder on Pages:
// next.config.mjs
const nextConfig = {
output: 'export',
basePath: '/redoubt',
assetPrefix: '/redoubt/',
trailingSlash: true,
images: { unoptimized: true },
};Signature technique #1 — the force-directed threat graph
Twenty-four nodes (core services, assets, identities, external actors) and thirty-one edges settle under three forces: pairwise repulsion, spring tension along edges, and a soft pull toward centre. This is the actual per-frame step from components/ThreatGraph.tsx:
/* physics: repulsion + edge springs + soft centring */
const rep = W * H * 0.0075; // repulsion scales with the stage
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const f = rep / d2; // inverse-square push apart
a.vx += (dx / d) * f; b.vx -= (dx / d) * f;
}
}
const rest = Math.min(W, H) * 0.19; // spring rest length
for (const [i, j] of LINKS) {
const f = (d - rest) * 0.015; // Hooke, gently
a.vx += (dx / d) * f; b.vx -= (dx / d) * f;
}
for (const n of nodes) {
n.vx += (W / 2 - n.x) * 0.0022; // soft centring
n.vx *= 0.84; // damping = the calm
n.x += n.vx;
}The damping constant is the brand: 0.84 settles the graph in a few seconds instead of letting it jitter forever. Hover detection is a nearest-node search within 26px; the hovered node’s neighbourhood stays at full alpha while everything else drops to 30% — you read blast radius, literally. Alert nodes breathe an amber ring on a time-based phase, so the pulse costs nothing extra per frame.
prefers-reduced-motion, the simulation runs 360 steps synchronously at mount and paints one static, fully-settled frame — no rAF loop ever starts, and hover still works via on-demand redraws.Signature technique #2 — the self-typing ops feed
The terminal’s sixteen log lines are server-rendered in full, so the content exists without JavaScript and reads as a complete document. After hydration (and only if motion is allowed), the client clears the buffer and replays it: one setTimeout per tick, three characters at a time, with longer beats after CRT and ACT lines — the pacing of someone actually reading the feed. Severity is a class, not an image: WRN is amber, ACT (containment) is violet, and the loop ends on SLA: green before replaying the shift.
Details that matter
- Dual themes, one source of truth. Every colour is a token on
:root[data-theme]; a two-line inline script restores the saved theme (keyredoubt-theme) before first paint, so there is no flash. The canvas reads its palette fromgetComputedStyleand re-reads it on adata-thememutation. - Progressive enhancement. The same script adds
.jsto<html>; every hidden reveal state and the clipped hero intro are gated on it. Scripts off: full page, full log, settled layout. - No hydration mismatches. Counters render their final value on the server and animate only after mount; the terminal’s static render is identical on server and first client paint.
- The named ease. One curve everywhere —
cubic-bezier(0.16, 0.84, 0.28, 1), the “containment” curve: fast first response, controlled settle. Mirrored in JS as1 − (1−t)^3.2so count-ups land like the CSS does. - A11y is not a coat of paint. Skip link,
:focus-visiblerings on everything including the theme toggle,aria-pressedon the toggle, decorative layers markedaria-hidden, and the demo login announces its confirmation viaaria-live.
Ship it on GitHub Pages
Static export writes to out/. GitHub Pages serves the repo from /docs, so the build output is copied there with a .nojekyll marker (so Pages doesn’t strip the _next folder):
# build → copy to docs → publish
npm run build
rm -rf docs && cp -r out docs && touch docs/.nojekyll
gh repo create bswxyz/redoubt --public --source . --push
# Settings → Pages → main / docs
# live at https://bswxyz.github.io/redoubt/Because basePath and assetPrefix are set, every chunk and font resolves under the project subpath, and trailingSlash keeps /guide/ working as a folder.
Redoubt is a design-showcase concept. The incidents, analysts, customers, certifications and prices are fictional; the demo login validates locally and sends nothing. See the repository README for the full demo-vs-real map.
← Back to Redoubt