/* ============================================================================ Global base. Spec: docs/02-design-system.md ========================================================================= */ @import './tokens.css'; /* --- Fonts: self-hosted, subset, swap. No runtime Google Fonts request. ---- Files and their provenance: docs/reference/fonts-provenance.md. Filenames are stable on purpose — a preload needs a path that does not change between builds, which rules out Astro's hashed asset pipeline. `?v=1` IS LOAD-BEARING. scripts/deploy-local.sh serves /fonts/* with `max-age=31536000, immutable`, so a returning visitor holds these bytes for a year and a CloudFront invalidation cannot reach their browser cache. Bump the query when a file's contents change — here AND on the preload in BaseLayout.astro, which must match byte for byte or the preload is a second, wasted request instead of a warmed cache. The `latin` cut of each face is listed FIRST and the `latin-ext` cut second. Order matters: where two @font-face rules for one family both match a codepoint, the last wins. Latin-ext is the wider, heavier file; putting it last would hand it every ASCII character on the page. */ @font-face { font-family: 'Instrument Serif'; src: url('/fonts/instrument-serif-latin-400-normal.woff2?v=1') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { font-family: 'Instrument Serif'; src: url('/fonts/instrument-serif-latin-ext-400-normal.woff2?v=1') format('woff2'); font-weight: 400; font-style: normal; font-display: swap; unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } /* Italic is the one flourish the design allows (docs/02) — a phrase inside a headline, never a paragraph. Latin only; there is no latin-ext italic file. */ @font-face { font-family: 'Instrument Serif'; src: url('/fonts/instrument-serif-latin-400-italic.woff2?v=1') format('woff2'); font-weight: 400; font-style: italic; font-display: swap; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* Geist and Geist Mono are variable fonts: one file spans the whole weight axis, so 300/400/500/600 cost nothing extra. `font-weight: 100 900` declares the axis range the file actually carries — narrowing it here would make the browser synthesise weights it already has. */ @font-face { font-family: 'Geist'; src: url('/fonts/geist-latin-wght-normal.woff2?v=1') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } @font-face { font-family: 'Geist'; src: url('/fonts/geist-latin-ext-wght-normal.woff2?v=1') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; } @font-face { font-family: 'Geist Mono'; src: url('/fonts/geist-mono-latin-wght-normal.woff2?v=1') format('woff2-variations'); font-weight: 100 900; font-style: normal; font-display: swap; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; } /* --- Reset ---------------------------------------------------------------- */ *, *::before, *::after { box-sizing: border-box; } * { margin: 0; } html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; /* No offset by default: below 66rem the header is not sticky, so nothing is covering the target. See the media query below. */ scroll-padding-top: var(--space-4); } /* The header is sticky from 66rem up, and `scroll-padding-top` has to clear it or "Skip to content" drops the reader behind it — the one control that exists specifically for keyboard users, landing them on content they cannot see. `--header-h` is a FLOOR at the default text size, so the `max()` ramp is what carries the cases where the masthead reflows taller (`AGENTS.md` Q61). ⚠️ THREE THINGS HERE ARE LOAD-BEARING AND EACH BREAKS SILENTLY. 1. `1lh` and not `1rem`/`1em`. Chrome's minimum-font-size setting enlarges text while `rem` keeps resolving at 16px; the font-metric units read the USED size and track it. `rem` here measures 97px against a 270.56px header — no error, no warning, focus behind the header. 2. The plain declaration comes FIRST and is not redundant. An engine without `lh` discards the whole `max()` as invalid, and the property then falls back to `--space-4` = 16px, which is worse than no fix at all. 3. `1lh` on `` is immune to the `font-display: swap` window ONLY because `` keeps the UA font family — `--font-sans` is set on `body`. Moving the family up to `html` makes this offset depend on whether a webfont has arrived. Do not. One case is still short: fallback metrics with a seventh nav item, gated by a build failure in `SiteHeader` (`AGENTS.md` R20). `docs/02` §Reflow has the measurements. */ @media (min-width: 66rem) { html { scroll-padding-top: calc(var(--header-h) + var(--space-4)); scroll-padding-top: max( calc(var(--header-h) + var(--space-4)), calc(10lh - 83px) ); } } body { background: var(--bg); color: var(--text); font-family: var(--font-sans); font-size: var(--text-base); font-weight: var(--weight-normal); line-height: var(--leading-body); -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; min-height: 100vh; /* No `overflow-x: hidden`. It was here, and it was hiding a real defect: at 320px the page overflowed by 72px and three cells sat outside the viewport with no scrollbar to reach them — WCAG 1.4.10 content loss, silently masked. A global overflow clamp turns every future layout bug on nineteen pages into an invisible one. Fix the overflow; do not hide it. */ } img, picture, video, canvas, svg { display: block; max-width: 100%; } img { height: auto; } input, button, textarea, select { font: inherit; color: inherit; } p, h1, h2, h3, h4, h5, h6 { overflow-wrap: break-word; } ul[role='list'], ol[role='list'] { list-style: none; padding: 0; } /* --- Type ----------------------------------------------------------------- */ h1, h2, h3, h4 { font-weight: var(--weight-normal); text-wrap: balance; } .display { font-family: var(--font-serif); font-weight: var(--weight-normal); line-height: var(--leading-display); letter-spacing: var(--tracking-display); } /* The one flourish the design allows. One italic phrase per headline, max. */ .display .it { font-style: italic; } /* The class is the type treatment; `Eyebrow.astro` is the label component. A real heading may carry the class — the footer's columns do — but an eyebrow above a heading is never itself an , which is what the component enforces. */ .eyebrow { font-family: var(--font-mono); font-size: var(--text-eyebrow); font-weight: var(--weight-medium); letter-spacing: var(--tracking-eyebrow); text-transform: uppercase; color: var(--text-meta); } .eyebrow .dot { display: inline-block; inline-size: 6px; block-size: 6px; border-radius: 50%; background: var(--accent); margin-inline-end: var(--space-3); vertical-align: 0.15em; } /* NO GLOBAL `p { max-inline-size }`. It was here, and it capped every paragraph on the site — inside cards, footers, and form hints — so components had to opt back out one by one, and it made `.prose` below a class with no effect, since every

was already capped. Long-form opts IN. */ a { color: var(--link); text-decoration-thickness: 1px; text-underline-offset: 0.2em; } a:hover { color: var(--accent-hover); } /* --- Focus: visible, always. The previous build removed it globally. ------- */ :focus-visible { outline: 2px solid var(--focus-ring); outline-offset: var(--focus-offset); border-radius: var(--radius-sm); } :focus:not(:focus-visible) { outline: none; } .skip-link { position: absolute; inset-block-start: var(--space-2); inset-inline-start: var(--space-2); z-index: var(--z-skip); padding: var(--space-3) var(--space-5); background: var(--accent); color: var(--text-inverse); border-radius: var(--radius-md); transform: translateY(-200%); transition: transform var(--dur-fast) var(--ease); } .skip-link:focus { transform: translateY(0); } ::selection { background: var(--accent); color: var(--text-inverse); } /* --- Layout --------------------------------------------------------------- */ .wrap { inline-size: 100%; max-inline-size: var(--width-content); margin-inline: auto; padding-inline: var(--gutter); } .wrap-wide { max-inline-size: var(--width-wide); } /* The reading measure, opted into. docs/02 caps body copy at 68ch; the old build ran full-bleed paragraphs at 1400px. Wrap long-form in `.prose`, and let the MDX `Prose` component own it for articles. */ .prose, .prose p { max-inline-size: var(--width-prose); } /* `.prose` HAD NO PARAGRAPH SPACING, AND NOTHING ANYWHERE SUPPLIED IT. The reset above sets `* { margin: 0 }`, so a bare `.prose` with two

children rendered them as one block. Measured on `/about/` §Language: **gap between paragraph 1 and paragraph 2 = 0.0px** — "…history arrive together." running straight into "Working in the parties' own language…", on screen and in the printed PDF. It survived step 2 because BOTH of `/`'s prose blocks supply their own spacing: `.approach-prose` uses `display:flex; gap`, and `.bio-prose` on `/about/` has its own `> p + p`. So the only two call sites happened to opt out of the defect. The rule belongs HERE, where `.prose` lives, or it has to be remembered on all fifteen remaining pages. `:where()` KEEPS THE SPECIFICITY AT ZERO so a component's own rule for the SAME PROPERTY wins without `!important`. ⚠️ IT DOES NOT PROTECT AGAINST A FLEX `gap`, AND THIS COMMENT ONCE CLAIMED IT DID — "Verified: with `:where()` the flex container's gap governs and this contributes nothing." That was false and was measured false minutes later: `:where()` lowers SPECIFICITY, which only matters when two rules set the same property. A flex `gap` is a different property, so gap and margin both apply and add. `/`'s `.approach-prose` went **24px -> 48px** on the strength of that sentence. It has been converted to use this rule instead of a `gap`, and `.bio-prose`'s duplicate `> p + p` was removed for the same reason. If a future block needs different spacing, override `margin-block-start` — do not reach for `gap`. */ :where(.prose) > p + p { margin-block-start: var(--space-5); } /* THE PAGE-OWNED WRAPPER FOR `SectionHeading`, and it lives here because it was byte-identical in two pages with seventeen to come. It exists only because a parent cannot style a child component's root (`CLAUDE.md`; measured on `SectionHeading`), so every page that uses a section heading needs a wrapper it owns — which means every page needs this rule. Same argument that extracted `ContactBand`, applied to a rule instead of a component. */ .section-head { margin-block-end: var(--space-7); } /* THE AUTO-FIT GRID GUARD, IN ONE PLACE. `adversarial-reviewer` counted five copies of the same five-line comment across three pages at step 4 — 25 lines explaining 5 identical declarations — plus a sixth, differently worded copy on `/about/`. That is this repo's own "a second copy is a second thing to keep true", and D19's comment rule, both breached by a paragraph about a guard. THE GUARD: `min(Nrem, 100%)`, never a bare rem. A bare rem floor is a HARD minimum, so at a 200% default font size (root 32px — a real accessibility setting, not page zoom) an 18rem floor becomes 576px and the track refuses to shrink. Measured on `/`: 234px of document overflow at a 390px viewport, down to 3px once the three grids took `min()`. docs/02 §Accessibility floor carries the full table. Set `--grid-min` on the element; default 18rem. ⚠️ NOT YET THE ONLY HOME, and the remaining copies are listed rather than claimed swept. `git grep -n 'auto-fit' -- src`, 2026-08-28: src/pages/index.astro:875 minmax(min(18rem, 100%), 1fr) src/pages/index.astro:916 minmax(min(13rem, 100%), 1fr) src/pages/about.astro:965 minmax(min(18rem, 100%), 1fr) src/pages/about.astro:1007 minmax(min(16rem, 100%), 1fr) Those four are on pages that already shipped; converting them after the review cap would be an unreviewed change to live CSS. They move onto this class at step 5, when `/practice/*` is in the same files. Until then a correction here reaches three call sites, not seven — say that rather than letting a reader believe the guard has one home. */ .grid-autofit { display: grid; grid-template-columns: repeat( auto-fit, minmax(min(var(--grid-min, 18rem), 100%), 1fr) ); } .section { padding-block: var(--section-y); } .section-alt { background: var(--bg-alt); } .section-inverse { background: var(--bg-inverse); color: var(--text-inverse); } .section-inverse .eyebrow, .section-inverse .text-meta { color: var(--text-inverse-2); } /* The conversion band. Maroon rather than ink so it reads as an action and not as a second footer — the real footer is ink and sits directly beneath it. Cream on maroon measures 12.29:1; gold-l on maroon 8.11:1 (docs/02). */ .section-accent { background: var(--accent); color: var(--text-inverse); } .section-accent .eyebrow, .section-accent .text-meta { color: var(--text-inverse-2); } /* Both inverse families need a focus ring that is visible ON them: the default ring is --maroon, which is 1.00:1 against the accent band's own background and 1.21:1 against ink. Gold-l measures 8.11:1 on maroon and 11.09:1 on ink. Without this the ring exists and cannot be seen, which is the same failure as not having one. */ .section-inverse :focus-visible, .section-accent :focus-visible { outline-color: var(--gold-l); } /* AND THE LINK COLOUR, for the same reason and measured the same way. `--link` is --maroon-l, documented at 8.95:1 ON CREAM and never overridden for an inverse ground: on --ink it measures 1.88:1, and :hover (--maroon-d) 1.10:1. That is WORSE than the gold-on-cream 2.10:1 this project treats as the defect that must never ship. `SiteFooter` sets its own `.footer-col a` colour, which is the only reason this had not fired before — `/process/` shipped the site's first body link on a dark band. Values: gold-l 11.09:1 on ink, cream 16.81:1. Here rather than in the page, because the next inverse-ground link hits the same hole. Found by `adversarial-reviewer`, 2026-08-30. */ .section-inverse a:not(.btn), .section-accent a:not(.btn) { color: var(--text-inverse-2); } .section-inverse a:not(.btn):hover, .section-accent a:not(.btn):hover { color: var(--text-inverse); } /* The eyebrow's dot is a --accent (maroon) box, so recolouring only the TEXT for an inverse ground leaves the dot at 1.21:1 on ink and 1.00:1 on the accent band — present in the markup, invisible on the page. Colour never carries meaning alone here (the dot is decorative and aria-hidden), so this is a design defect rather than a WCAG one; it is still a mark nobody can see. Measured 2026-08-27. */ .section-inverse .eyebrow .dot, .section-accent .eyebrow .dot { background: var(--text-inverse-2); } /* Pill reads its colours from custom properties, which are the one thing that crosses Astro's component-scope boundary (they inherit). See Pill.astro. */ .section-inverse, .section-accent { --pill-border: var(--line-dark); --pill-fg: var(--text-inverse-2); /* `Button` — THE GAP `a:not(.btn)` ABOVE LEFT OPEN. That rule excludes `.btn` on the reasoning that a button carries its own colours; `.btn-ghost`'s are ink on an ink-alpha border, i.e. the background of both these grounds. `--line-dark` is cream at 14% alpha and reads as an edge on ink and on maroon. See `Button.astro` for why these are custom properties. */ --btn-ghost-fg: var(--text-inverse); --btn-ghost-border: var(--line-dark); --btn-ghost-fg-hover: var(--text-inverse-2); --btn-ghost-border-hover: var(--text-inverse-2); --btn-gold-border: var(--line-dark); /* `DefinitionGrid`'s

. Added 2026-08-29: --text-meta is --muted, which tokens.css marks ON CREAM ONLY (3.07:1 on ink), and `/practice/` is the first page to put that component on an inverse ground. */ --def-name-fg: var(--text-inverse-2); } hr { border: none; border-block-start: 1px solid var(--border); } .rule-gold { border: none; border-block-start: 1px solid var(--rule); } .visually-hidden { position: absolute; inline-size: 1px; block-size: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } /* --- Reveal ---------------------------------------------------------------- Scroll-driven CSS. There is NO JavaScript on this site, and this block is why: the reveal used to be an inline IntersectionObserver in , which collided with the Content-Security-Policy docs/05-backend-spec.md specifies (`script-src 'self'`, no `unsafe-inline`, "use a hash or nonce for the reveal script"). A per-build hash is a moving target and drifts from the policy. `animation-timeline: view()` is what docs/02 §Motion offers as the alternative, and it removes the script — and the problem — entirely. The @supports gate is load-bearing, not defensive. Without it a browser that ignores `animation-timeline` would run the animation once against the document timeline at load; with it, that browser gets no animation and fully visible content. Content is never hidden behind a feature that might not arrive. The previous build had this backwards and shipped a blank page to anything without JavaScript. */ @supports (animation-timeline: view()) { @media (prefers-reduced-motion: no-preference) { /* LONGHANDS ONLY. `animation: reveal-in linear both` beside `animation-timeline: view()` is folded by Lightning CSS on minify into `animation: linear both reveal-in view()`, which is invalid — `view()` is not a component of the shorthand — so the whole declaration is thrown away. It works in `npm run dev` and is dead in `npm run build`. This is the same defect the header's condense had; it was found there first and written straight back into the fix for it. Grep dist for it (Phase 5). */ .reveal { animation-name: reveal-in; animation-duration: 1ms; animation-timing-function: linear; animation-fill-mode: both; animation-timeline: view(); animation-range: entry 0% cover 22%; } .reveal-stagger > * { animation-name: reveal-in; animation-duration: 1ms; animation-timing-function: linear; animation-fill-mode: both; animation-timeline: view(); } /* Stagger is expressed as timeline range, not delay: a scroll-driven animation has no wall clock to delay against. Each child completes a little further into the scroll than the one before. Six children by design (docs/02) — a seventh simply lands with the sixth. */ .reveal-stagger > *:nth-child(1) { animation-range: entry 0% cover 18%; } .reveal-stagger > *:nth-child(2) { animation-range: entry 0% cover 22%; } .reveal-stagger > *:nth-child(3) { animation-range: entry 0% cover 26%; } .reveal-stagger > *:nth-child(4) { animation-range: entry 0% cover 30%; } .reveal-stagger > *:nth-child(5) { animation-range: entry 0% cover 34%; } .reveal-stagger > *:nth-child(6) { animation-range: entry 0% cover 38%; } } } @keyframes reveal-in { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: none; } } @media (prefers-reduced-motion: reduce) { html { scroll-behavior: auto; } *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } /* Belt and braces. The @supports block above is already gated on no-preference, so nothing here should be animating at all — this keeps the guarantee true even if a later rule forgets the gate. */ .reveal, .reveal-stagger > * { animation: none !important; opacity: 1 !important; transform: none !important; } } /* A scroll-driven animation has no timeline when printing, so every revealed element would render at its `from` state — which is `opacity: 0`. Measured before this block existed: printing the page to PDF dropped four card headings from the output entirely. `/about/` is written to be printed by people evaluating an appointment; content that vanishes at Cmd-P is not a cosmetic problem. */ @media print { .reveal, .reveal-stagger > * { animation: none !important; opacity: 1 !important; transform: none !important; } } /* --- Print: the About page will be printed by people evaluating an appointment */ @media print { body { background: #fff; color: #000; font-size: 11pt; } .site-header, .site-footer, .skip-link, .no-print { display: none !important; } a[href^='http']::after { content: ' (' attr(href) ')'; font-size: 9pt; } .section { padding-block: var(--space-5); } /* THE INVERSE GROUNDS HAD TO BE NEUTRALISED AND WERE NOT — and this block's own heading says why it matters: the About page is printed by people evaluating an appointment. `print-color-adjust` defaults to `economy`, so a UA drops the BACKGROUND and keeps the text. Chrome's default print dialog has "Background graphics" unchecked, so `.section-inverse` and `.section-accent` printed cream text on white paper. Measured with `Page.printToPDF`, `printBackground: false`, rasterised at 150 dpi: the dominant glyph colour across the whole arc block was **rgb(166,164,161) — 2.49:1 against white**, and that grey is Chrome's own legibility fudge. The DECLARED colour is cream at ~1.04:1, which is what a UA without that fudge renders. With `printBackground: true` the pages are correct, which is what isolates the cause. What vanished when it was measured was `/about/`'s inverse-ground arc block plus the contact band. (That arc section was deleted on 2026-08-29; the defect it exposed was never about that section — any `section-inverse` reproduces it, and `/practice/*` and `/arbitration/` still carry one.) `!important` because the rules being overridden are class-level and these must win regardless of which section variant a future page uses. */ /* TOKENS FIRST, THEN CLASSES — and the token half is the part that works. A class-by-class version of this block shipped first and MISSED TWO ELEMENTS, both measured under print-media emulation: `.approach-metaphor` on `/` and `.btn-gold` on both pages stayed at `rgb(226,200,154)` — gold-l, which is **1.62:1 against white paper** once the ground is dropped. One is the paragraph carrying the infinity-mark argument; the other is the call to action. Enumerating class names cannot work here: `--text-inverse-2` is consumed by page-scoped and component-scoped rules this file has never heard of, and there will be seventeen more pages of them. Custom properties INHERIT, and that is the one mechanism that crosses Astro's component-scope boundary (see `Pill.astro`). Redefining the three inverse tokens on the section itself therefore reaches every descendant rule, including ones written after this block. */ .section-inverse, .section-accent { background: transparent !important; color: #000 !important; --text-inverse: #000; --text-inverse-2: #000; --pill-fg: #000; --def-name-fg: #000; --pill-border: #000; --rule: #000; } /* The belt-and-braces half. These four set a colour LITERALLY rather than through a token, so the inheritance above does not reach them. */ .section-inverse .eyebrow, .section-accent .eyebrow, .section-inverse .lede, .section-accent .lede { color: #000 !important; } /* The dot is a background, not text, so it does not follow `color`. */ .section-inverse .eyebrow .dot, .section-accent .eyebrow .dot { background: #000 !important; } /* EVERY BUTTON, NOT JUST THE ONES ON AN INVERSE GROUND. Two of the three variants set light text on a coloured background of their own (`.btn-primary` cream-on-maroon, `.btn-gold` gold-on-ink), and a UA at `print-color-adjust: economy` drops the background and keeps the text. Measured against white paper: `.btn-gold` 1.62:1, `.btn-primary` **1.07:1**. The first version of this rule was scoped to `.section-accent .btn-gold`, which fixed the contact band and left the HERO CTA on `/` unreadable — `.btn-primary` sits on cream, inside no inverse section at all, so nothing in this block reached it. A print sweep of all 89 visible text elements found it; the class-scoped version had passed its own narrower check. */ .btn { background: transparent !important; color: #000 !important; border-color: #000 !important; } }