Files
adr-sml/src/styles/global.css
T
Pouya LajevardiandClaude Opus 5 bd282aa47d
Build and deploy / build-and-deploy (push) Failing after 4s
feat: production run — Q61 ramp, /404/, CloudFront router, cutover runbook
Five items of Pouya's production run, 2026-09-01.

Q61 — scroll-padding-top becomes a max() ramp on `10lh - 83px`, with the
plain calc() first as the fallback for engines without `lh`. Hidden focus
stops under minimumFontSize=32: 290 of 1,455 -> 0, control build still
290. Default settings byte-identical (0 differences over 352 page-widths x
17 fields). The 12 residual cells at minimumFontSize=16/20 are pre-existing
and unchanged-or-better; reported, not widened, per instruction.

Intake backend + CloudFront — docs/09-cutover-runbook.md is the
copy-paste sequence for admin execution: every command followed by its
verification and expected output, rollback per part, and Part 10 is Q60's
TTL test. infra/cloudfront/router.js is the trailing-slash function
(30-case suite; 8 fail against the pre-review version, incl. a
protocol-relative open redirect). infra/cloudfront/configure.mjs is
dry-run-by-default and idempotent. scripts/intake-env.mjs emits the six
Lambda env vars from src/data/site.ts.

Four launch blockers found by reading the running system:
  - handler.mjs wrote pk/sk; the live table's key is submissionId with no
    sort key, so every submission would have failed validation silently
  - the Lambda invoke permission is scoped to the old route path
  - 22 of 23 pages 403 without the router function
  - there was no 404 page; src/pages/404.astro adds it

Claims audit (D20 cutover pass) — five gloss over-reaches corrected on
/practice/energy/, /practice/insurance/ (x2), /practice/technology/ and
/med-arb/. Three findings left open for Pouya: Q62, the /med-arb/ gloss,
and Q60.

Q62 — one frozen-tripwire pattern added under the freeze's own breach
exception, with a probe and four negative fixtures. check:claims exits 1
until the false /legal/privacy/ sentence is corrected, so both deploy
paths are blocked by a mechanism rather than by memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
2026-09-02 06:52:20 -04:00

700 lines
26 KiB
CSS

/* ============================================================================
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 `<html>` is immune to the `font-display: swap` window ONLY
because `<html>` 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 <h*>, 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 <p> 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 <p>
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 <dt>. 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 <head>, 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;
}
}