Typography, motion and components
2. Typography
Section titled “2. Typography”22. Ship only fonts you are allowed to ship.
- Problem. Gilroy Regular, Medium, SemiBold and Bold are paid weights, and the repos shipped them with no licence.
- Rule. Display headings use Gilroy ExtraBold (800); light display text uses Gilroy Light (300). Everything else (body, labels, controls, IDs) uses Outfit 400 to 700, which is open licence. Unbounded 500 to 600 is the one accent face, for one short phrase per layout. Anywhere the free Gilroy files cannot be embedded (PDF engines) falls back to Outfit. The paid weights are removed from every repo.
- Values.
displayGilroy 800 / 300,sansOutfit 400 to 700,accentUnbounded 500 to 600. - Enforced.
fonts/holds only the free files; source guard bans obsolete fonts. - Source. Decision 299; design repo
7dca9a4.
23. Display sizes must shrink on phones.
- Problem. The display face at desktop size (for example 56 px) overflowed or wrapped badly on a 360 px screen, and a bold face read as too heavy at small sizes.
- Rule. Under 640 px the type scale drops: display 44/48, display-sub 24/30, heading 34/40, accent 32/40 (size / line height in px). Titles on phones need room, so give the header enough height.
- Enforced.
styles/chrome.cssmedia query atmax-width: 639px. - Source. App
chrome.css;2d606d4e1(phone titles).
3. Motion
Section titled “3. Motion”24. Never let the browser interpolate between two matrix() transforms.
- Problem. The box-open logo flaps are 3D-looking folds drawn with
matrix()transforms. The browser blends two matrices by decomposing them, which drifts the hinge and flickers (the “matrix glitch”) when a flap goes edge-on. - Rule. Sample the flap transform once per frame (96 samples for the one-shot, 160 for the loop) and play
with
steps(1, end), so no matrix is ever interpolated. Only opacity may interpolate, and every opacity sample is emitted so a ramp is not smeared across a gap. In dark the seam line uses the page colour so it disappears against black. - Values. One-shot 1600 ms; loop cycle 4400 ms; dark box under-face
#66625D, seam#000000. - Enforced. Generated keyframes in
components/boxOpen/css.ts; design repomotion/box-open/. - Source. App
96018635f; design repo0f6fb57.
25. Reduced motion is a first-class design, not a bug fix.
- Rule. Under
prefers-reduced-motion: reduce, durations drop to about 0, distances are zeroed, only fades remain, scroll behaviour is instant, and the loader and logo show their still (open) frame. Kiosk and TV displays get only fades. A scroll story needs a full static fallback for reduced motion, short viewports and phones. - Values. Tokens: durations 120 / 180 / 240 ms; curves
cubic-bezier(0.2,0,0,1)standard,(0.16,1,0.3,1)enter,(0.4,0,1,1)exit; enter distance 8 px; pop scale 0.98. Nothing on the critical path longer than 240 ms. Transform and opacity only, no new animation dependency. - Enforced. Global reduced-motion block in
chrome.css;tokens.jsonmotion. - Source. Decisions 300, 323; design spec E.
26. No hover animation on touch.
- Problem. Hover effects stick on touch screens (the “hover” state stays after a tap).
- Rule. Gate hover animation on
@media (hover: hover)and gate everything onprefers-reduced-motion: no-preference. - Source. Decision 362; design repo
motion/box-open/README.md.
27. The logo-hover rule: the mark opens once on hover and focus, everywhere, except tenant logos.
- Rule. The Space Works mark plays the one-shot box opening (1600 ms) on pointer hover and keyboard focus wherever it appears (navbar, landing, sidebar, member header, auth panel, docs, mobile bar, error pages) through one shared component; leaving returns the still mark. It never loops except as the loader, tenant logos never animate, and under reduced motion it stays static.
- Source. Decision 362; design repo
docs/motion.md.
Current rule (v2.0.1, 2026-11-13): SW-367 (2026-11-13) refines lesson 27: hover/focus folds shut 600 ms then opens 1600 ms, finishing the 2200 ms cycle even after pointer leave, without restarting in flight. Touch, tenant-logo and reduced-motion exclusions remain. (1A-N18) — supersedes the line above; see docs/current-rules.md#cr-motion-01.
28. Use one loader everywhere: the box that opens.
- Problem. Ring spinners and grey skeleton blocks gave two different loading looks, and the ring had its own dark-mode bugs (see 29).
- Rule. Pages, panels, sections, dialogs, cards and Suspense fallbacks show the full box loader. Small inline
spots (buttons, table rows, fields, pickers) show the compact version of the same artwork. Every loading state
keeps a polite
role="status"label; under reduced motion it shows the still open logo. A cold start shows an inline splash before the app mounts so the first load is never blank. - Source. Decisions 313, 322; app
96018635f.
29. Switching theme should animate, but never flash the old theme.
- Rule. The theme switch reveals the new theme as a circle from the toggle (final frame held so the old theme never flashes), with a plain colour cross-fade where View Transitions are unsupported, and an instant swap with reduced motion. The default is light, with explicit saved choices respected, including across tabs and when storage is blocked; an OS-dark visitor without a saved choice starts light.
- Enforced. Tests labelled dark must select and verify actual dark mode.
- Source. App
f91f4c3b8; decision 204.
4. Components
Section titled “4. Components”30. Skeletons, spinners and sidebars each needed dark-specific fixes.
- Problem. The skeleton block used the
linecolour, which is nearly the page colour on black, so loading states vanished. The ring spinner’s track and teal head were nearly invisible on a dark panel. The sidebar lost its edge on black and its in-sidebar search field was a bright slab. - Rule. Skeleton blocks use
linein light andhoverin dark. A ring spinner usesfocusfor the moving head (teal in light, Citron in dark). The dark sidebar is thesurfacelayer with alineright border, and its inner controls usepanelandhover. (Most of this is now superseded by the box loader, lesson 28, but the colour reasoning still applies to any placeholder.) - Source. App
6e60c20c4.
31. Buttons with a loader use the mono variant.
- Rule. Inside a filled button the loader draws in the button’s text colour (
mono), at the button’s text size, so it reads on teal, Citron, Orange or red fills alike. A loading button keeps its width and its label or a status text. - Source. Design repo
motion/box-open/README.md; decision 322.
32. Every button variant has rest, hover and active states that all pass.
- Rule. Hover and active are separate tokens (
accent-hover,accent-active, and so on) with the label tested on each (teal: 5.68 / 6.24 / 6.78). Disabled is explicit (surfacefill,mutedtext,lineborder) and never done withopacity-50. Controls are 44 px tall, radius 10 px, no hover lift or shadow. - Enforced. Contrast guard “fill labels”.
- Source. Design spec B; app contrast tests.
33. Make every control reachable and visible with the keyboard.
- Rule. A skip link needs a target that always renders (
tabIndex={-1}), outside loading or module gates. Collapsed content is unmounted, not hidden-but-present. Scrollable regions (charts, finance tables) get a named keyboard target. Accessible names must match the visible label. - Source. INVARIANTS “accessibility floor”; app
2d606d4e1,024a84fd8.
Part of Design lessons learned, published from docs/lessons-learned.md. Edit it there.