Skip to content

Typography, motion and components

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. display Gilroy 800 / 300, sans Outfit 400 to 700, accent Unbounded 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.css media query at max-width: 639px.
  • Source. App chrome.css; 2d606d4e1 (phone titles).

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 repo motion/box-open/.
  • Source. App 96018635f; design repo 0f6fb57.

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.json motion.
  • 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 on prefers-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.

30. Skeletons, spinners and sidebars each needed dark-specific fixes.

  • Problem. The skeleton block used the line colour, 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 line in light and hover in dark. A ring spinner uses focus for the moving head (teal in light, Citron in dark). The dark sidebar is the surface layer with a line right border, and its inner controls use panel and hover. (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 (surface fill, muted text, line border) and never done with opacity-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.