Skip to main content

Site Design System

The public marketing site (/site) went through a 5-phase visual redesign to a bold, technical "schematic" aesthetic — purple/white/near-black, two display/mono webfonts, decorative hairline primitives, and two genuine WebGL 3D scenes on the homepage hero. A 6th pass then inverted the neutral ramp to a dark palette (near-black page, off-white text) — same structure, same accent purple, same primitives, just recolored; see "The dark-palette pass" below for exactly what did and didn't change. This doc is the reference for that system: the exact tokens, why they were chosen, and the rules that keep the site's accessibility/performance invariants (see docs/marketing-site.md and docs/checklist-traceability.md) intact as it evolves. It does not repeat the SEO/legal/consent material those two docs already own.

Palette​

All tokens live in site/app/globals.css's @theme block. There is no other place a color is defined for use in Tailwind utility classes — a bg-accent or text-ink-muted class always resolves back to one of these. Token names still follow the original light-theme metaphor (paper = background, ink = foreground text) even though the palette is now dark — the role each token plays didn't change, only which end of the lightness scale it sits at.

TokenHexRole
--color-paper#0B0B12Page background, near-black (not pure black)
--color-paper-2#15131DSecondary/alternating section background
--color-paper-3#1D1927Tertiary surface (subtle recess)
--color-ink#F5F3F8Primary text, off-white (not pure white)
--color-ink-2#C7C2D3Secondary heading/emphasis text
--color-ink-muted#A79FB4Body copy on paper backgrounds
--color-ink-faint#6F6A7DDe-emphasized text / decorative glyph color
--color-line#221E2CDefault hairline border
--color-line-strong#6F6A7DEmphasized hairline (table rules, dividers that need to read)
--color-accent#A855F7Primary purple accent (unchanged)
--color-accent-hover#C084FCHover state for accent surfaces
--color-accent-strong#7C3AEDAccent on higher-contrast surfaces (buttons, focus rings; unchanged)
--color-accent-ink#C4B5FDAccent used as text color on paper (dark bg)
--color-accent-deep#2E1065Darkest accent tint, used as a panel background (unchanged)
--color-accent-tint#221A38Darkest accent tint, used as a subtle fill
--color-danger#F87171Error text
--color-danger-border#EF4444Error input border
--color-danger-tint#2A1215Error banner/background fill

Phase 3 of the original redesign removed the old --color-brand-* shim entirely — there is no legacy alias left pointing at these values under a different name. Confirm this before adding a new token: grep -n "color-brand" site/app/globals.css should return nothing.

The ReelBolt logo (the R-with-bolt mark, its outlined Chakra Petch wordmark and the lockups) uses only the violet tokens in this table — accent-deep, accent-ink, accent and ink — and adds no colour of its own. Its masters, clear-space and minimum-size rules, and the sync script that copies them into /site, /web and /docs-site are documented in docs/brand.md.

Why these exact hex values — measured contrast​

Every text/background and interactive-border pairing actually used in the UI was checked against WCAG 2 contrast requirements before being adopted. These are the pairings that matter; re-run a contrast check before changing any of the hexes above, since a "close enough" purple can silently drop a pairing below its required ratio.

ForegroundBackgroundRatioRequirement met
#0B0B12 (paper)#A855F7 (accent)4.96:1AA, normal text (button text on bg-accent)
#FFFFFF#7C3AED (accent-strong)5.70:1AA, normal text
#C4B5FD (accent-ink)#0B0B12 (paper)10.62:1AAA, normal text
#F5F3F8 (ink)#0B0B12 (paper)17.80:1AAA, normal text
#A79FB4 (ink-muted)#0B0B12 / #15131D7.71 / 7.24:1AAA, normal text
#6F6A7D (ink-faint / line-strong)#0B0B12 / #15131D3.77 / 3.54:1≥3:1 only — used for interactive control borders (WCAG 1.4.11 non-text contrast); also carries a handful of pre-existing small-print text spots (footer copyright, legal-page footnote, the ContactForm "(optional)" label) that sit below 4.5:1 — a known minor gap inherited unchanged from the light-theme palette, not introduced by the dark-palette pass
#F87171 (danger)#0B0B12 / #2A1215 (danger-tint)7.09 / 6.35:1AAA, normal text
#FFFFFF#2E1065 (accent-deep)15.24:1AAA, normal text

If a future change starts using ink-faint for new text (beyond the pre-existing spots above), re-run a contrast check against whatever background it lands on; at ~3.5–3.8:1 it fails normal-text AA (needs 4.5:1).

The dark-palette pass​

