# 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. - 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 ``. - Italic display (`.it`) is the one flourish the design allows. One italic phrase per headline, at most. - Never skip a heading level. `

` 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 `` 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` | Inline SVG, `currentColor`, `aria-hidden` when decorative. Never a PNG | | `SiteHeader` | **Sticky from 64rem (1024 px) up; static below it.** Practice dropdown as CSS-only `
`. *Amended 2026-08-26, both halves by measurement:* **(a)** the one-row header has to hold the brand, **seven** nav items and the CTA — Insights is the seventh and arrives on its own at build step 7 — and it does that at 1024 px with 32 px of clearance, not below. Under 64 rem the nav takes its own row and the header measures **137 px** at tablet widths and **185 px** at 320–375 px, which is more of a small viewport than a sticky header is worth, so it scrolls away there. *(This row first said 60 rem and "~115 px". Both were wrong — the threshold moved when the seven-item case was measured, and 115 px was never a height the header took at any width. Corrected against headless-Chrome measurements at thirteen widths.)* **(b)** "Condenses on scroll" is now **a hairline rule and a shadow, not a size change.** A `position: sticky` header stays in normal flow, so its layout box sits at the top of the document whatever the viewport shows; shrinking its padding shortens that box and lifts every page below it — a scroll-linked layout shift on every page, against the CLS < 0.05 budget in `04-seo-spec.md`. Implemented with `animation-timeline: scroll()`, **longhands only** — see the note in the component about what the minifier does to the `animation` shorthand | | `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 `` or `