Files
adr-sml/docs/02-design-system.md
T
Pouya LajevardiandClaude Opus 5 364b09632e fix: replace the traced infinity mark with the real artwork; add §4 Offerings
Q32 — the traced mark was a WRONG SHAPE and had shipped. Pouya compared
it against the master and rejected it. Two grounds reproduce from the
path and are verified here: all four cubic branches meet the origin at
exactly 90 degrees, so the loops are tangent rather than crossing and at
stroke-width 28 render as two kissing circles (signed crossing number 0;
the strokes fuse across 61% of the mark's height at 2rem); and the
master's ink bbox is 2668x1704 = 1.5657:1. The path is deleted, not kept
as a fallback.

Pouya's 1.23:1 figure is reconciled rather than left dangling: it is the
bounding box of the path's COORDINATES, not the curve. Control points sit
at y +/-160 where the curve reaches +/-120, so the hull is 400x320 and
with stroke 428x348 = 1.2299. A trap rather than a slip — x is monotone,
so the control points give the right width and a 33% inflated height, and
the "does the width look right" check passes.

The real artwork is now in the repo: master, tight crop (the render
source, so the file's aspect ratio IS the mark's), full lockup, and the
SVG. InfinityMark renders AVIF/WebP; a Retina device takes 3,063 B.
Favicons regenerated; favicon.svg deleted.

Q33/Q36 — Pouya accepts arbitration appointments now. §4 gains an
Offerings category: competence for an offering, permission for a
credential, with an explicit boundary so it cannot become a route around
D13. The masthead tagline is restored, and the footer designation strip
now carries "Q.Arb — commenced August 2026" so §4's paired-disclosure
condition is actually met on every page rather than only asserted.

Two conventions added to CLAUDE.md, both earned this session: anything a
spec makes a claim about must be reachable from the repo (R14 — the
traced mark survived two review passes because the artwork was not here
to compare against); and a command that did not run is not evidence of
absence (`timeout` is not installed on macOS, so four Drive reads never
executed and were reported as an empty directory).

Reviews: claims-auditor FAIL/13 and adversarial-reviewer 2 blocking, all
resolved. The severe one was self-inflicted — `flex: none` landed on the
<img> while <Picture>'s <picture> wrapper is the flex item, so the logo
compressed to 28.5x32 at 1024px with seven nav items. The page-level
overflow check passed throughout because the brand block absorbed the
deficit by crushing the mark. Harness now asserts rendered aspect ratio.

Opened: Q38, Q39. Closed: Q32, Q33, Q36. Narrowed: Q35. Added: R13, R14.
AGENTS.md entry (v) carries a RESUME HERE section.

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

221 lines
12 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 — but at 257,278 bytes against 3,063 for the AVIF a Retina browser takes, carrying seven embedded base64 PNGs that would breach `CLAUDE.md`'s no-base64-images rule. The exception is about payload and composition, not about fidelity. 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.
- Every page must be readable and navigable with JavaScript disabled.