The 6th redesign pass flipped the site from a white-paper/near-black-ink palette to a near-black paper/off-white-ink one, keeping the accent purple family, the type system, the decorative primitives, and every layout untouched. It was not a blind swap of the two hex values, because several tokens are used for more than one role:

  • ink is also a background color, not just the default text color — Panel's tone="ink" and Section's tone="ink" (used for the homepage/features closing CTA bands and the hero's small viewfinder panel) render bg-ink. Those call sites previously hardcoded text-white/border-white for the text and corner-bracket color sitting on that background, because ink was dark. Now that ink is the light color, that on-ink content was repointed to text-paper/border-paper (paper — dark — is exactly the color those spots need). See components/ui/Panel.tsx, components/ui/CornerBrackets.tsx (new paper tone), components/layout/Section.tsx, components/home/Hero.tsx, app/page.tsx, and app/features/page.tsx.
  • accent-strong (#7C3AED) cannot serve as both a button/icon background and body text on the new dark paper — it clears AA (5.70:1) as a background with white text, but as text on #0B0B12 it only reaches 3.44:1, short of the 4.5:1 normal-text requirement, and that pairing (text-accent-strong on bg-paper/bg-paper-2) was the site's primary "accent-colored link/ heading/icon" treatment across nearly every page. Rather than compromise the button-background role, every former text-accent-strong call site was repointed to text-accent-ink (10.62:1 on paper), which is exactly what that token's role — "accent used as text color on paper" — already meant; only the axis of "paper" flipped. Former hover:text-accent-ink pairs became hover:text-accent (4.96:1), so hovering still visibly shifts the color without dropping below AA. accent-strong itself keeps its original hex and is now used only for backgrounds/fills/ focus rings/icon fills, never body text.
  • accent-tint (the "subtle fill" role) flipped from a near-white lavender to a near-black lavender (#221A38) rather than just inverting lightness on the same hue at the same distance from white — it was tuned so accent/accent-ink text sitting on it (e.g. the numbered step badges on the homepage) still clears AA.
  • danger/danger-border/danger-tint all moved to brighter/darker-on-dark equivalents (#F87171/#EF4444/#2A1215) rather than reusing the light-theme reds verbatim, since the original dark reds (#B91C1C/#DC2626) don't clear 4.5:1 against a near-black background.
  • app/manifest.ts's background_color was updated from #ffffff to #0B0B12 to match (PWA splash-screen background); theme_color (#7C3AED, the browser-chrome color) was left unchanged — it was never a page-background value.
  • The three.js hero/viewfinder scene colors (components/three/HeroScene.tsx, ViewfinderScene.tsx) and the OG/Twitter card gradient (app/opengraph-image.tsx) were not changed — both were already dark-purple-on-transparent or dark-purple-gradient and read correctly against the new dark page background without modification.

Re-verify the rename held with:

grep -rn "text-accent-strong" site/app site/components --include='*.tsx'

which should return nothing — every accent-as-text usage goes through accent-ink now.

Typography​

Two Google Fonts, loaded via next/font/google in site/app/layout.tsx:

  • --font-display (Chakra Petch) — headings, CTAs, eyebrow numerals, anything meant to read as bold/technical signage.
  • --font-mono (JetBrains Mono) — eyebrow labels, nav items, captions, form labels, timestamps — the "readout" register of the design.
  • --font-sans — the original zero-download system font stack, kept (not replaced) for long-form prose: legal pages and feature/about body copy. Long paragraphs set in a display or mono face hurt readability at body-copy sizes and line lengths; system-sans is the more legible choice for a multi-paragraph privacy policy or terms page. This is a one-line-reversible decision (swap the font-sans utility for font-mono/font-display on those pages) if the site owner later wants the technical faces used everywhere, including prose.

Decorative primitive catalogue​

All in site/components/ui/:

PrimitivePurpose
GridOverlayAbsolute-fill hairline column/row grid — the schematic backdrop behind hero/section content.
RegistrationMarkSmall solid accent square, print-registration-mark styling, placed at panel corners.
CornerBracketsFour L-shaped corner brackets around a panel, viewfinder/technical-readout styling.
ChromeWidgetSmall bordered glyph box (close/target/plus/grid icon) — desktop-chrome-style ornament.
EyebrowSmall-caps mono label with a leading accent dot, used above headings.
ButtonThe site's one button/link component — every CTA, everywhere, goes through this.
PanelBordered content container with optional tone and corner brackets — the base "card" surface.

The aria-hidden / non-interactive contract​

GridOverlay, RegistrationMark, CornerBrackets, and ChromeWidget are purely decorative — each renders aria-hidden="true" on its root element, carries no tabIndex, no onClick, and no interactive ARIA role, and this must never regress. A screen reader and a keyboard-only user should be able to ignore all four completely; a design change that adds a click handler or a real link inside one of these components breaks that contract and must move the interactive affordance out to a real, focusable element instead (usually a Button or a plain <a>).

Eyebrow is the one partial exception worth calling out: the label text is real, meaningful content (not aria-hidden) — only its small leading accent square is a decorative aria-hidden="true" dot. Button is intentionally interactive (it's the CTA primitive, not decoration). Panel is a plain layout container with no ARIA semantics of its own; it only becomes non-interactive-by-construction because nothing inside it is added except through other, already-compliant primitives.

Re-verify the contract with grep -rn "tabIndex\|onClick" site/components/ui/GridOverlay.tsx site/components/ui/RegistrationMark.tsx site/components/ui/CornerBrackets.tsx site/components/ui/ChromeWidget.tsx — it should return nothing.

The 3D strategy — honest, not fabricated​

The hero's 3D objects (an extruded solid of the ReelBolt mark's bolt — the closed M68 12 L28 68 H54 L46 108 L92 48 H64 Z path of brand/mark.svg, hard-coded in site/components/three/HeroScene.tsx and built there with three.js' own THREE.Shape + ExtrudeGeometry — inside a wireframe cage, with an orbit ring; a wireframe/solid octahedron pair in the smaller "viewfinder" panel) are genuine, live-rendered WebGL geometry — not a fabricated product photo, not a mascot render, not a stock 3D asset passed off as bespoke. There is no real 3D asset for this product to render, and none was faked to look like one; abstract generative geometry, rendered honestly as what it is, was the design choice that fit the technical/schematic aesthetic without inventing a visual fact about the product. This mirrors the site's broader "no invented facts" discipline (see docs/marketing-site.md) applied to visuals rather than copy.

The hero core is a derived copy of the mark's geometry, not a second drawing of it, and it says so: it extrudes only the bolt, because that is the one closed, fill-governed path in brand/mark.svg — the R beside it is a stroked open path and the dark outline around the bolt in app-icon.svg is a paint order, and neither becomes a single clean extrusion without a boolean operation the scene deliberately does not carry. brand/mark.svg stays the source: the six vertices are that path's own viewBox coordinates carried through the markup's own transform="translate(66 80) scale(-.68 .68) translate(-60 -60)", mirrored into three.js' y-up frame and scaled to the footprint the abstract icosahedron used to occupy (HeroScene.tsx documents the derivation in place), so a change to the bolt path is a recomputation there, never a silent divergence. The static fallback shows the same mark through the shared components/ui/BrandMark.tsx.

SSR isolation rule​

three and @react-three/fiber are imported only in two files:

  • site/components/three/HeroScene.tsx
  • site/components/three/ViewfinderScene.tsx

Every other file reaches the scenes only through their mount wrappers — site/components/three/HeroSceneMount.tsx and site/components/three/ViewfinderMount.tsx — which load the real scene component via next/dynamic(() => import('./HeroScene'), { ssr: false }) (and the ViewfinderScene equivalent). This is required, not stylistic: Next.js prerenders/statically generates this site's pages on the server, and WebGL has no server-side implementation — a top-level import * as THREE from 'three' reached during SSR either no-ops uselessly or throws, depending on what it touches (document/window/canvas context). next/dynamic(..., { ssr: false }) is also only legal inside a Client Component, which is why the mount files are 'use client' separately from the scene files and from whatever page renders the mount.

Verify the isolation holds with:

grep -rln "from 'three'\|from '@react-three/fiber'" site/app site/components site/lib

This should list exactly HeroScene.tsx and ViewfinderScene.tsx and nothing else.

Reduced-motion rule​

site/lib/use-reduced-motion.ts exports useReducedMotion(), which defaults to true (assume reduced motion) until a matchMedia('(prefers-reduced-motion: reduce)') check proves otherwise on mount. Every motion-bearing piece of the site is built on top of this one hook, and all of them must keep failing safe toward "do nothing that moves":

  • HeroSceneMount / ViewfinderMount (site/components/three/) — when reduced is true, the component returns its static fallback (HeroFallback, a pure-CSS gradient box carrying the mark's static BrandMark SVG; ViewfinderStatic, a static SVG) without ever calling next/dynamic's import — the three.js/@react-three/fiber chunk is not requested at all, not even prefetched, for a reduced-motion visitor.
  • Reveal (site/components/motion/Reveal.tsx) — when reduced is true, it renders children immediately, fully visible, with no wrapper transition classes and no IntersectionObserver — a true no-op, not just a fast animation.
  • useScrollParallax (site/lib/use-scroll-parallax.ts) — when reduced is true, the effect returns immediately; no scroll/resize listeners are attached and the element's transform is never touched.

Re-confirm this hasn't regressed by reading all five files together (lib/use-reduced-motion.ts, components/three/HeroSceneMount.tsx, components/three/ViewfinderMount.tsx, components/motion/Reveal.tsx, lib/use-scroll-parallax.ts) — each must branch on the same hook and each must choose "render the static/no-op path, don't just animate faster" as its reduced-motion behavior.

Bundle code-split contract​

  • Only HeroScene.tsx and ViewfinderScene.tsx may import three or @react-three/fiber (see "SSR isolation rule" above — the same rule doubles as the bundle boundary).
  • Only HeroSceneMount.tsx and ViewfinderMount.tsx may import those two scene files, and only via next/dynamic(..., { ssr: false }) — never a static import of HeroScene/ViewfinderScene from anywhere else, which would pull the three.js chunk back into a shared/eagerly-loaded bundle.
  • site/components/home/Hero.tsx (the only page that renders the mounts) imports the mount components, never the scene components directly.

Net effect, confirmed at every phase of the redesign and re-confirmed for this phase's regression pass: the three.js/@react-three/fiber code (~130KB+ gzipped) ships in its own lazy chunk, is requested only when the homepage's hero mounts on a client that does not have reduced motion set, and every non-homepage route's First Load JS stays within about 1KB of the pre-redesign baseline. Current npm run build route table: homepage First Load JS is 113KB, every other route is 103–108KB, and the three.js chunk does not appear in any route's First Load JS figure at all (it's a route-level lazy chunk fetched at runtime, not part of the initial bundle Next.js's build output attributes to a route). The hero core's move from the abstract icosahedron to the extruded mark changed the homepage figure (112KB → 113KB, +0.26KB of route JS) and left every other route's figures byte-identical. The reduced-motion half of that contract was re-checked by loading the production build in headless Chromium twice, with and without --force-prefers-reduced-motion: the reduced run requested 10 of the 16 chunk URLs the normal run requested and none that the normal run did not, and the three.js chunk is among the 6 it never asks for.

The @react-three/[email protected] pin​

package.json pins @react-three/fiber to exactly 9.8.1 — no caret — because its peer dependency on React is bounded (react: ">=19 <19.4") while this project's declared React is ^19.3.0, which resolves to 19.3.0 in the lockfile: the range still admits that version today, but an unpinned bump would let a future React 19.4+ resolution break npm ci (used by the production Dockerfile's deps stage, and by this phase's full regression rebuild). 9.8.1 declares react: ">=19 <19.4", which resolves cleanly against React 19.3.0. Do not bump @react-three/fiber past 9.8.1 without first confirming its peer-dependency range against whatever React version is installed at the time.

three itself is declared with a caret (^0.186.0) and currently resolves to 0.186.0 — it has no equivalent peer-dependency conflict with React, so it wasn't pinned exactly.

No @react-three/drei and no framer-motion were added anywhere in the redesign: the 3D scenes are built from three.js core geometries/materials only, and all scroll-driven motion (Reveal, useScrollParallax) is a small hand-rolled IntersectionObserver/CSS-transition implementation (~1KB), not a motion library.

No invented facts, applied to visuals​

  • The reference design's "partner logos" strip became site/components/home/UseCaseStrip.tsx, a "Made for" strip of the video jobs ReelBolt produces ("Product launches", "Feature demos", "Sales outreach", …) rendered as plain text — never as logos, and never captioned as "partners" or "customers". It previously listed stack technologies under a "Runs on" heading; that was accurate but spoke to engineers rather than buyers, so the slot now carries use cases (which also earn their keep as keyword coverage). The no-invented-logos rule is unchanged.
  • The reference design's social-icon rail became site/components/home/SectionIndexRail.tsx, real in-page section-anchor links (#how-it-works, #whats-inside, #get-started) — no fabricated social-media URLs were added anywhere on the site.

How to extend this system​

  • Reuse Reveal, GridOverlay, Eyebrow, Button, and Panel for new sections rather than hand-rolling new decorative markup or a new button/card implementation. If a new decorative shape doesn't fit the existing primitives, add a new one to site/components/ui/ following the same aria-hidden/non-interactive contract described above, rather than inlining aria-hidden divs ad hoc across pages.
  • New hardcoded hex colors in .tsx files are not allowed outside opengraph-image.tsx, twitter-image.tsx, and components/three/* — those three exceptions exist because next/og's ImageResponse and three.js material colors aren't CSS and can't reference the @theme tokens. Everywhere else, use the Tailwind utility classes backed by the tokens in the Palette section above (bg-accent, text-ink-muted, etc.), so a future palette change stays a one-file edit. Verify with:
    grep -rln "#[0-9a-fA-F]\{6\}" site/app site/components --include='*.tsx' \
    | grep -v opengraph-image | grep -v twitter-image | grep -v components/three
    which should return nothing.
  • Before changing any palette hex, re-run a contrast check for every pairing in the "Why these exact hex values" table above — a value swapped for a "close enough" purple can silently drop a pairing below the WCAG ratio it currently clears.