One system,
two platforms
Every value on this page exists in two places: assets/css/tokens.css for the
website, and UI/Styles/tokens.uss for the Unity build. The names match on both
sides so a change is a search-and-replace, not an interpretation.
Tip: click any token name or hex value to copy it.
Colour
One accent, one success, one alert, and a neutral ramp that does most of the work. Colour carries meaning here — it is never used to decorate a surface that has nothing to say.
Brand
Neutral
Contrast pairs
| Pair | Ratio | Passes | Use for |
|---|---|---|---|
| #111111 on #F7F8FA | 16.4 : 1 | AAA | Body text |
| #666666 on #F7F8FA | 5.7 : 1 | AA | Secondary text |
| #0A84FF on #FFFFFF | 3.6 : 1 | AA large | Links, 18 pt and above |
| #FFFFFF on #0A84FF | 3.6 : 1 | AA large | Primary button label, 16 pt semibold |
| #F5F6F8 on #0A0B0D | 17.1 : 1 | AAA | Dark-mode body text |
The accent on white sits below 4.5 : 1, so it is never used for body-size text — only for labels at 16 pt semibold or larger, where AA large applies.
Typography
SF Pro Display for headings, SF Pro Text for body, falling back through the system stack. Sizes are fluid on the web and fixed in points on device.
Learn signals
Visualising invisible signals
Four phases, in order
Amplitude and wave behaviour
Push the amplitude past the ceiling and the crests flatten. Clipping stops being a definition and starts being a thing you caused.
Marker SV-M01 · confidence 0.98 · anchored to plane
Print at 100%. Outer brackets must measure 80 mm.
3.5 Hz · 0.72 · 0.29 s
Two rules
Numbers use tabular figures. A readout that shifts width as it counts is a readout nobody can read while dragging a slider.
Headings tighten, body does not. Letter-spacing goes negative as size increases, and stays at −0.006em for anything at body size or below.
Spacing & grid
An 8-point system with one half-step at 4 px for tight icon gaps. Everything else is a multiple of eight, which is why nothing on any page needs a one-off margin.
| Container | Max width | Used for |
|---|---|---|
.container | 1240 px | Default page width |
.container--wide | 1440 px | Prototype, design system |
.container--narrow | 880 px | FAQ, long-form sections |
.container--text | 760 px | Reading passages |
--gutter | 20 → 40 px | Side padding, fluid |
--section-y | 72 → 160 px | Vertical section rhythm |
Border radius
Larger surfaces get larger radii. Anything interactive and pill-shaped uses the full round.
--r-sm · 10px
Chips, small controls
--r-md · 14px
Inputs, icons
--r-lg · 20px
List groups
--r-xl · 28px
Cards
--r-2xl · 36px
Modals, stages
--r-3xl · 48px
CTA bands
--r-pill
Buttons, badges
--r-device · 56px
Device screen
Elevation
Five levels, each a two-part shadow — a tight contact shadow plus a wide ambient one. Level 5 is reserved for modals; nothing else should float that high.
--shadow-1
Resting cards, inputs
--shadow-2
Hovered inputs, chips
--shadow-3
Glass panels, popovers
--shadow-4
Hovered cards, toasts
--shadow-5
Modals only
--shadow-glow
Primary button hover
In Unity there is no box-shadow. Each level maps to a 9-sliced sprite
in UI/Sprites/Shadow_*.png with matching border values. The developer
handoff covers the substitution in full.
Buttons
Minimum height 48 px on the web, 54 px in the app. One primary action per view — if a screen appears to need two, the second one is secondary.
Inputs
Errors appear on blur, never while typing — and clear the moment the field becomes valid. Nothing shouts at someone mid-sentence.
Enter a valid email address.
Cards
Four kinds. If a card needs a fifth variant, the layout is probably wrong.
Surface, hairline border, level-1 shadow. The workhorse.
Lifts 6 px on hover with a mouse-tracked spotlight. Hover this one.
No shadow, muted fill. For notes nested inside another card.
Blurred backdrop and an inset highlight. Only over imagery or the camera feed.
Feedback
Toasts confirm, alerts interrupt, skeletons wait. Each has exactly one job.
2.6 s, bottom centre, never blocks input.
Shown after 300 ms of waiting, never before.
Determinate. Fills over 1.1 s on first view.
Pop-in with a drawn check. Confetti is reserved for phase completion and the certificate — twice per course, so it still means something.
Charts & data
One waveform renderer serves the marketing pages, the in-app workbench and the Unity build. Grid lines sit at 5% opacity so they read as structure, not content.
Every canvas registers with one shared requestAnimationFrame loop and pauses when it scrolls out of view or the tab is hidden. Twelve waveforms on a page cost roughly what one costs.
Icons
A single stroke set: 24 × 24 box, 1.8 px stroke, round caps and joins, no fills.
Sixty-eight glyphs, all inheriting currentColor.
signal
qr
ar
target
gauge
graduation
medal
face
Motion
Motion explains where something came from. If an animation does not answer that question, it is decoration and it goes.
| Token | Value | Use for |
|---|---|---|
--dur-instant | 90 ms | Press feedback |
--dur-fast | 180 ms | Hover, focus rings |
--dur-base | 280 ms | Most state changes |
--dur-slow | 460 ms | Screen transitions |
--dur-slower | 720 ms | Scroll reveals |
--dur-cinema | 1100 ms | Hero and headline reveals |
--ease-out | cubic-bezier(.22,1,.36,1) | Default — things arriving |
--ease-spring | cubic-bezier(.34,1.56,.64,1) | Overshoot on press and pop-in |
--ease-in-out | cubic-bezier(.65,0,.35,1) | Looping ambient motion |
28 px rise, 6 px blur, 720 ms. Fires once at 12% visibility and then unobserves — nothing re-animates on scroll back.
70 ms between siblings. Beyond about eight items the last one arrives too late to feel connected, so groups stay small.
Push, pop, modal, fade and zoom — 420 ms each. The outgoing screen shifts 28% and dims rather than sliding fully away.
Reduced motion is a real path, not a stub
With prefers-reduced-motion or the in-app toggle, transitions collapse to
0.01 ms rather than being removed. Every transitionend callback still fires, so nothing
gets stuck half-mounted — a failure mode that is easy to ship and hard to notice.
Component states
Six states, defined once, applied everywhere.
| State | Visual change | Duration |
|---|---|---|
| Default | Base tokens | — |
| Hover | −2 px translate, elevation +1, colour interpolates | 280 ms |
| Focus | 3 px ring at --accent-ring, 3 px offset | 180 ms |
| Pressed | 0.97 scale, elevation −1 | 90 ms |
| Loading | Spinner replaces the label, pointer events off | — |
| Disabled | 0.42 opacity, pointer events off, no focus | — |
Focus rings use :focus-visible, so they appear for keyboard users and stay
out of the way for mouse users. They are never removed — a focus ring you cannot see is
an interface a keyboard user cannot navigate.
Light & dark
Dark mode follows the operating system until the visitor chooses otherwise, and that choice persists. It is a full palette, not an inverted one.
Signal frequency
3.5 Hz · period 0.29 s
Signal frequency
3.5 Hz · period 0.29 s
Three things that change beyond colour
Shadows deepen. The light-mode shadows disappear on a dark ground, so opacity roughly triples.
Borders become light. A hairline at 9% white replaces the solid grey; a dark border on dark is invisible.
Grain increases. Noise opacity goes from 3.5% to 5% because dark gradients band far more visibly.
Responsive behaviour
Four breakpoints, and type that scales fluidly between them rather than jumping.
| Breakpoint | Width | What changes |
|---|---|---|
| Large desktop | 1441 px + | Container caps at 1440; whitespace grows, content does not |
| Desktop | 1081–1440 px | Full navigation, three-column prototype layout |
| Tablet | 769–1080 px | Nav collapses to the drawer; prototype rails move below the device |
| Mobile | ≤ 768 px | Single column, device scales to 0.72, custom cursor disabled |
Fluid type uses clamp() keyed to viewport width, so a heading at 1200 px
and one at 1400 px differ by a few pixels rather than a whole step. Nothing in the
layout depends on a specific font size having been chosen.
Accessibility
WCAG 2.2 AA, checked rather than claimed.
- Contrast — body text at 16.4 : 1, secondary at 5.7 : 1. The accent never carries body-size text.
- Keyboard — every interactive element reachable in DOM order, with a visible
:focus-visiblering. Skip link first in the tab order. - Targets — 44 × 44 px minimum for anything tappable, including the icon-only buttons in the navigation.
- Semantics — landmarks on every page, one
h1, headings that never skip a level, and ARIA only where HTML has no equivalent. - Live regions — toasts announce politely; form errors use
role="alert"and are tied to their field witharia-describedby. - Colour is never the only signal — quiz results pair colour with an icon, and the clipping warning changes the readout text, not just its colour.
- Reduced motion — respected from the OS and available as an in-app toggle, with every callback still firing.
- AR parity — every workbench readout is mirrored as a live text region, so values are announced as they change.