Build and deploy / build-and-deploy (push) Failing after 4s
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
700 lines
26 KiB
CSS
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;
|
|
}
|
|
}
|