Files
adr-sml/docs/02-design-system.md
T
Pouya LajevardiandClaude Opus 5 c576b9a85f
Build and deploy / build-and-deploy (push) Failing after 6s
feat: build step 3 — /about/, minus its memberships; close Q40–Q43
Applies Pouya's rulings on Q42, Q41(a)(b)(c), Q43 and Q40, then builds
`/about/` — six of docs/01's seven items.

`/about/` ships WITHOUT a memberships group. R10 is a prohibition on
shipping a page that lists memberships before they are re-confirmed; the
re-confirmation is a fact only Pouya holds and was not obtained. The
first version published all four and disclosed the gap in five places
instead; both review agents rejected that. Q44 carries the question.

Rulings:
- Q42 — ENE, dispute-system design and pre-dispute technical advisory
  rowed; settlement counsel struck as a partisan role. The strike
  exposed a hole in the offering test, which now states the prior
  question it was missing.
- Q41(a) — Q37 reaches prose, and prose is held to a higher bar. The
  sentence is now one constant, ASYMMETRY_LINE, because two hand-typed
  copies had already diverged inside one session.
- Q41(b) — not restored; the implication turned out to be in three
  places, two of which survived the sweep that closed it.
- Q41(c) — verified against the LAT's own Rules and extracted into
  docs/reference/lat-case-conference.md. Rule 2.4 makes "Pre-Hearing
  Conference" the Tribunal's own term for a case conference; the Rules
  contain zero occurrences of `mediat` in 66,593 characters.
- Q43 — the timings are service commitments; PROCESS_FRAMING renders
  adjacent to them, not in a lede above.
- Q40 — bundled to step 7 as R15, blocking cutover.

Four review passes, 43 findings, nine of them defects in their own
predecessors' fixes. The worst was mine: the false universal Q39 struck
reached a public page. Also fixed a portrait ladder that upscaled 1.93x
at 1024/DPR2 on BOTH pages — the shipped home page included — because
its 960 ceiling was derived from the layout range where the image is
narrowest.

Verified: check/lint/build/audit clean; 0 upscaling across 11 device
profiles; 0 overflow and 0 over-wide elements at 13 widths; 0 contrast
failures across 127 and 88 painted pairs; 0 print failures against white
paper; reveal 0 hidden under reduced-motion and print; zero JavaScript.
Lighthouse NOT RUN — tool unavailable until step 7 (R11). HTML validator
NOT RUN.

Opens Q44 (memberships), Q45 (PDF bio), Q46 (offering-test gating; the
glossary standard), Q47 (jobTitle without worksFor). Adds R15.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148NztQskLKKApP5SzAA78e
2026-08-28 12:10:41 -04:00

