Files
adr-sml/docs/02-design-system.md
T
Pouya LajevardiandClaude Opus 5 165d259f5c feat: build step 2 — the home page; close Q35, Q37, Q39; Q39's answer corrected the register
Pouya's rulings and the page are one commit, not two, because they are not
separable: Q37 changed the credential label the page renders, Q39 scoped the
arbitration copy, and Q35 gave Med-Arb the row that lets the footer link stand.
Splitting them would produce a commit where the register says one thing and the
page says another.

RULINGS

Q39 — ANSWERED, and my assertion was FALSE as a universal. Pouya checked
rather than defended. Family arbitration in Ontario is gated: prescribed
training, 14h on screening for domestic violence and power imbalances, 30h of
Ontario family law for arbitrators outside the bar, 10h ongoing per two years.
claims-auditor produced that counter-example from inside the repo on 2026-08-26
and it was right. The source is now IN the repo per R14 —
docs/reference/ontario-family-arbitration-training.md, retrieved with its
provenance — and §4 states which half it establishes: the family gate directly,
the commercial half only by absence. So "commercial arbitration in Ontario
requires no licence and no designation" is recorded as Pouya's stated position,
unstamped. What disposes of the question is the scope exclusion: he does not do
family arbitration, so it is NOT OFFERED and the gate never bears on the
practice.

The false universal survived in three more live places, found by grep, not
recall: §4's NOT-NEGOTIABLE boundary bullet (the boundary paragraph breaching
its own boundary), §9's Q33 closure row, and a comment in SiteHeader.astro.

Q35 — ANSWERED, all four items, and the answer supplied a gate that was
missing. Med-Arb gets an Offerings row and stays in the footer sitemap. The six
subject-matter labels pass test 1. §4 gains "Subject-matter areas — the
publication gate": competence to accept an appointment, AND a page that frames
it as positioning without claiming history. Nothing in the nav or footer is
unrowed any more.

Q37 — ANSWERED. "Law and engineering" becomes "Legal training and engineering
practice". A degree is not a practice; the parallel was doing the implying.

Q38/R13 — the committed SVG does not close it; the walk-back went too far. It
renders faithfully BECAUSE it is the raster. R13 stays open.

R1 — surfaced and acknowledged; the interim licensure framing is now carried by
a shipped page rather than a spec, which raises the stakes.

BUILD STEP 2

Seven of docs/01's eight home sections, six new components, zero JavaScript.
Section 7 (Latest insights) ships at step 7 with the collection it lists —
recorded in docs/01 and in the page, not just here. Four credential slots, not
three: §4's paired-disclosure condition requires the Q.Arb stage on any page
that offers arbitration. No booking link (R6). The masthead tagline is
suppressed on / (it duplicated the hero eyebrow). The step-1 proof sheet is
deleted, and five live references to it were found by grep.

WHAT THE REVIEWS FOUND — 24 findings across the two passes

claims-auditor FAILED it on ten, every one implication or scope rather than
fabrication, which is where D13 says the risk lives. The four that mattered:
"I mediate and arbitrate" asserted a track record §4 does not hold for
arbitration; the JSON-LD asserted arbitration twice and stated the stage
nowhere (a crawler-only claim is still a claim); "at one published rate with
preparation time included" misdescribed money against docs/07's two day rates
and capped prep allowance; and "Law and engineering are not blended here" was
Q37's struck parallel relocated into prose one day after Pouya struck it.
It also found the Canadian Tax Foundation missing from §9 Q8 — a stale second
copy of a fact that would have bitten at step 3.

adversarial-reviewer found fourteen, three blocking. The first was
class="section-head" on <SectionHeading> never matching — the parent-cannot-
style-a-child defect for the FOURTH time, written into a diff where I had just
added fresh warnings about it to two other components. Measured: 0px margin,
0px gap, headings over the card edges, with astro check and eslint both clean.
I had looked at a screenshot of that section and passed over it. Fixed with a
page-owned wrapper (48px, measured) and the prop is deleted from six components
so it is now a build error. Also: the credential row was never "two up on a
phone" and its comment said it was; PROCESS was hardcoded in the page against
the reason written in site.ts; 83px of residual overflow at a 200% default font
size, now 3px.

Seven more I found myself first, including <Picture widths> declaring the
untouched 1600px master as the <img src> fallback (254,626 B for a 476px slot,
and the build log said "before: 349kB" either way), and a prop named `as`
silently turning off type-checking for a whole component.

VERIFICATION — run, not asserted. Full figures in AGENTS.md entry (w).

npx tsc 6.0.3 · check 0/0/0 · lint clean · build clean · audit 0
1 <script> and it is JSON-LD; non-JSON-LD scripts 0; no JS bundle; identical
page with script execution disabled (444 nodes, 6,578 chars)
Phase 5 minifier check: no `animation` shorthand beside animation-timeline
overflow 0 at 14 widths, AND every mark measured at 1.5654-1.5657 vs 1.5657
one h1, no heading skips, focus order == DOM order across 44 focusables
31 painted contrast pairs at 3 widths, 0 failures
72/72 hit-test points across 8 cards resolve to the card's link
print 0 hidden, reduced-motion 0 hidden
Lighthouse NOT RUN — tool unavailable until step 7 (R11)
HTML validator NOT RUN, and 4 of 12 srcsets carry a duplicate 1x descriptor

Opened for Pouya: Q40 (one OG image for nineteen pages), Q41 (does Q37 reach
prose; may the comparative be restored; what LAT pre-hearing mediation means),
Q42 (the four "also offered" processes have no row), Q43 (the process timings
are published commitments with no row).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0148NztQskLKKApP5SzAA78e
2026-08-27 16:31:56 -04:00

16 KiB
Raw Blame History

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.951.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.
@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 countsAGENTS.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 65 px

    Two things worth keeping. overflow-wrap: break-word permits a break at layout time but does not reduce min-content sizeanywhere 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.