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
221 lines
12 KiB
Markdown
221 lines
12 KiB
Markdown
# 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, 11–12 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 320–375 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.
|