diff --git a/AGENTS.md b/AGENTS.md index 934b367..5f1ed9d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -52,19 +52,43 @@ is built to be added to continuously, not shipped once. `[verified 2026-08-25]` The site live at `adr.smlcompany.ca` as of this entry: `[verified 2026-08-25]` +> ⚠️ **Several of these measurements are contested — see Q34 (opened +> 2026-08-26).** A re-fetch of the deployed URL that day reproduced some of the +> list exactly and could not reproduce others *from the served HTML*, because +> the served document turns out to be a bundler harness whose application lives +> in nine UUID-named script files that were not fetched. **Do not cite any +> figure below as established, and do not put one in public copy, until Q34 +> closes.** The items marked ⚠️ are the specific ones in doubt. This caveat sits +> here rather than only in §9 because a reader who arrives at a `[verified]` +> stamp will otherwise never learn it is under review. + - It is a **single 2.2 MB HTML file** containing an entire React application, compiled **in the browser at runtime** by Babel Standalone loaded from unpkg. -- React and ReactDOM are loaded as **development** builds from a public CDN. + *Confirmed on re-fetch 2026-08-26: the response is **2,206,032 bytes**, of + which **2,210,921** are inline `" inside any string value +// would close this element early and hand the rest of the payload to the HTML +// parser. Escaping the angle bracket is the whole fix; JSON readers decode it. +const jsonLdText = + jsonLd === undefined ? null : JSON.stringify(jsonLd).replace(/ + + + +
+ Build step 1 · temporary page +
+
+ Everything below is read out of src/styles/tokens.css at build
+ time. The contrast ratios are computed from the token values, not copied from
+ the spec — if a token changes, this page changes with it.
+
+ This page is temporary. It is noindex, it
+ is excluded from the sitemap, and build step 2 deletes it.
+
Display type
+
+ docs/02: display type is used at --text-4xl and above only. It
+ has almost no hinting at small sizes and looks weak below 32 px.
+
Redraw the loop
+Redraw the loop into a line
+Redraw the loop into a line
+Type scale
++ Resize the window. Every step interpolates; there are no breakpoint + jumps. The declaration beside each line is the token itself. +
+{step.token}
+ {step.value}
+ Colour on cream
+
+ Ratios computed at build time against --cream
+ {' '}{hex.cream}. AA is 4.5:1 for body text and 3:1 for large text.
+
+ The hard rule, shown without breaking it. Gold is a rule,
+ a border, and an icon stroke. The identical hue fails at
+ {contrast(hex.gold, hex.cream).toFixed(2)}:1 as text on cream and
+ passes at {contrast(hex.gold, hex.ink).toFixed(2)}:1 on ink — the
+ colour is not the problem, the pairing is. Both blocks below are the
+ same --gold.
+
--gold on --cream
+
+ {contrast(hex.gold, hex.cream).toFixed(2)}:1 · never as text
+
+
+ --gold on --ink
+
+ {contrast(hex.gold, hex.ink).toFixed(2)}:1 · text is fine here
+
+
+ Components
+
+ Tab through this section. Every interactive element takes a visible
+ focus ring — 2 px maroon, 3 px offset, on
+ :focus-visible. Never outline: none without a replacement.
+
Measure
+on the site — long-form asks for the measure, and this block is + demonstrating the very thing it uses. */ + } +
+ Body copy is capped at --width-prose, 68 ch. Past
+ roughly 75 characters the eye starts losing the beginning of the next
+ line on the return sweep, and long-form copy stops being read. This
+ paragraph sits at the cap. It is the single largest lever on whether a
+ page of prose is actually finished, and it costs nothing to set.
+
+ Line height is 1.6 for body and 0.98 for display. Headings balance
+ with
+ text-wrap: balance, so a two-line headline breaks evenly
+ rather than leaving one word stranded.
+
Spacing
+{step.token}
+
+ {step.value}
+ Colour on ink
+
+ --muted is the trap: it passes on cream at 5.47:1 and fails on
+ ink. Secondary text on dark is --gold-l or cream.
+
Motion
+
+ The four cards below carry .reveal-stagger. Scroll them
+ into view. With JavaScript disabled, or with reduced motion requested,
+ they are simply present — the animation is layered onto a page that
+ already reads.
+
Stagger step 1
Stagger step 2
Stagger step 3
Stagger step 4
was already capped. Long-form opts IN. */ a { color: var(--link); @@ -202,7 +276,11 @@ a:hover { .wrap-wide { max-inline-size: var(--width-wide); } -.prose { +/* 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); } .section { @@ -241,53 +319,81 @@ hr { border: 0; } -/* --- Reveal --------------------------------------------------------------- - Progressive enhancement, not a dependency. Content is rendered and visible - in the HTML; `.reveal` only takes effect once JS adds `js-reveal` to . - If the observer never runs, every page reads normally. The previous build - had this backwards and shipped a blank page to anything without JS. */ +/* --- 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. -.js-reveal .reveal { - opacity: 0; - transform: translateY(20px); + 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%; + } + } } -.js-reveal .reveal.is-in { - opacity: 1; - transform: none; - transition: - opacity var(--dur-reveal) var(--ease), - transform var(--dur-reveal) var(--ease); + +@keyframes reveal-in { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: none; + } } -.js-reveal .reveal-stagger > * { - opacity: 0; - transform: translateY(16px); -} -.js-reveal .reveal-stagger.is-in > * { - opacity: 1; - transform: none; - transition: - opacity var(--dur-reveal) var(--ease), - transform var(--dur-reveal) var(--ease); -} -.js-reveal .reveal-stagger.is-in > *:nth-child(1) { - transition-delay: 0ms; -} -.js-reveal .reveal-stagger.is-in > *:nth-child(2) { - transition-delay: 70ms; -} -.js-reveal .reveal-stagger.is-in > *:nth-child(3) { - transition-delay: 140ms; -} -.js-reveal .reveal-stagger.is-in > *:nth-child(4) { - transition-delay: 210ms; -} -.js-reveal .reveal-stagger.is-in > *:nth-child(5) { - transition-delay: 280ms; -} -.js-reveal .reveal-stagger.is-in > *:nth-child(6) { - transition-delay: 350ms; -} -/* Stagger caps at six children by design. */ @media (prefers-reduced-motion: reduce) { html { @@ -301,8 +407,27 @@ hr { transition-duration: 0.01ms !important; scroll-behavior: auto !important; } - .js-reveal .reveal, - .js-reveal .reveal-stagger > * { + /* 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; } diff --git a/src/styles/tokens.css b/src/styles/tokens.css index e8831fc..e60e8fb 100644 --- a/src/styles/tokens.css +++ b/src/styles/tokens.css @@ -102,10 +102,24 @@ --space-10: 8rem; /* 128 */ --space-11: 10rem; /* 160 */ - --section-y: clamp(var(--space-9), 6vw + 2rem, var(--space-11)); + /* docs/02: "Section rhythm: --space-9 (96px) mobile, --space-11 (160px) + desktop." The previous curve was `6vw + 2rem`, which reaches 160px only at + a 2133px viewport — measured 128px at 1600px, 108.8px at 1280px. The upper + bound was unreachable on any real screen, so the token read as if it + delivered a rhythm it never delivered. `9vw + 1rem` hits 160px at 1600px + and still clamps to 96px on a phone. [measured 2026-08-26] */ + --section-y: clamp(var(--space-9), 9vw + 1rem, var(--space-11)); /* --- Layout ------------------------------------------------------------ */ + /* Sticky-header height at >= 64rem, where the header IS sticky. global.css + drives `scroll-padding-top` off this, so the skip link does not drop the + reader behind the header. If SiteHeader's padding or nav sizing changes, + re-measure and change this with it — one fact living in two files. + [measured 2026-08-26 — headless Chrome at 1024/1100/1280/1440px, with six + nav items and with a seventh injected: 77px at every one] */ + --header-h: 4.8125rem; /* 77 — measured, not chosen */ + --width-content: 80rem; /* 1280 */ --width-wide: 90rem; /* 1440 */ --width-prose: 68ch; /* reading measure — never exceed for body copy */ diff --git a/tsconfig.json b/tsconfig.json index 06fceba..d86e5a4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,14 +2,7 @@ "extends": "astro/tsconfigs/strict", "compilerOptions": { "strictNullChecks": true, - "allowJs": true, - "baseUrl": ".", - "paths": { - "@components/*": ["src/components/*"], - "@layouts/*": ["src/layouts/*"], - "@styles/*": ["src/styles/*"], - "@data/*": ["src/data/*"] - } + "allowJs": true }, "include": [".astro/types.d.ts", "**/*"], "exclude": ["dist", "node_modules"]