Files
adr-sml/docs/02-design-system.md
T
Pouya LajevardiandClaude Opus 5 8134709548 feat: build step 1 — scaffold, layout, header, footer, SEO; zero JavaScript
Build order step 1 (docs/01): scaffold, tokens, base layout, header,
footer, SEO component, plus a temporary /type-scale/ proof sheet that
step 2 deletes.

THE FONTS WERE NEVER ON DISK. global.css declared six @font-face rules
pointing at /fonts/*.woff2 and public/fonts/ did not exist, so every
face had been silently falling back to Georgia and the system sans.
Six cuts committed, 123,804 bytes, SIL OFL 1.1, provenance in
docs/reference/fonts-provenance.md. ?v=1 on every URL because the
deploy script serves them immutable for a year.

ZERO JAVASCRIPT. The reveal was an inline IntersectionObserver in
<head>; docs/05 specifies script-src 'self' with no unsafe-inline, so
the only script on the site was the one thing the site's own CSP would
refuse to execute. Replaced with animation-timeline: view() behind
@supports. 0 script tags and 0 .js files in dist.

The infinity mark is lifted verbatim from the deployed site's own
smlMark loading thumbnail, not redrawn (Q32 asks whether a canonical
vector exists). The proof sheet computes its contrast table from
tokens.css rather than restating docs/02 — all eleven ratios reproduce
the measured table exactly.

Register: Canadian Tax Foundation added (§4, R10 widened); Q30 closed
— SML Company Ltd is federally incorporated under the CBCA, and the
footer publishes neither that nor the place of business; Q31 closed —
Plausible, on EU-only data residency (D15 amended). ROLE constants
added for "Director of Firm Operations" and "active litigation
exposure" so step 3 does not hand-type them.

Lighthouse unavailability now stated in six places rather than left as
a control that had silently stopped existing (§7, R11).

Both review agents ran twice. The second pass found four defects in
the first pass's fixes, including the minifier bug written back into
its own fix and a colour-alone repair that used the banned gold-on-
cream pairing at 2.10:1. Measured in headless Chrome at thirteen
widths with a seventh nav item injected: 0 overflow, 0 tap targets
under 44x44, 0 focus-order inversions, state indicators at 12.29:1,
755 words of body text with no JavaScript.

Opened: Q32-Q37. Closed: Q30, Q31.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012XquaEq4BgWMCwUqLEyNkF
2026-08-26 15:57:02 -04:00

215 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 02 — Design system
Authority: `AGENTS.md` §3 D7 — *keep the palette and the infinity mark;
modernize the execution.* The look is not up for redesign. What follows is the
system that preserves it while fixing what the old build got wrong.
---
## What carries over unchanged
- The **palette**: cream, ink, maroon, gold.
- The **infinity mark** — SML Company Ltd.'s actual logo, and the metaphor holds:
a dispute is a loop, and the work is redrawing the loop into a line.
- The **type pairing**: Instrument Serif for display, Geist for text, Geist Mono
for eyebrows and labels.
- The **editorial register** — generous whitespace, restrained colour, serif
display type at large sizes.
## What changes
| Was | Is | Why |
|---|---|---|
| Google Fonts at runtime | Self-hosted, subset, preloaded | Removes a render-blocking third-party round trip from a page that collects legal inquiries |
| Fixed px type sizes | Fluid `clamp()` scale | One scale from 360 px to 1600 px with no breakpoint jumps |
| Ad hoc spacing values | 8 px base scale | Consistent vertical rhythm; no magic numbers |
| Gold used as a text colour on cream | Gold restricted to decorative and on-dark | **It fails WCAG AA at 2.10:1.** Measured, not assumed |
| Scroll-reveal on every element, always on | Reveal on major sections only, gated behind `prefers-reduced-motion` | Motion that reads as confident rather than decorative; accessible by default |
| 2.2 MB single file, ~1 MB of base64 logos *(both figures under review — `AGENTS.md` Q34; and "470 KB PNG", which this row used to assert, has no source anywhere in the repo and has been removed)* | Optimized SVG mark, AVIF/WebP photography | The mark is geometry, so it should be vector. That holds whatever the old file weighed |
| React 18 dev build + Babel Standalone in the browser | Static HTML, near-zero JS | The reason the site is invisible to crawlers |
---
## Colour
Tokens live in `src/styles/tokens.css`. Never write a raw hex value in a
component.
| Token | Value | Use |
|---|---|---|
| `--cream` | `#faf7f2` | Page background |
| `--cream-2` | `#f3ede0` | Alternating section background |
| `--cream-3` | `#ebe3d1` | Cards and insets on cream |
| `--ink` | `#1a1614` | Body text; dark section backgrounds |
| `--ink-soft` | `#3a322c` | Secondary text |
| `--muted` | `#6e6359` | Metadata, captions — **on cream only** |
| `--maroon` | `#5a1a1c` | Primary action, accents, dark panels |
| `--maroon-d` | `#3d1112` | Hover on maroon |
| `--maroon-l` | `#7a2a2c` | Links on cream |
| `--gold` | `#c9a876` | Rules, dividers, on-dark accent — **never text on cream** |
| `--gold-d` | `#a88858` | Large decorative text on cream only |
| `--gold-l` | `#e2c89a` | Text on ink or maroon |
### Contrast — measured, 2026-08-25
Against `--cream` `#faf7f2`:
| Colour | Ratio | AA body (4.5) | AA large (3.0) |
|---|---|---|---|
| `--ink` `#1a1614` | **16.81** | pass | pass |
| `--ink-soft` `#3a322c` | **11.75** | pass | pass |
| `--maroon` `#5a1a1c` | **12.29** | pass | pass |
| `--maroon-l` `#7a2a2c` | **8.95** | pass | pass |
| `--muted` `#6e6359` | **5.47** | pass | pass |
| `--gold-d` `#a88858` | **3.11** | **FAIL** | pass |
| `--gold` `#c9a876` | **2.10** | **FAIL** | **FAIL** |
Against `--maroon` `#5a1a1c`: cream 12.29 · gold-l 8.11 · gold 5.84 — all pass.
Against `--ink` `#1a1614`: cream 16.81 · gold-l 11.09 · gold 8.00 — all pass.
`--muted` on ink measures **3.07 and fails**; use `--gold-l` or cream at reduced
opacity for secondary text on dark.
**Hard rules.**
1. `--gold` is never a text colour on cream. Rules, borders, dividers, icon
strokes, and on-dark text only.
2. `--gold-d` on cream only at 24 px+ / 19 px bold, and only for decorative
display text — never for anything a reader must parse.
3. Body text on cream is `--ink` or `--ink-soft`. Metadata may use `--muted`.
4. Secondary text on dark is `--gold-l` or `--cream` at ≥ 70% opacity.
5. **No dark mode.** A single committed light identity is the right call for a
legal practice, and halving the surface area halves the ways contrast breaks.
---
## Typography
**Faces.** Instrument Serif (display) · Geist 300/400/500/600 (text) ·
Geist Mono 400/500 (eyebrows, labels, data).
Self-host all three. Subset to Latin + Latin Extended-A. `font-display: swap`.
Preload only the two faces used above the fold — Instrument Serif regular and
Geist 400.
**Scale.** Fluid, `clamp()`, `1.25` ratio at the small end widening to `1.333` at
the display end. Tokens `--text-xs` through `--text-6xl` in `tokens.css`.
**Rules.**
- Display type (`--font-serif`) at `--text-4xl` and above only. It has almost no
hinting at small sizes and looks weak below 32 px.
- Display line-height `0.95``1.05`; letter-spacing `-0.02em`.
- Body line-height `1.6`. Measure capped at `68ch` — the old site ran full-bleed
paragraphs at 1400 px, which is unreadable.
- Eyebrows: mono, 1112 px, `0.18em` tracking, uppercase, always paired with a
real heading. An eyebrow is not a heading and never carries the `<h*>`.
- Italic display (`.it`) is the one flourish the design allows. One italic phrase
per headline, at most.
- Never skip a heading level. `<h1>` once per page.
---
## Spacing and layout
8 px base: `--space-1` `4px` · `-2` `8` · `-3` `12` · `-4` `16` · `-5` `24` ·
`-6` `32` · `-7` `48` · `-8` `64` · `-9` `96` · `-10` `128` · `-11` `160`.
Content width `1280px`; prose measure `68ch`; wide media `1440px`.
Gutters: `24px` mobile, `48px` desktop.
Section rhythm: `--space-9` (96 px) mobile, `--space-11` (160 px) desktop —
`--section-y` in `tokens.css`. *Corrected 2026-08-26:* the curve was
`6vw + 2rem`, which reaches 160 px only at a **2133 px** viewport, so the
desktop half of this line was never delivered (measured 108.8 px at 1280 px,
128 px at 1600 px). It is now `9vw + 1rem`, which reaches 160 px at 1600 px. If
you change the curve, re-measure — a `clamp()` whose upper bound is unreachable
reads exactly like one that works.
Grid: 12 columns desktop, 6 tablet, 4 mobile, `--space-5` gutter.
---
## Motion
The old build animated nearly everything on scroll. The replacement is
deliberate and quiet.
- **Reveal on section entry only** — not on every child element. Sub-element
stagger is limited to card grids, and capped at six children.
- Duration `600ms`, easing `cubic-bezier(.2,.7,.2,1)`. Transform and opacity
only — never layout properties.
- Implement with **`animation-timeline: view()`**, behind `@supports`. *Amended
2026-08-26:* the `IntersectionObserver` alternative this line used to offer
first is now ruled out, not merely second choice. It has to run inline in
`<head>` to avoid a flash, and `05-backend-spec.md` specifies `script-src
'self'` with no `unsafe-inline` — so the only script on the site would have
been the one thing the site's own CSP refuses to execute, and a per-build hash
drifts from the policy pinning it. The CSS route ships **zero** JavaScript.
Not a framework, not a library, not a script.
- **Content is visible without the feature.** The `@supports` gate is
load-bearing, not defensive: without it, a browser that ignores
`animation-timeline` runs the animation once against the document timeline at
load; with it, that browser gets no animation and fully visible content. The
old build had this exactly backwards and shipped a blank page.
- **And without a print timeline.** A scroll-driven animation has no timeline
when printing, so a revealed element renders at its `from` state — `opacity:
0`. Measured 2026-08-26: before the print override existed, printing a page to
PDF dropped four card headings from the output entirely. `/about/` is written
to be printed by people evaluating an appointment.
- Hover transitions `250ms`.
```css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
```
Nothing animates infinitely. Nothing autoplays. Nothing moves on page load
except the reveal of the hero.
---
## Components
| Component | Notes |
|---|---|
| `InfinityMark` | Inline SVG, `currentColor`, `aria-hidden` when decorative. Never a PNG |
| `SiteHeader` | **Sticky from 64rem (1024 px) up; static below it.** Practice dropdown as CSS-only `<details>`. *Amended 2026-08-26, both halves by measurement:* **(a)** the one-row header has to hold the brand, **seven** nav items and the CTA — Insights is the seventh and arrives on its own at build step 7 — and it does that at 1024 px with 32 px of clearance, not below. Under 64 rem the nav takes its own row and the header measures **137 px** at tablet widths and **185 px** at 320375 px, which is more of a small viewport than a sticky header is worth, so it scrolls away there. *(This row first said 60 rem and "~115 px". Both were wrong — the threshold moved when the seven-item case was measured, and 115 px was never a height the header took at any width. Corrected against headless-Chrome measurements at thirteen widths.)* **(b)** "Condenses on scroll" is now **a hairline rule and a shadow, not a size change.** A `position: sticky` header stays in normal flow, so its layout box sits at the top of the document whatever the viewport shows; shrinking its padding shortens that box and lifts every page below it — a scroll-linked layout shift on every page, against the CLS < 0.05 budget in `04-seo-spec.md`. Implemented with `animation-timeline: scroll()`, **longhands only** — see the note in the component about what the minifier does to the `animation` shorthand |
| `SiteFooter` | Three-column sitemap, contact block, designations, entity line |
| `Eyebrow` | Mono label with optional maroon dot |
| `SectionHeading` | Eyebrow + display heading + optional lede, one measure |
| `Button` | Variants `primary` (maroon) · `ghost` (outlined) · `gold` (ink bg, gold-l text). Renders `<a>` or `<button>` correctly |
| `Pill` | Small bordered label for designations and sector chips |
| `CredentialRow` | Three or four credential slots. **Never matter counts**`AGENTS.md` §4 |
| `PracticeCard` | Sector chip, heading, one paragraph, arrow link |
| `ProcessStep` | Numbered step, timing, body |
| `ArticleCard` | Title, description, date, topic pills, reading time |
| `Prose` | Long-form wrapper. Owns all typographic defaults for MDX |
| `SEO` | Title, description, canonical, OG, Twitter, JSON-LD — see `04-seo-spec.md` |
**Focus states.** Every interactive element gets a visible focus ring:
`outline: 2px solid var(--maroon); outline-offset: 3px`. Use `:focus-visible`.
Never `outline: none` without a replacement — the old build removed it globally.
---
## Accessibility floor
Not a polish pass. A build requirement.
- One `<h1>` per page; heading levels never skipped.
- Landmarks: `<header>`, `<nav>`, `<main>`, `<footer>`. Skip-to-content link first
in tab order.
- Every image has `alt`. Decorative images get `alt=""`.
- Colour never carries meaning alone.
- All functionality reachable by keyboard; focus order matches visual order.
- Forms: real `<label>` elements, `aria-describedby` for hints, errors announced
with `role="alert"` and tied to their field.
- Touch targets ≥ 44 × 44 px.
- Test at 200% zoom and at 320 px width.
- Every page must be readable and navigable with JavaScript disabled.