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.
| Token | Hex | Role |
|---|---|---|
--color-paper | #0B0B12 | Page background, near-black (not pure black) |
--color-paper-2 | #15131D | Secondary/alternating section background |
--color-paper-3 | #1D1927 | Tertiary surface (subtle recess) |
--color-ink | #F5F3F8 | Primary text, off-white (not pure white) |
--color-ink-2 | #C7C2D3 | Secondary heading/emphasis text |
--color-ink-muted | #A79FB4 | Body copy on paper backgrounds |
--color-ink-faint | #6F6A7D | De-emphasized text / decorative glyph color |
--color-line | #221E2C | Default hairline border |
--color-line-strong | #6F6A7D | Emphasized hairline (table rules, dividers that need to read) |
--color-accent | #A855F7 | Primary purple accent (unchanged) |
--color-accent-hover | #C084FC | Hover state for accent surfaces |
--color-accent-strong | #7C3AED | Accent on higher-contrast surfaces (buttons, focus rings; unchanged) |
--color-accent-ink | #C4B5FD | Accent used as text color on paper (dark bg) |
--color-accent-deep | #2E1065 | Darkest accent tint, used as a panel background (unchanged) |
--color-accent-tint | #221A38 | Darkest accent tint, used as a subtle fill |
--color-danger | #F87171 | Error text |
--color-danger-border | #EF4444 | Error input border |
--color-danger-tint | #2A1215 | Error 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.
| Foreground | Background | Ratio | Requirement met |
|---|---|---|---|
#0B0B12 (paper) | #A855F7 (accent) | 4.96:1 | AA, normal text (button text on bg-accent) |
#FFFFFF | #7C3AED (accent-strong) | 5.70:1 | AA, normal text |
#C4B5FD (accent-ink) | #0B0B12 (paper) | 10.62:1 | AAA, normal text |
#F5F3F8 (ink) | #0B0B12 (paper) | 17.80:1 | AAA, normal text |
#A79FB4 (ink-muted) | #0B0B12 / #15131D | 7.71 / 7.24:1 | AAA, normal text |
#6F6A7D (ink-faint / line-strong) | #0B0B12 / #15131D | 3.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:1 | AAA, normal text |
#FFFFFF | #2E1065 (accent-deep) | 15.24:1 | AAA, 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:
inkis also a background color, not just the default text color —Panel'stone="ink"andSection'stone="ink"(used for the homepage/features closing CTA bands and the hero's small viewfinder panel) renderbg-ink. Those call sites previously hardcodedtext-white/border-whitefor the text and corner-bracket color sitting on that background, becauseinkwas dark. Now thatinkis the light color, that on-inkcontent was repointed totext-paper/border-paper(paper— dark — is exactly the color those spots need). Seecomponents/ui/Panel.tsx,components/ui/CornerBrackets.tsx(newpapertone),components/layout/Section.tsx,components/home/Hero.tsx,app/page.tsx, andapp/features/page.tsx.accent-strong(#7C3AED) cannot serve as both a button/icon background and body text on the new darkpaper— it clears AA (5.70:1) as a background with white text, but as text on#0B0B12it only reaches 3.44:1, short of the 4.5:1 normal-text requirement, and that pairing (text-accent-strongonbg-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 formertext-accent-strongcall site was repointed totext-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. Formerhover:text-accent-inkpairs becamehover:text-accent(4.96:1), so hovering still visibly shifts the color without dropping below AA.accent-strongitself 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 soaccent/accent-inktext sitting on it (e.g. the numbered step badges on the homepage) still clears AA.danger/danger-border/danger-tintall 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'sbackground_colorwas updated from#ffffffto#0B0B12to 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 thefont-sansutility forfont-mono/font-displayon those pages) if the site owner later wants the technical faces used everywhere, including prose.
Decorative primitive catalogue
All in site/components/ui/:
| Primitive | Purpose |
|---|---|
GridOverlay | Absolute-fill hairline column/row grid — the schematic backdrop behind hero/section content. |
RegistrationMark | Small solid accent square, print-registration-mark styling, placed at panel corners. |
CornerBrackets | Four L-shaped corner brackets around a panel, viewfinder/technical-readout styling. |
ChromeWidget | Small bordered glyph box (close/target/plus/grid icon) — desktop-chrome-style ornament. |
Eyebrow | Small-caps mono label with a leading accent dot, used above headings. |
Button | The site's one button/link component — every CTA, everywhere, goes through this. |
Panel | Bordered 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.tsxsite/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/) — whenreducedis true, the component returns its static fallback (HeroFallback, a pure-CSS gradient box carrying the mark's staticBrandMarkSVG;ViewfinderStatic, a static SVG) without ever callingnext/dynamic's import — the three.js/@react-three/fiberchunk is not requested at all, not even prefetched, for a reduced-motion visitor.Reveal(site/components/motion/Reveal.tsx) — whenreducedis true, it renders children immediately, fully visible, with no wrapper transition classes and noIntersectionObserver— a true no-op, not just a fast animation.useScrollParallax(site/lib/use-scroll-parallax.ts) — whenreducedis 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.tsxandViewfinderScene.tsxmay importthreeor@react-three/fiber(see "SSR isolation rule" above — the same rule doubles as the bundle boundary). - Only
HeroSceneMount.tsxandViewfinderMount.tsxmay import those two scene files, and only vianext/dynamic(..., { ssr: false })— never a staticimportofHeroScene/ViewfinderScenefrom 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, andPanelfor 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 tosite/components/ui/following the samearia-hidden/non-interactive contract described above, rather than inliningaria-hiddendivs ad hoc across pages. - New hardcoded hex colors in
.tsxfiles are not allowed outsideopengraph-image.tsx,twitter-image.tsx, andcomponents/three/*— those three exceptions exist becausenext/og'sImageResponseand three.js material colors aren't CSS and can't reference the@themetokens. 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:which should return nothing.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 - 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.