287 lines
17 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.
**It is a shaded ribbon, not a stroked curve**: a band of variable width that
twists in three dimensions, maroon flowing into champagne, passing over itself
at the crossing. Ink bounding box **2668 × 1704 = 1.5657:1**
`[verified 2026-08-26 — measured against the master]`. Source of truth:
`src/assets/brand/sml-infinity-mark.png`; provenance in
`docs/reference/brand-assets.md`.
- 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` | ⚠️ **Currently a raster — a documented, temporary exception to this rule** (`AGENTS.md` Q38, R13). The mark is gradient-mesh artwork rather than flat vector paths. An SVG *is* held and it renders faithfully — **and it renders faithfully because it IS the raster**: 257,278 bytes wrapping **seven embedded base64 PNGs**, against **3,063 B** for the AVIF a Retina browser takes in the header — **84×**. *Figures re-stated 2026-08-27 because a second, larger call site now exists and the single number had become misleading:* the mark ships at **two intrinsic widths**, 64 px (header, footer) and 232 px (the home page's approach section, which renders at 225.5 px). At 232 px a DPR-2 device takes **14,555 B** and DPR-3 **22,639 B**, so the ratio against the SVG there is ~11×, not 84×. The full ladders are in `docs/reference/brand-assets.md`; do not quote one number as if it covered both. Inlining it would breach `CLAUDE.md`'s no-base64-images rule. *Pouya settled the characterisation on 2026-08-27: a first draft called the file "a raster in a vector wrapper", a later draft withdrew that as unfair, and **the withdrawal went too far.** Both halves are true — the fidelity is real, and it is bought with embedded raster, which is exactly why fidelity was never the question.* The exception is about payload and composition. It renders AVIF/WebP with a PNG fallback; a Retina device takes 3,063 B of AVIF, `alt=""` when decorative, aspect ratio pinned to `667 / 426`. **Restore this rule the moment the commissioned vector master lands.** Until then the rule stands unchanged for every other mark or icon: inline SVG, `currentColor`, `aria-hidden` when decorative, never a PNG |
| `SiteHeader` | **Sticky from 66 rem (1056 px) up; static below it.** Practice dropdown as CSS-only `<details>`. **(a)** The one-row header holds the brand, **seven** nav items and the CTA — Insights is the seventh, arriving on its own at build step 7. Binary search on the built page puts the true fit at **1047 px**; 66 rem is the clean token above it. Below 66 rem the nav takes its own row and the header measures **141 px** at tablet widths and **189 px** at 320375 px. **(b)** "Condenses on scroll" is a hairline rule and a shadow, **not a size change** — a `position: sticky` header stays in normal flow, so shrinking its padding lifts every page below it, against the CLS < 0.05 budget. Longhands only; see the component on what the minifier does to the `animation` shorthand. *(This row has been wrong twice, instructively. It first said 60 rem / "~115 px", a height the header never took. It then said 64 rem "with 32 px of clearance" — that 32 px was `.header-inner`'s own `column-gap` mistaken for slack; the real figure at 1024 px with seven items was **21.6 px**, and nothing overflowed only because flexbox crushed the logo inside the brand block. **Measure slack, not gaps.**)* |
| `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. **Both measured 2026-08-27 on `/`:
document overflow 0 at 320, 360, 390, 414, 640, 768, 900, 1024, 1056, 1200,
1216, 1280, 1440 and 1920 CSS px, with zero elements extending past the
viewport.** Page zoom at 200% of 1280 is the 640 column and at 400% is the
320 column, so WCAG 1.4.4 and 1.4.10 are both covered by that sweep.
**A stricter case is not fully clean, and it is recorded rather than left to
be discovered.** With the reader's *default font size* at 200% — root at
32 px, a real accessibility setting and not page zoom — `/` measured **234 px**
of overflow at 390. Brought down in three measured steps:
| Fix | 390 px | 320 px |
|---|---|---|
| as first built | 234 px | 304 px |
| `minmax(min(Nrem, 100%), 1fr)` on three grids | 83 px | 153 px |
| `.credentials` made explicit `repeat(2, minmax(0, 1fr))`; `.feature` padding clamped and `overflow-wrap: anywhere` on its title; `.contact-action` `flex: 0 1 auto` + `min-inline-size: 0` | **3 px** | **63 px** |
**`65 px` corrected to `63 px` on 2026-08-28**, re-measured independently on
the same page and setting. Two pixels, and it is recorded because a table that
reads as the site-wide record has to be re-measurable rather than remembered.
**`/about/` added 2026-08-28** — step 3, and the first page to be measured
against this table rather than establishing it:
| Page and fix | 390 px | 360 px | 320 px |
|---|---|---|---|
| `/about/` as first built | 38 px | 68 px | 108 px |
| `.designation-part` `white-space: nowrap` removed (the separator is held by an NBSP instead) and `overflow-wrap: anywhere` on `.hero-h` | **0 px** | **23 px** | **63 px** |
Two findings from that page specifically. The `nowrap` was **introduced as a
fix** for an orphaned `·` at the end of a wrapped line, and it made the whole
designation item unbreakable — a fix that created a reflow regression, caught
only because this table existed to regress against. And **the type scale is
rem-based**, so an `<h1>` at `--text-5xl` computes to 88 px at root 32 and a
single unbreakable 9-character name ("Lajevardi") exceeds the 224 px content
box at 320 px; `overflow-wrap: anywhere` is the only remedy that reduces
min-content size. `/about/` now measures equal to or better than `/` at all
three widths, and its 320/360 residual is the same header decision.
Command, so the numbers are re-runnable rather than quoted: headless Chrome
over the built `dist`, `document.documentElement.style.fontSize = '32px'`, then
`documentElement.scrollWidth - documentElement.clientWidth`, plus an
enumeration of every element wider than `clientWidth` to name the offender.
Two things worth keeping. **`overflow-wrap: break-word` permits a break at
layout time but does not reduce min-content size** — `anywhere` does, and that
distinction was the whole of one of those fixes. And 1280 px stays **602 px**
over, from the header's deliberate `flex-wrap: nowrap` above 66 rem plus
`white-space: nowrap` on the brand name; the 320 px residual is the same header
plus the display headline's 104 px floor. Undoing either re-opens the measured
step-1 header decision, so they stand. All of this is beyond what this floor
requires — page zoom is clean — so it is a robustness margin rather than a
failure. Revisit if a real reader hits it.
- **Measure the elements, not only the page.** A document-level overflow check
passes while a flex child absorbs the deficit by being crushed — that is how
step 1 shipped a logo at aspect 0.891 under a green "0 overflow at every
width". Assert the rendered geometry of the thing you care about. On `/`
every infinity-mark instance measures 1.56541.5657 against the master's
1.5657.
- **A touch-target measurement of the wrong box is not a finding.** The eight
cards on `/` report 2639 px-tall `<a>` elements and are fine: each card's
whole box is the link's hit area via `::after { inset: 0 }`, verified by
hit-testing nine points per card at three widths (24 cards, 9/9). Hit-test
before enlarging anything. WCAG 2.5.8's inline exception also applies to a
link sitting mid-sentence, and one such link on `/` is deliberately left at
164 x 21.
- Every page must be readable and navigable with JavaScript disabled.