Build and deploy / build-and-deploy (push) Failing after 4s
Three pages, five in the build, zero JavaScript. /arbitration/ carries §4's paired-disclosure condition on four surfaces and Q39's struck universal appears in no form. /med-arb/ meets the procedural-fairness objection at the level of process design and ships deliberately without Pouya's own protocol commitments, which are Q54. docs/01 directed the mediation page to name the "ADRIC Model Mediation Rules". No such document exists — 0 occurrences across all four of ADRIC's rules pages against 10 for "National Mediation Rules"; "Model" belongs to the Model Dispute Resolution Clause inside the rules. Caught only because R14 requires the source before the claim. docs/reference/adric-rules.md + adric-extract/ carry it, with the digest drift measured rather than assumed: the HTML changes per request, the text extracts are byte-stable, so the extracts are the artefact. Four review passes, 21 defects, and the pattern was mine: I wrote the Q54 gate into the page and then breached it four times, then round 2 found two survivors of round 1's own fixes and one defect round 1's fix created. Also removed a <title> naming a practised role §4 does not grant, a habitual presupposing awards issued, and a claim about what ADRIC's rules permit that my own reference doc says is unsupported. Two instrument failures caught before they became conclusions: touch targets measured over file:// with no CSS loaded (uniform 18px, including on a .btn with a 44px floor), and a schema.org validator call that parsed nothing and returned 0 warnings for everything. Both re-run with the instrument validated first. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0148NztQskLKKApP5SzAA78e
314 lines
19 KiB
Markdown
314 lines
19 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 — **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 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. **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** |
|
||
|
||
**Step 4's three pages added 2026-08-28**, and they needed no fix of their own
|
||
— the guards this table produced were applied while writing them rather than
|
||
after measuring:
|
||
|
||
| Page | 390 px | 360 px | 320 px |
|
||
|---|---|---|---|
|
||
| `/mediation/` | 3 px | 23 px | 63 px |
|
||
| `/arbitration/` | 3 px | 23 px | 63 px |
|
||
| `/med-arb/` | **0 px** | 23 px | 63 px |
|
||
|
||
At the **default** root size all three are **0 px at every width measured**.
|
||
The element-level sweep at 320/root-32, predicate `right > clientWidth`,
|
||
names **three** `PracticeCard` chips on `/mediation/` — Construction,
|
||
Technology, Shareholder — plus the header and footer brand elements, which
|
||
appear on all five pages. *(This read "six" for one pass. Six is the count of
|
||
`article.card` with `scrollWidth > clientWidth`, a different predicate on a
|
||
different element; `adversarial-reviewer` re-ran it and no predicate yields
|
||
six chips. A number in this table has to be re-runnable, which is the whole
|
||
reason the table exists.)* All three are byte-identical to `/`'s — same
|
||
widths, same offsets, same text — so step 4 introduced no new element-level
|
||
overflow.
|
||
|
||
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 over, from the header's
|
||
deliberate `flex-wrap: nowrap` above 66 rem plus `white-space: nowrap` on the
|
||
brand name — **602 px on `/` and 944 px on every other page**, because
|
||
`SiteHeader` gates the tagline on `!isHome`, so the masthead is wider
|
||
everywhere except home. *(This read a flat "602 px" until 2026-08-28. That
|
||
figure was measured on `/` and generalised; `/about/` was already 944 when it
|
||
was added to this table, and step 4's three pages are 944 too. Found by
|
||
`adversarial-reviewer`.)* 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.5654–1.5657 against the master's
|
||
1.5657.
|
||
- **A touch-target measurement of the wrong box is not a finding.** The eight
|
||
cards on `/` report 26–39 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.
|