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
1226 lines
80 KiB
Markdown
1226 lines
80 KiB
Markdown
# 02 — Design system
|
||
|
||
Authority: `AGENTS.md` §3 D7 — *keep the palette and the infinity mark;
|
||
modernize the execution.* The look is not up for redesign. What follows is the
|
||
system that preserves it while fixing what the old build got wrong.
|
||
|
||
---
|
||
|
||
## What carries over unchanged
|
||
|
||
- The **palette**: cream, ink, maroon, gold.
|
||
- The **infinity mark** — SML Company Ltd.'s actual logo, and the metaphor holds:
|
||
a dispute is a loop, and the work is redrawing the loop into a line.
|
||
**It is a shaded ribbon, not a stroked curve**: a band of variable width that
|
||
twists in three dimensions, maroon flowing into champagne, passing over itself
|
||
at the crossing. Ink bounding box **2668 × 1704 = 1.5657:1**
|
||
`[verified 2026-08-26 — measured against the master]`. Source of truth:
|
||
`src/assets/brand/sml-infinity-mark.png`; provenance in
|
||
`docs/reference/brand-assets.md`.
|
||
- The **type pairing**: Instrument Serif for display, Geist for text, Geist Mono
|
||
for eyebrows and labels.
|
||
- The **editorial register** — generous whitespace, restrained colour, serif
|
||
display type at large sizes.
|
||
|
||
## What changes
|
||
|
||
| Was | Is | Why |
|
||
|---|---|---|
|
||
| Google Fonts at runtime | Self-hosted, subset, preloaded | Removes a render-blocking third-party round trip from a page that collects legal inquiries |
|
||
| Fixed px type sizes | Fluid `clamp()` scale | One scale from 360 px to 1600 px with no breakpoint jumps |
|
||
| Ad hoc spacing values | 8 px base scale | Consistent vertical rhythm; no magic numbers |
|
||
| Gold used as a text colour on cream | Gold restricted to decorative and on-dark | **It fails WCAG AA at 2.10:1.** Measured, not assumed |
|
||
| Scroll-reveal on every element, always on | Reveal on major sections only, gated behind `prefers-reduced-motion` | Motion that reads as confident rather than decorative; accessible by default |
|
||
| 2.2 MB single file, ~1 MB of base64 logos *(both figures under review — `AGENTS.md` Q34; and "470 KB PNG", which this row used to assert, has no source anywhere in the repo and has been removed)* | Optimized SVG mark, AVIF/WebP photography | The mark is geometry, so it should be vector. That holds whatever the old file weighed |
|
||
| React 18 dev build + Babel Standalone in the browser | Static HTML, near-zero JS | The reason the site is invisible to crawlers |
|
||
|
||
---
|
||
|
||
## Colour
|
||
|
||
Tokens live in `src/styles/tokens.css`. Never write a raw hex value in a
|
||
component.
|
||
|
||
| Token | Value | Use |
|
||
|---|---|---|
|
||
| `--cream` | `#faf7f2` | Page background |
|
||
| `--cream-2` | `#f3ede0` | Alternating section background |
|
||
| `--cream-3` | `#ebe3d1` | Cards and insets on cream |
|
||
| `--ink` | `#1a1614` | Body text; dark section backgrounds |
|
||
| `--ink-soft` | `#3a322c` | Secondary text |
|
||
| `--muted` | `#6e6359` | Metadata, captions — **on cream only** |
|
||
| `--maroon` | `#5a1a1c` | Primary action, accents, dark panels |
|
||
| `--maroon-d` | `#3d1112` | Hover on maroon |
|
||
| `--maroon-l` | `#7a2a2c` | Links on cream |
|
||
| `--gold` | `#c9a876` | Rules, dividers, on-dark accent — **never text on cream** |
|
||
| `--gold-d` | `#a88858` | Large decorative text on cream only |
|
||
| `--gold-l` | `#e2c89a` | Text on ink or maroon |
|
||
|
||
### Contrast — measured, 2026-08-25
|
||
|
||
Against `--cream` `#faf7f2`:
|
||
|
||
| Colour | Ratio | AA body (4.5) | AA large (3.0) |
|
||
|---|---|---|---|
|
||
| `--ink` `#1a1614` | **16.81** | pass | pass |
|
||
| `--ink-soft` `#3a322c` | **11.75** | pass | pass |
|
||
| `--maroon` `#5a1a1c` | **12.29** | pass | pass |
|
||
| `--maroon-l` `#7a2a2c` | **8.95** | pass | pass |
|
||
| `--muted` `#6e6359` | **5.47** | pass | pass |
|
||
| `--gold-d` `#a88858` | **3.11** | **FAIL** | pass |
|
||
| `--gold` `#c9a876` | **2.10** | **FAIL** | **FAIL** |
|
||
|
||
Against `--maroon` `#5a1a1c`: cream 12.29 · gold-l 8.11 · gold 5.84 — all pass.
|
||
Against `--ink` `#1a1614`: cream 16.81 · gold-l 11.09 · gold 8.00 — all pass.
|
||
`--muted` on ink measures **3.07 and fails**; use `--gold-l` or cream at reduced
|
||
opacity for secondary text on dark.
|
||
|
||
**Hard rules.**
|
||
|
||
1. `--gold` is never a text colour on cream. Rules, borders, dividers, icon
|
||
strokes, and on-dark text only.
|
||
2. `--gold-d` on cream only at 24 px+ / 19 px bold, and only for decorative
|
||
display text — never for anything a reader must parse.
|
||
3. Body text on cream is `--ink` or `--ink-soft`. Metadata may use `--muted`.
|
||
4. Secondary text on dark is `--gold-l` or `--cream` at ≥ 70% opacity.
|
||
5. **No dark mode.** A single committed light identity is the right call for a
|
||
legal practice, and halving the surface area halves the ways contrast breaks.
|
||
|
||
---
|
||
|
||
## Typography
|
||
|
||
**Faces.** Instrument Serif (display) · Geist 300/400/500/600 (text) ·
|
||
Geist Mono 400/500 (eyebrows, labels, data).
|
||
|
||
Self-host all three. Subset to Latin + Latin Extended-A. `font-display: swap`.
|
||
Preload only the two faces used above the fold — Instrument Serif regular and
|
||
Geist 400.
|
||
|
||
**Scale.** Fluid, `clamp()`, `1.25` ratio at the small end widening to `1.333` at
|
||
the display end. Tokens `--text-2xs` through `--text-6xl` in `tokens.css`, plus
|
||
`--text-eyebrow` at 14 px — the same value as `--text-sm` and deliberately not an
|
||
alias of it; see the eyebrow rule below.
|
||
|
||
**Rules.**
|
||
|
||
- Display type (`--font-serif`) at `--text-4xl` and above only. It has almost no
|
||
hinting at small sizes and looks weak below 32 px.
|
||
- Display line-height `0.95`–`1.05`; letter-spacing `-0.02em`.
|
||
- Body line-height `1.6`. Measure capped at `68ch` — the old site ran full-bleed
|
||
paragraphs at 1400 px, which is unreadable.
|
||
- Eyebrows: mono, **14 px** (`--text-eyebrow`), `0.18em` tracking, uppercase,
|
||
weight 500, always paired with a real heading. Pouya raised it **12 → 13 → 14 px
|
||
on 2026-08-31**, in two passes, because uppercase mono at this tracking reads
|
||
smaller than it measures and 13 px was still getting lost against the display
|
||
type. There is **one definition**, `.eyebrow` in `global.css`. An eyebrow is not
|
||
a heading and never carries the `<h*>` — but a real heading may carry the class,
|
||
and **fourteen do, in two groups**: the footer's four column headings
|
||
(`SiteFooter.astro`) and `/bio/`'s ten block headings.
|
||
⚠️ **"ONE DEFINITION" HAS ONE CARVE-OUT AND IT IS NAMED RATHER THAN IMPLIED:**
|
||
`/contact/`'s `<label>`/`<legend>` set and its direct-contact `<dt>`s repeat the
|
||
treatment instead of taking the class, because they need `--text-secondary`
|
||
(11.75 : 1) rather than `.eyebrow`'s `--text-meta` (5.47 : 1) — a form label is
|
||
operative text. **Everything else about them matches, `font-weight` included**;
|
||
without that they rendered at 400 beneath a `p.eyebrow` of the same size and
|
||
colour, which is the divergence a carve-out is meant to bound rather than hide.
|
||
Do not widen this to any other element.
|
||
**14 px is the same value as `--text-sm`, and `--text-eyebrow` is deliberately
|
||
not an alias of it.** The two move for different reasons; aliasing would mean a
|
||
future change to body-meta type silently moved every eyebrow on the site.
|
||
(This bullet said "13 px is not a rung on the scale — it sits between
|
||
`--text-xs` and `--text-sm`" for one revision. At 14 px it is that rung.)
|
||
- **THE 11 px FLOOR HAS EXACTLY ONE CONSUMER, AND THAT IS THE CLAIM THAT IS
|
||
TRUE.** Pouya's ruling, 2026-08-31: *"form labels are operative text, and the
|
||
site should have exactly one small-text floor."* `--text-2xs` (11 px) now has
|
||
**one consumer in `src/`** — the header tagline (`git grep 'var(--text-2xs)' --
|
||
src` returns one hit, `SiteHeader.astro`; 21 rendered instances, every one
|
||
`span.eyebrow.brand-tagline`) — and nothing else may use it without a
|
||
measurement recorded beside it.
|
||
⚠️ **THIS BULLET SAID "THE SMALL-TEXT FLOOR IS 14 px, AND THERE IS EXACTLY ONE
|
||
EXCEPTION" FOR ONE REVISION, AND THAT WAS FALSE** — falsified by this document
|
||
thirty lines below, which concedes a 12 px family. The ruling asked for the
|
||
record that the tagline is the only sub-14 px text on the site; **it is not, so
|
||
that is not what is recorded here.** What is true is the sentence above: one
|
||
consumer of the 11 px token. `--text-xs` (12 px) is a separate rung and is
|
||
enumerated below.
|
||
**The exception, and why it is deliberate rather than residual:** the tagline is
|
||
ornamental and layout-constrained. ⚠️ **THE COST WAS RE-MEASURED ON 2026-09-01
|
||
AND IT IS A DIFFERENT COST NOW — the masthead may wrap, so what used to overflow
|
||
invisibly is visible instead.** At `--text-eyebrow` (14 px) the header stands at
|
||
**144.98 px** rather than 81 px — at 1216 px with six nav items, and at **every
|
||
width from 1216 px up** with a seventh (measured at 1216 / 1240 / 1280 / 1360 /
|
||
1440 / 1600 / 1760 / 1920) — because the row wraps. *(This listed four widths
|
||
ending at 1440 for one revision; that list was carried over from the superseded
|
||
CTA-past-the-edge measurement, which really did stop at 1440.)* **Document overflow
|
||
is 0 and the CTA sits exactly on `.header-inner`'s content edge in every one of
|
||
those cases**, where before the fix they were 20 px of overflow at 1216 px, 4 px
|
||
at 1280 px, and the CTA 67.8 / 43.8 / 51.8 / 51.8 px past the content edge. So
|
||
the two costs this bullet used to cite are **gone**, replaced by one that is
|
||
larger and easier to see: 64 px of header height on every page. *(13 px took the
|
||
header to 83.4 px; that figure was taken before the masthead could wrap.)*
|
||
Insights is that seventh item. The measurements are in `SiteHeader.astro`.
|
||
**Three blocks moved to `--text-eyebrow` on 2026-08-31 and their carve-outs are
|
||
superseded.** (a) `/contact/`'s `<label>`/`<legend>` set — *raise, do not
|
||
ratify*, superseding the "accepted, not ratified" note this bullet carried for
|
||
one revision. (b) `/contact/`'s direct-contact `<dl>` terms, which are label
|
||
text on the same page and would otherwise have been left a step behind the
|
||
labels beside them. (c) `/bio/`'s ten block headings and its strap, which were
|
||
**copies of five of `.eyebrow`'s six declarations, at 11 px** — the same escape
|
||
the footer's column headings turned out to be — and now carry the class. Not
|
||
byte-for-byte: the `font-size` differed, and **the missing sixth was
|
||
`font-weight`**, which is precisely the one the print block now has to freeze.
|
||
⚠️ **(c)'s carve-out was justified by a reason that did not apply.** This bullet
|
||
said the `/bio/` sheet was held at 11 px because `npm run bio:pdf` holds it to
|
||
one page. Measured 2026-08-31: the `@media print` block sets both elements to
|
||
**7 pt**, so the screen size never reached the PDF and the one-page constraint
|
||
was never what kept them small. **Print does now freeze `font-weight: 400`**,
|
||
because taking `.eyebrow`'s 500 changed the printed sheet — the PDF grows from
|
||
**89,496 to 91,151 bytes, +1,655** — and the ruling that moved them was about
|
||
size. *(This read "63,743 bytes" for one revision. That is the `cmp -l`
|
||
differing-byte count, not the size delta: the content stream is Flate-compressed,
|
||
so a one-property change scrambles most of the file. Two figures, and the wrong
|
||
one answered a question nobody asked.)* ⚠️ **THAT FREEZE IS NOW A RULED
|
||
CONSTRAINT, 2026-09-01, NOT A DECISION AWAITING POUYA: the circulated PDF's
|
||
typography changes only when its CONTENT is deliberately revised, never as a
|
||
side effect of a screen refactor.** So the print block keeps 400 whatever the
|
||
screen does, and `.eyebrow`'s 500 stops at the `@media print` boundary. **The
|
||
reason it is a rule and not a preference** is that the PDF is the one artefact
|
||
this project's apparatus sees only when someone re-renders it (`AGENTS.md`
|
||
R16): a refactor that silently re-typesets it changes a document already in a
|
||
reader's hands, and the diff that would have shown it is a 1,655-byte
|
||
compressed blob nobody reads. A screen change that reaches print is therefore
|
||
a defect by construction, not a judgement call — see `bio.astro`.
|
||
**`--text-xs` (12 px) IS A SEPARATE RUNG AND IT WAS NOT IN SCOPE ON 2026-08-31.
|
||
Ten declaration sites, all of them:** `Pill`, `Breadcrumbs`, `CredentialRow`,
|
||
`DefinitionGrid`, `ArticleCard`, `ProcessStep` (two), `insights/[...slug].astro`,
|
||
and **`/bio/`'s two — `.fine` and `.sheet-contact p`**. The first eight are mono
|
||
and uppercase or tracked at `0.06em`, a third of the eyebrow's tracking, which is
|
||
what makes them a different treatment. **`/bio/`'s two are neither uppercase nor
|
||
tracked**, so they are not in that family at all — they are simply 12 px text,
|
||
and an earlier draft of this bullet defined the exception in a way that excluded
|
||
them and therefore missed them.
|
||
**Rendered count, and say what it is a number of:** at 1280 px / root 16 across
|
||
all 22 pages, **130 elements carrying their own text compute to 12 px** (149 if
|
||
elements that merely inherit the size are counted). `ArticleCard` and the article
|
||
meta contribute **zero** today — no article is published, so neither renders.
|
||
Every instance measures ≥ 5.01 : 1.
|
||
- Italic display (`.it`) is the one flourish the design allows. One italic phrase
|
||
per headline, at most.
|
||
- Never skip a heading level. `<h1>` once per page.
|
||
|
||
---
|
||
|
||
## Spacing and layout
|
||
|
||
8 px base: `--space-1` `4px` · `-2` `8` · `-3` `12` · `-4` `16` · `-5` `24` ·
|
||
`-6` `32` · `-7` `48` · `-8` `64` · `-9` `96` · `-10` `128` · `-11` `160`.
|
||
|
||
Content width `1280px`; prose measure `68ch`; wide media `1440px`.
|
||
Gutters: `24px` mobile, `48px` desktop.
|
||
Section rhythm: `--space-9` (96 px) mobile, `--space-11` (160 px) desktop —
|
||
`--section-y` in `tokens.css`. *Corrected 2026-08-26:* the curve was
|
||
`6vw + 2rem`, which reaches 160 px only at a **2133 px** viewport, so the
|
||
desktop half of this line was never delivered (measured 108.8 px at 1280 px,
|
||
128 px at 1600 px). It is now `9vw + 1rem`, which reaches 160 px at 1600 px. If
|
||
you change the curve, re-measure — a `clamp()` whose upper bound is unreachable
|
||
reads exactly like one that works.
|
||
|
||
Grid: 12 columns desktop, 6 tablet, 4 mobile, `--space-5` gutter.
|
||
|
||
---
|
||
|
||
## Motion
|
||
|
||
The old build animated nearly everything on scroll. The replacement is
|
||
deliberate and quiet.
|
||
|
||
- **Reveal on section entry only** — not on every child element. Sub-element
|
||
stagger is limited to card grids, and capped at six children.
|
||
- Duration `600ms`, easing `cubic-bezier(.2,.7,.2,1)`. Transform and opacity
|
||
only — never layout properties.
|
||
- Implement with **`animation-timeline: view()`**, behind `@supports`. *Amended
|
||
2026-08-26:* the `IntersectionObserver` alternative this line used to offer
|
||
first is now ruled out, not merely second choice. It has to run inline in
|
||
`<head>` to avoid a flash, and `05-backend-spec.md` specifies `script-src
|
||
'self'` with no `unsafe-inline` — so the only script on the site would have
|
||
been the one thing the site's own CSP refuses to execute, and a per-build hash
|
||
drifts from the policy pinning it. The CSS route ships **zero** JavaScript.
|
||
Not a framework, not a library, not a script.
|
||
- **Content is visible without the feature.** The `@supports` gate is
|
||
load-bearing, not defensive: without it, a browser that ignores
|
||
`animation-timeline` runs the animation once against the document timeline at
|
||
load; with it, that browser gets no animation and fully visible content. The
|
||
old build had this exactly backwards and shipped a blank page.
|
||
- **And without a print timeline.** A scroll-driven animation has no timeline
|
||
when printing, so a revealed element renders at its `from` state — `opacity:
|
||
0`. Measured 2026-08-26: before the print override existed, printing a page to
|
||
PDF dropped four card headings from the output entirely. `/about/` is written
|
||
to be printed by people evaluating an appointment.
|
||
- Hover transitions `250ms`.
|
||
|
||
```css
|
||
@media (prefers-reduced-motion: reduce) {
|
||
*, *::before, *::after {
|
||
animation-duration: 0.01ms !important;
|
||
animation-iteration-count: 1 !important;
|
||
transition-duration: 0.01ms !important;
|
||
scroll-behavior: auto !important;
|
||
}
|
||
}
|
||
```
|
||
|
||
Nothing animates infinitely. Nothing autoplays. Nothing moves on page load
|
||
except the reveal of the hero.
|
||
|
||
---
|
||
|
||
## Components
|
||
|
||
| Component | Notes |
|
||
|---|---|
|
||
| `InfinityMark` | ⚠️ **Currently a raster — a documented, temporary exception to this rule** (`AGENTS.md` Q38, R13). The mark is gradient-mesh artwork rather than flat vector paths. An SVG *is* held and it renders faithfully — **and it renders faithfully because it IS the raster**: 257,278 bytes wrapping **seven embedded base64 PNGs**, against **3,063 B** for the AVIF a Retina browser takes in the header — **84×**. *Figures re-stated 2026-08-27 because a second, larger call site now exists and the single number had become misleading:* the mark ships at **two intrinsic widths**, 64 px (header, footer) and 232 px (the home page's approach section, which renders at 225.5 px). At 232 px a DPR-2 device takes **14,555 B** and DPR-3 **22,639 B**, so the ratio against the SVG there is ~11×, not 84×. The full ladders are in `docs/reference/brand-assets.md`; do not quote one number as if it covered both. Inlining it would breach `CLAUDE.md`'s no-base64-images rule. *Pouya settled the characterisation on 2026-08-27: a first draft called the file "a raster in a vector wrapper", a later draft withdrew that as unfair, and **the withdrawal went too far.** Both halves are true — the fidelity is real, and it is bought with embedded raster, which is exactly why fidelity was never the question.* The exception is about payload and composition. It renders AVIF/WebP with a PNG fallback; a Retina device takes 3,063 B of AVIF, `alt=""` when decorative, aspect ratio pinned to `667 / 426`. **Restore this rule the moment the commissioned vector master lands.** Until then the rule stands unchanged for every other mark or icon: inline SVG, `currentColor`, `aria-hidden` when decorative, never a PNG |
|
||
| `SiteHeader` | **Sticky from 66 rem (1056 px) up; static below it.** Practice dropdown as CSS-only `<details>`. **(a)** The one-row header holds the brand, **seven** nav items and the CTA — Insights is the seventh, arriving on its own at build step 7. Binary search on the built page puts the true fit at **1047 px**; 66 rem is the clean token above it. Below 66 rem the nav takes its own row and the header measures **141 px** at tablet widths and **189 px** at 320–375 px. **(b)** "Condenses on scroll" is a hairline rule and a shadow, **not a size change** — a `position: sticky` header stays in normal flow, so shrinking its padding lifts every page below it, against the CLS < 0.05 budget. Longhands only; see the component on what the minifier does to the `animation` shorthand. *(This row has been wrong twice, instructively. It first said 60 rem / "~115 px", a height the header never took. It then said 64 rem "with 32 px of clearance" — that 32 px was `.header-inner`'s own `column-gap` mistaken for slack; the real figure at 1024 px with seven items was **−21.6 px**, and nothing overflowed only because flexbox crushed the logo inside the brand block. **Measure slack, not gaps.**)* |
|
||
| `SiteFooter` | Three-column sitemap, contact block, designations, entity line |
|
||
| `Eyebrow` | Mono label with optional maroon dot |
|
||
| `SectionHeading` | Eyebrow + display heading + optional lede, one measure |
|
||
| `Button` | Variants `primary` (maroon) · `ghost` (outlined) · `gold` (ink bg, gold-l text). Renders `<a>` or `<button>` correctly |
|
||
| `Pill` | Small bordered label for designations and sector chips |
|
||
| `CredentialRow` | Three or four credential slots. **Never matter counts** — `AGENTS.md` §4 |
|
||
| `PracticeCard` | Sector chip, heading, one paragraph, arrow link |
|
||
| `ProcessStep` | Numbered step, timing, body |
|
||
| `ArticleCard` | Title, description, date, topic pills, reading time |
|
||
| `Prose` | Long-form wrapper. Owns all typographic defaults for MDX |
|
||
| `SEO` | Title, description, canonical, OG, Twitter, JSON-LD — see `04-seo-spec.md` |
|
||
|
||
**Focus states.** Every interactive element gets a visible focus ring:
|
||
`outline: 2px solid var(--maroon); outline-offset: 3px`. Use `:focus-visible`.
|
||
Never `outline: none` without a replacement — the old build removed it globally.
|
||
|
||
---
|
||
|
||
## Accessibility floor
|
||
|
||
Not a polish pass. A build requirement.
|
||
|
||
- One `<h1>` per page; heading levels never skipped.
|
||
- Landmarks: `<header>`, `<nav>`, `<main>`, `<footer>`. Skip-to-content link first
|
||
in tab order.
|
||
- Every image has `alt`. Decorative images get `alt=""`.
|
||
- Colour never carries meaning alone.
|
||
- All functionality reachable by keyboard; focus order matches visual order.
|
||
- Forms: real `<label>` elements, `aria-describedby` for hints, errors announced
|
||
with `role="alert"` and tied to their field.
|
||
- Touch targets ≥ 44 × 44 px.
|
||
- Test at 200% zoom and at 320 px width. **Both measured 2026-08-27 on `/`:
|
||
document overflow 0 at 320, 360, 390, 414, 640, 768, 900, 1024, 1056, 1200,
|
||
1216, 1280, 1440 and 1920 CSS px, with zero elements extending past the
|
||
viewport.** Page zoom at 200% of 1280 is the 640 column and at 400% is the
|
||
320 column, so WCAG 1.4.4 and 1.4.10 are both covered by that sweep.
|
||
|
||
**A stricter case is not fully clean, and it is recorded rather than left to
|
||
be discovered.** With the reader's *default font size* at 200% — root at
|
||
32 px, a real accessibility setting and not page zoom — `/` measured **234 px**
|
||
of overflow at 390. Brought down in three measured steps:
|
||
|
||
⚠️ **THE THREE TABLES BELOW ARE A HISTORY OF HOW THE NUMBER CAME DOWN, NOT THE
|
||
CURRENT STATE.** Every residual in their final rows — the 3 px, the 23 px and the
|
||
63 px — went to **0 on 2026-08-31**, on all 22 pages at 320 / 360 / 390 / 414 /
|
||
640 / 1024 px. The current state is §Reflow below, which also carries what is
|
||
still NOT zero at 1280 px and 1920 px. Read the rows as dated steps.
|
||
|
||
| Fix | 390 px | 320 px |
|
||
|---|---|---|
|
||
| as first built | 234 px | 304 px |
|
||
| `minmax(min(Nrem, 100%), 1fr)` on three grids | 83 px | 153 px |
|
||
| `.credentials` made explicit `repeat(2, minmax(0, 1fr))`; `.feature` padding clamped and `overflow-wrap: anywhere` on its title; `.contact-action` `flex: 0 1 auto` + `min-inline-size: 0` | **3 px** | **63 px** |
|
||
|
||
**`65 px` corrected to `63 px` on 2026-08-28**, re-measured independently on
|
||
the same page and setting. Two pixels, and it is recorded because a table that
|
||
reads as the site-wide record has to be re-measurable rather than remembered.
|
||
|
||
**`/about/` added 2026-08-28** — step 3, and the first page to be measured
|
||
against this table rather than establishing it:
|
||
|
||
| Page and fix | 390 px | 360 px | 320 px |
|
||
|---|---|---|---|
|
||
| `/about/` as first built | 38 px | 68 px | 108 px |
|
||
| `.designation-part` `white-space: nowrap` removed (the separator is held by an NBSP instead) and `overflow-wrap: anywhere` on `.hero-h` | **0 px** | **23 px** | **63 px** |
|
||
|
||
**Step 4's three pages added 2026-08-28**, and they needed no fix of their own
|
||
— the guards this table produced were applied while writing them rather than
|
||
after measuring:
|
||
|
||
| Page | 390 px | 360 px | 320 px |
|
||
|---|---|---|---|
|
||
| `/mediation/` | 3 px | 23 px | 63 px |
|
||
| `/arbitration/` | 3 px | 23 px | 63 px |
|
||
| `/med-arb/` | **0 px** | 23 px | 63 px |
|
||
|
||
At the **default** root size all three are **0 px at every width measured**.
|
||
⚠️ **THE ELEMENT-LEVEL SWEEP BELOW IS ALSO HISTORICAL.** It read: *"the
|
||
element-level sweep at 320/root-32, predicate `right > clientWidth`, names three
|
||
`PracticeCard` chips on `/mediation/` — Construction, Technology, Shareholder —
|
||
plus the header and footer brand elements, which appear on all five pages."*
|
||
Re-run with that exact predicate on 2026-08-31: **0 elements**, on all five pages
|
||
at 320 / 360 / 390 with root 32. The chips were fixed at their cause
|
||
(`PracticeCard`'s padding) and the brand elements at theirs; see §Reflow. *(This read "six" for one pass. Six is the count of
|
||
`article.card` with `scrollWidth > clientWidth`, a different predicate on a
|
||
different element; `adversarial-reviewer` re-ran it and no predicate yields
|
||
six chips. A number in this table has to be re-runnable, which is the whole
|
||
reason the table exists.)* All three are byte-identical to `/`'s — same
|
||
widths, same offsets, same text — so step 4 introduced no new element-level
|
||
overflow.
|
||
|
||
Two findings from that page specifically. The `nowrap` was **introduced as a
|
||
fix** for an orphaned `·` at the end of a wrapped line, and it made the whole
|
||
designation item unbreakable — a fix that created a reflow regression, caught
|
||
only because this table existed to regress against. And **the type scale is
|
||
rem-based**, so an `<h1>` at `--text-5xl` computes to 88 px at root 32 and a
|
||
single unbreakable 9-character name ("Lajevardi") exceeds the 224 px content
|
||
box at 320 px; `overflow-wrap: anywhere` is the only remedy that reduces
|
||
min-content size. `/about/` now measures equal to or better than `/` at all
|
||
three widths. *(Its 320/360 residual was recorded here as "the same header
|
||
decision" until 2026-08-31, when that attribution turned out to be wrong and the
|
||
residual went to 0 — see below.)*
|
||
|
||
Command, so the numbers are re-runnable rather than quoted: headless Chrome
|
||
over the built `dist`, `document.documentElement.style.fontSize = '32px'`, then
|
||
`documentElement.scrollWidth - documentElement.clientWidth`, plus an
|
||
enumeration of every element that either **is wider than `clientWidth`** or
|
||
**whose own content overflows its own box** (`scrollWidth - clientWidth`), to
|
||
name the offender. ⚠️ **Both halves of that predicate are load-bearing and the
|
||
second was added on 2026-09-01, after the first half alone reported "no offender"
|
||
at three of the four widths that were failing** — the footer email spilled text
|
||
out of a box that was itself inside the viewport. A sweep that enumerates only
|
||
over-wide *elements* will name no cause for a whole class of real overflow.
|
||
|
||
⚠️ **AND THE RECIPE ABOVE HAS A UNIFORM-PASS TRAP THAT IS NOT VISIBLE IN IT.**
|
||
Run `document.documentElement.style.fontSize = '32px'` with
|
||
`prefers-reduced-motion: reduce` in force — launch flag *or* CDP emulation — and
|
||
it reports **0 of 352 with the enlargement never applied**: brand width, header
|
||
height and document height all byte-identical to root 16. The reduced-motion
|
||
reset emits `*,::before,::after{transition-duration:.01ms!important}` over the
|
||
initial `transition-property: all`, so the root font-size change becomes a
|
||
`CSSTransition` and a synchronous read returns its START value.
|
||
`document.getAnimations()` shows `CSSTransition: font-size, running, t=0`; after
|
||
one `requestAnimationFrame` it is 32 px. **Kill motion with an injected
|
||
`transition:none;animation:none` sheet rather than by emulating reduced motion,
|
||
and assert the applied root size per cell** — that assertion is the only thing
|
||
that distinguishes this from a clean sweep. Found by `adversarial-reviewer`,
|
||
round 2, running this section's own published recipe.
|
||
|
||
✅ **THE LARGE-TEXT NAV OVERFLOW IS FIXED, 2026-09-01.** Pouya reopened the
|
||
step-1 header decision for the purpose — *"its record attributed the residual to
|
||
the wrong cause and characterized a measured 944 px functional failure as a
|
||
robustness margin"* — and ruled *fix, do not accept*. What follows is the
|
||
current state. The narrow case was ruled a defect on 2026-08-31 (*"the
|
||
152/112/82 px overflows at 320/360/390 with 32 px root text violate WCAG 1.4.10
|
||
and are not accepted"*) and fixed then.
|
||
|
||
**THREE MECHANISMS ENLARGE TEXT, AND THEY ARE NOT EQUIVALENT — this is the
|
||
measurement the whole record turned on.** Every figure in this section before
|
||
2026-09-01 came from `documentElement.style.fontSize = '32px'`. A later sweep
|
||
used Chrome's `Page.setFontSizes({standard: 32})`, reported 0 everywhere, and
|
||
the prose generalised that zero. All three are re-measured below.
|
||
|
||
| mechanism | `rem` in a media query | `rem`/`em` in a PROPERTY | `getComputedStyle` root | sees the enlargement? |
|
||
|---|---|---|---|---|
|
||
| `documentElement.style.fontSize='32px'` (root style) | **16 px → `66rem` = 1056 px** | 32 px | 32 px | media query **no**, property **yes** |
|
||
| `Page.setFontSizes({standard:32})` (the reader's *default font size*) | 32 px → `66rem` = **2112 px** | 32 px | 32 px | **yes, both** — the breakpoint moves with the type |
|
||
| `--blink-settings=minimumFontSize=32` (the reader's *minimum font size*) | 16 px → `66rem` = **1056 px** | **16 px** | **32 px** | **NO `rem`/`em` CONSTRUCT SEES IT** — the columns of this table are all `rem`/`em`, and that is the limit of what the row measures. ⚠️ **The FONT-METRIC units DO see it** (`ch`, `ex`, `cap`, `lh`, `rlh`); see the table below |
|
||
|
||
*(Row 1's media-query cell read "32 px → 2112 px" for one revision, which
|
||
contradicted its own verdict column, the paragraph below it, and measurement —
|
||
and it deleted the one number that explains why the failure is reachable at all.
|
||
If `66rem` really resolved to 2112 px under root scaling, the 66 rem desktop
|
||
masthead would never be reached at 1280 px and the 944 px overflow could not
|
||
happen. Found by `adversarial-reviewer`.)*
|
||
|
||
Two consequences, both measured 2026-09-01 and both load-bearing:
|
||
|
||
1. **In a media query `rem` resolves against the browser's DEFAULT font size,
|
||
not the root element's.** So raising the *default* moves the breakpoints
|
||
along with the type and the desktop masthead is never reached; setting the
|
||
*root* doubles the type and leaves the breakpoints where they were, which is
|
||
the layout the desktop nav was measured in. **A container query is the one
|
||
construct that differs** — `@container` `rem`/`em` DO resolve against the
|
||
root element, measured — which is why the sticky gate below is a property and
|
||
not a query.
|
||
2. ⚠️ **UNDER A MINIMUM-FONT-SIZE SETTING, `getComputedStyle(el).fontSize` AND
|
||
THE `em` UNIT DISAGREE.** Blink floors the *reported computed* font size to
|
||
32 px while `em` and `rem` keep resolving at 16 px — so `scroll-padding-top:
|
||
6.0625em` computes to **97 px, not 194 px**, a `66em`-wide box measures
|
||
**1056 px, not 2112 px**, and `min(0px, calc(100vw - 66em))` is **0px**.
|
||
⚠️ **AND THE SENTENCE THAT USED TO SIT HERE WAS FALSE AND LOAD-BEARING: it
|
||
said "media queries, container queries and length units are all blind to it"
|
||
and that "only used-value layout — wrapping — responds".** It is the
|
||
`rem`/`em` FAMILY that is blind, not CSS. The **font-metric** units read the
|
||
*used* font size and therefore double, and they do so in all three
|
||
constructs — measured 2026-09-01 on `/fees/` @1280, headless Chrome, one
|
||
probe element per cell:
|
||
|
||
| construct | `rem`, `em`, `ic`, `px` | `ch`, `ex`, `cap`, `lh`, `rlh` |
|
||
|---|---|---|
|
||
| property value | blind (16 px → 16 px) | **responds** — `ch` 10.608 → 21.216 px, `ex` 8.48 → 16.96, `cap` 11.36 → 22.72, `lh` 25.59 → 51.19 |
|
||
| `@media (min-width: N<unit>)` | blind — threshold never flips | **responds** — `100ch`, `110ex`, `80cap`, `45lh` all flip |
|
||
| `@container (min-width: N<unit>)` | blind | **responds** — `45ex`, `32cap` flip |
|
||
|
||
So wrapping is still the right mechanism for *reflow* — it needs no
|
||
threshold and no fitted constant — but **"no CSS can detect this setting" is
|
||
not true, and the residual below is therefore not provably unfixable.**
|
||
Found by `adversarial-reviewer` and reproduced independently.
|
||
|
||
**Before and after, 22 pages × 16 widths (320 → 1920 px) = 352 page-widths per
|
||
setting, document overflow `documentElement.scrollWidth −
|
||
documentElement.clientWidth`. Both columns measured on the SAME grid**, from a
|
||
build of the previous commit and a build of the working tree, by the same probe:
|
||
|
||
| text setting | before | after |
|
||
|---|---|---|
|
||
| default (root 16) | 0 of 352 | **0 of 352** |
|
||
| root style 32 px | **175 of 352** | **0 of 352** |
|
||
| default font size 32 | 0 of 352 | **0 of 352** |
|
||
| minimum font size 32 | **219 of 352** | **0 of 352** — the header fix left 88, all of them the footer; the footer fix below closed them, 2026-09-01 |
|
||
|
||
✅ **SO: ZERO DOCUMENT OVERFLOW UNDER ALL FOUR METHODS AT EVERY WIDTH
|
||
MEASURED — AND THE SCOPE OF THAT SENTENCE IS EXACTLY THE GRID ABOVE.** 22 built
|
||
pages × those 16 widths × four enlargement methods = **1,408 page-widths**,
|
||
document overflow by `documentElement.scrollWidth − documentElement.clientWidth`,
|
||
webfonts loaded, six nav items, `prefers-reduced-motion` neutralised by an
|
||
injected sheet rather than emulated. Plus **762** further points for the sticky
|
||
gate (roots 9–15, roots 16–32, the band seam, both thresholds, all five Chrome
|
||
presets), 0 failing.
|
||
|
||
⚠️ **It is not a claim about anything outside that grid, and "no document
|
||
overflow" is not "no accessibility defect".** Three known cases sit outside it
|
||
deliberately. The **fallback-metrics case** below needs the webfonts blocked and
|
||
a seventh nav item. The **focus-obscuring case** below was a **WCAG 2.2
|
||
SC 2.4.11 (AA) failure**, now **fixed and closed** — `AGENTS.md` Q61 — and it is
|
||
the sharpest illustration of the warning above: **document overflow could not see
|
||
it at all**, because nothing overflowed. The header simply covered what had
|
||
focus, on 290 of 1,455 stops, while every zero in the table above stayed a zero.
|
||
A residual at `minimumFontSize=16` and `=20` remains, is pre-existing, and is
|
||
likewise invisible to this grid. And a
|
||
**footer nav label** still overruns its own column by 24 px at 640 px under
|
||
minimum font size with 7.7 px of clearance, again with zero document overflow.
|
||
None of the three is contradicted by the zeros above, and none of them is
|
||
measured by them — which is the reason each is recorded in its own right rather
|
||
than summarised into the table. Widths
|
||
between the sampled ones are not measured either — the grid is 16 columns, not a
|
||
continuum, and the two thresholds and the band seam were swept precisely because
|
||
a 16-column grid can step over a 36 px band.
|
||
|
||
**The wide-width figures that were the defect, root style 32 px, and what they
|
||
are now.** Every one is 0 of 22 pages after the fix, and the nav items and the
|
||
CTA are on-screen at every width:
|
||
|
||
| width | before | after |
|
||
|---|---|---|
|
||
| 1056 | 649 px, 22 of 22 | **0** |
|
||
| 1100 | 606 px, 22 of 22 | **0** |
|
||
| 1216 | 928 px / 491 px on `/`, 22 of 22 | **0** |
|
||
| 1240 | 904 px / 468 px on `/` | **0** |
|
||
| 1280 | **944 px** / 508 px on `/` | **0** |
|
||
| 1440 | 784 px / 351 px on `/` | **0** |
|
||
| 1600 | 624 px / 193 px on `/` | **0** |
|
||
| 1920 | 304 px, 21 of 22 | **0** |
|
||
|
||
*(Only 1280 and 1920 were in the earlier record, so it never showed that the
|
||
failure ran the whole width of the sticky range. The same run under minimum font
|
||
size was 414 / 370 / 854 / 830 / 830 / 750 / 670 / 510 px, of which the record
|
||
carried only the 830.)*
|
||
|
||
⚠️ **AND IT WAS NOT A MARGIN — IT WAS UNREACHABLE NAVIGATION.** The furthest
|
||
element was `div.header-cta` at **2224 px** against a 1280 px viewport; the nav
|
||
clipped mid-word after "Arbitration", and **Practice, Fees, Contact and the
|
||
header CTA sat entirely off-screen** behind a horizontal scrollbar — the route
|
||
to the inquiry form among them. WCAG 1.4.4 with loss of functionality. The
|
||
paragraph struck here called it *"a robustness margin rather than a failure.
|
||
Revisit if a real reader hits it."*
|
||
|
||
**THE FIX IS TWO DECLARATIONS, AND THE SECOND ONE IS THERE BECAUSE THE FIRST
|
||
ONE HAS A CONSEQUENCE.**
|
||
|
||
**(1) The masthead may wrap.** `flex-wrap: nowrap` is gone from `.header-inner`
|
||
and from `.nav-list` above 66 rem, along with the dead `flex: none` beside it.
|
||
|
||
⚠️ **WHICH OF THE TWO WAS THE CAUSE IS NOW MEASURED, AND THIS PARAGRAPH FIRST
|
||
GOT IT WRONG.** It said *"that pair, not any one declaration, was the cause"*.
|
||
Restoring each declaration alone on the fixed build, root 32, `/about/` —
|
||
document overflow / number of nav links and CTA off-screen:
|
||
|
||
| restored above 66 rem | 1056 | 1216 | 1280 | 1440 | 1920 |
|
||
|---|---|---|---|---|---|
|
||
| neither (shipped) | 0/0 | 0/0 | 0/0 | 0/0 | 0/0 |
|
||
| `.nav-list { flex-wrap: nowrap }` only | 0/0 | 0/0 | 0/0 | 0/0 | 0/0 |
|
||
| `.header-inner { flex-wrap: nowrap }` only | 0/0 | 273/1 | 209/1 | 49/1 | 0/0 |
|
||
| both (= the state before) | 649/3 | 928/5 | 944/5 | 784/4 | 304/1 |
|
||
|
||
**`.header-inner`'s `nowrap` was necessary and sufficient.** And the second row
|
||
is the one that matters for honesty: **`.nav-list`'s removal is inert** — byte-
|
||
identical to shipped at every width, under a 200 % root size *and* under minimum
|
||
font size, **with six nav items and with seven**. It is kept removed for two
|
||
reasons that are not "it fixed the overflow": the `nowrap` prohibition in
|
||
`SiteHeader.astro` would otherwise contradict a `nowrap` still sitting in the
|
||
file, and the override only ever re-stated `nowrap` over a base rule that already
|
||
wraps. Found by `adversarial-reviewer`; the mechanism sentence about min-content
|
||
was true and was not the binding constraint. *(`flex` is a flex-ITEM property and `.nav`
|
||
computes `display: block`, measured at 1056 / 1280 / 1920 px — so `flex: none`
|
||
set `flex-shrink: 0` on something that is not a flex item and had **no effect**.
|
||
Removing it leaves `.nav`'s and `.nav-list`'s geometry byte-identical at all
|
||
three widths; the only trace was the computed `flex-shrink`, 0 → 1. Its comment
|
||
claimed it was what stopped the nav being squeezed, which it never was.)*
|
||
**It cost nothing at any normal size: 0 geometry
|
||
differences across 22 pages × 16 widths (352 page-widths)**, header exactly **81.00 px** at every
|
||
width from 1056 px up, brand on one line, nav on one line, CTA exactly on
|
||
`.header-inner`'s content edge (gap 0.00 px) — **and the same with a seventh nav
|
||
item injected**, at 1024 / 1047 / 1056 / 1071 / 1100 / 1150 / 1200 / 1216 / 1240
|
||
/ 1280 / 1440 / 1920 px, where all seven share one line from 1056 px up. So the
|
||
`nowrap` was not load-bearing at any width above the breakpoint; the 66 rem
|
||
breakpoint is what keeps the row intact, and it already accounts for seven items.
|
||
|
||
**(2) The masthead is sticky only while it is one row**, and this half took two
|
||
attempts — the first one shipped a regression, which is recorded because the
|
||
reason it failed is the useful part.
|
||
|
||
Wrapping makes the header **taller** at enlarged text: **244.59–350.86 px** at a
|
||
200 % root size against 81 px normally. A sticky box that size covers `#main`
|
||
after "Skip to content", so stickiness has to go wherever the header can exceed
|
||
`scroll-padding-top`. The gate is a **property**, not a query, because in a
|
||
property `rem` tracks the root element.
|
||
|
||
⚠️ **ROUND 1 WROTE `min(0px, calc(100vw - 66rem))` AND IT WAS WRONG IN TWO WAYS
|
||
THAT ONLY AN INTERMEDIATE ROOT SIZE EXPOSES.** Both were found by
|
||
`adversarial-reviewer` and independently reproduced: **20 of 300**
|
||
(page × viewport × root) points left `#main` behind the header, 10–69 px, across
|
||
roots 18–30 — while root 16 and root 32, the only two sizes the first sweep
|
||
measured, were both clean. **WCAG 1.4.4 is a requirement up TO 200 %, not AT
|
||
200 %**, and a two-point sweep cannot see a diagonal band.
|
||
|
||
1. **It RAMPED where it needed to STEP.** One pixel inside the threshold the term
|
||
lifts the header by one pixel — but the header has already gained a whole row,
|
||
so it still covered `#main`. Wrapping is a step function; the response has to
|
||
be one too. Hence the `* 100000` saturation, bounded by `-100vh`.
|
||
2. **It used the wrong threshold in the wider band.** The tagline appears at
|
||
76 rem, and with it the one-row masthead fits only from **1207 px = 75.4 rem**
|
||
— the step-1 binary search already recorded that number. Gating the wide band
|
||
on 66 rem therefore fired far too late. **`/` measured clean throughout, and
|
||
that is what identified the driver:** `/` is the one page that suppresses the
|
||
masthead tagline.
|
||
|
||
⚠️ **AND ROUND 2 FOUND THAT THE GATE WAS ONE-SIDED — it could only fire when the
|
||
root was LARGER than the default.** Chrome's "Font size" control has **five**
|
||
presets (Very small 9 px, Small 12, Medium 16, Large 20, Very large 24) and the
|
||
sweeps had used 16 and 32 — 32 is not one of them. At **9 px the masthead wraps
|
||
for the opposite reason**: `--width-content` is `80rem`, so the content column
|
||
shrinks to 720 px while the header's `min-inline-size: 44px` / `min-block-size:
|
||
44px` floors do not, and the row wraps at **every** viewport width. The header
|
||
stood at **120 px against a 54.56 px `scroll-padding-top`** — **65 px of `#main`
|
||
behind it on the 21 pages that render the tagline**, at 1056 through 1920 px.
|
||
`/` measured 12 px, and that 12 px is **pre-existing**: the previous build is 67 px
|
||
against the same 54.56 px offset on all 22 pages. So the wrap fix widened a
|
||
pre-existing 12 px defect to 65 px, and the second term closes both.
|
||
|
||
**The shipped form is two bands, each gating on the width ITS layout requires,
|
||
and two terms, each catching one direction:**
|
||
|
||
```css
|
||
@media (min-width: 66rem) { .site-header {
|
||
position: sticky;
|
||
inset-block-start: clamp(-100vh,
|
||
min(calc((100vw - 66rem) * 100000), calc((1rem - 16px) * 100000)), 0px); } }
|
||
@media (min-width: 76rem) { .site-header {
|
||
inset-block-start: clamp(-100vh,
|
||
min(calc((100vw - 76rem) * 100000), calc((1rem - 16px) * 100000)), 0px); } }
|
||
```
|
||
|
||
`1rem - 16px` is negative exactly when the root is below the CSS initial font
|
||
size — the 16 px the whole rem scale is built on — and it also catches the
|
||
root-style equivalent, where the previous form left the header sticky at 69 px
|
||
against a 60.63 px offset at root 10. **The cost, measured and accepted:** a
|
||
reader whose default text size is "Small" (12 px) loses the sticky header, where
|
||
it currently works — but only just: the header is 73 px against a 72.75 px offset,
|
||
a **0.25 px** margin. Trading a 0.25 px margin for a provable gate is the right
|
||
way round.
|
||
|
||
Both thresholds are the existing layout breakpoints, and both sit just above the
|
||
measured one-row fit width (1047 px ≈ 65.4 rem without the tagline, 1207 px ≈
|
||
75.4 rem with it), so the gate fires **at or before** the wrap rather than after
|
||
it. **At every normal size both evaluate to exactly `0px`** — a matched
|
||
`min-width: 66rem` guarantees `100vw ≥ 1056 px` and a matched `min-width: 76rem`
|
||
guarantees `100vw ≥ 1216 px`, because `100vw` counts a classic scrollbar and the
|
||
query width does not, so the term can only be more positive.
|
||
|
||
**Measured after: 0.00 px of `#main` covered — 0 of 300 grid points across roots
|
||
16–32 × viewports 1056–1920 on three pages, 0 of 176 rows at the default size and
|
||
at a 200 % root size over all 22 pages, 0 of 144 cases sitting directly on the
|
||
two thresholds, and 0 of 198 at the SEAM where the two bands meet** (viewports
|
||
1180–1260 × roots 16–32). ⚠️ **`covered: 0` is also what a non-sticky header
|
||
gives, so the seam was checked the other way round as well:** at the default size
|
||
across 1180 / 1200 / 1210 / 1215 / 1216 / 1217 / 1220 / 1240 / 1260 / 1440 /
|
||
1920 px the computed `inset-block-start` is `0px` and the header is still pinned
|
||
at top 0.00 when scrolled — stickiness is preserved, not quietly lost. ⚠️ **Saturation leaves a transition band, and it is
|
||
arithmetic rather than measured:** a finite factor means the response is only
|
||
a near-step, so the band is about (header − `scroll-padding-top`) / factor ≈
|
||
**0.0007 px** of viewport width. CDP takes integer viewport widths, so that band
|
||
was not driven directly — it is bounded by the arithmetic, not by a probe.
|
||
|
||
*(Lightning CSS folds the factor into the units and emits
|
||
`clamp(-100vh,10000000vw - 6600000rem,0px)`. Verified equivalent by measurement,
|
||
not by reading: the computed `inset-block-start` is `0px` at every sticky width at
|
||
the default size, and the header pins at top 0.00.)*
|
||
|
||
⚠️ **"AN OFFSET TOO SHORT CANNOT ARISE" WAS WRITTEN HERE AND IN TWO SHIPPED
|
||
COMMENTS, AND IT IS FALSE. THE EXCEPTIONS ARE ENUMERATED RATHER THAN WAVED AT**,
|
||
because a comment that says "cannot" is the kind that stops the next reader
|
||
looking. The gate handles **root-relative** enlargement in both directions.
|
||
What it cannot see, measured, header height against computed
|
||
`scroll-padding-top`, header `top` = `0px` in every row:
|
||
|
||
| condition | header | offset | short by | status |
|
||
|---|---|---|---|---|
|
||
| `minimumFontSize=32` | 164.58–270.56 px | 97 px | **68–174 px** | 🛑 **OPEN — needs a fresh ruling.** The 2026-09-01 acceptance rested on two facts that are both false; and the real cost is **290 entirely-obscured focus stops, SC 2.4.11 AA**, not a short skip-link landing. **A pure-CSS detector DOES exist.** See below |
|
||
| fallback font metrics + 7th nav item, **default** text size, 1056–1091 px | 141 px | 97 px | **44 px** | latent on `showInsights`. **HARD GATE: `AGENTS.md` R20 — the seventh item does not ship until this is fixed** |
|
||
| `defaultFontSize=9` | 120 px | 54.56 px | ~~65 px~~ | **fixed** by the `1rem - 16px` term |
|
||
| root-style 10 px | 69 px | 60.63 px | ~~8 px~~ | **fixed** by the same term |
|
||
|
||
**`--header-h` IS A FLOOR, NOT A CONSTANT**, and that is the sentence that
|
||
had to change. It is the header's height at the **default** text size; above the
|
||
default the masthead is deliberately taller, and declaration (2) is what makes
|
||
that safe — where the gate can see the header exceed the token it is not sticky,
|
||
so a `scroll-padding-top` that is too *generous* is harmless. ⚠️ **It does NOT
|
||
make a short offset impossible: the two cases the gate cannot see are tabulated
|
||
above.** The token previously asserted "one constant 81 px across
|
||
every width where it is sticky", and that sentence is what made a 944 px
|
||
overflow read as settled.
|
||
|
||
**What the narrow-width fix DID establish, and it is worth keeping separate.**
|
||
The narrow residual had been charged to that same `flex-wrap: nowrap` — and for
|
||
the narrow case that attribution was **wrong**. The cause there was
|
||
`white-space: nowrap` on `.brand-name`, a different declaration in the same
|
||
component, and removing it **changes nothing at any real size**: the brand name
|
||
is one line at all 16 widths at root 16, the sticky header still measures exactly
|
||
81 px at every width from 66 rem up, and the CTA still lands exactly on
|
||
`.header-inner`'s content edge. **A residual defended by the wrong cause is
|
||
defended forever**, because the reason given is one nobody wants to re-open —
|
||
which is exactly what happened for four days.
|
||
|
||
**ELEVEN FIXES ACROSS THREE PASSES, IN TWELVE ROWS — no `overflow-x` was added
|
||
anywhere.** Eight are narrow-width (2026-08-31), two are the large-text nav
|
||
(2026-09-01), and the eleventh is the footer email (2026-09-01, the pass that
|
||
closed the last recorded overflow). ⚠️ **The twelfth row is `.nav-list`, and it
|
||
is a row without being a fix:** it is here because its removal was *measured*,
|
||
and it is not counted because the measurement was that it changes nothing. Say
|
||
so rather than leaving the arithmetic to a reader who counts rows — the heading
|
||
read "TEN FIXES" over eleven rows until 2026-09-01 for exactly this reason. All
|
||
but one of the eleven are cause-specific; the exception is marked as the
|
||
backstop it is:
|
||
|
||
| Element | Was | 320 px | Cause |
|
||
|---|---|---|---|
|
||
| `SiteHeader .header-inner` | `flex-wrap: nowrap` removed above 66 rem | **944 px** at 1280 px, root 32 | **Necessary and sufficient, measured.** A `nowrap` flex line cannot break, so the brand / nav / CTA row could not reflow at all |
|
||
| `SiteHeader .nav-list` | `flex-wrap: nowrap` and a dead `flex: none` removed above 66 rem | **0 px — inert** | Kept removed so the `nowrap` prohibition is not contradicted by a `nowrap` in the same file. Byte-identical to shipped at every width and every text setting, six items and seven |
|
||
| `SiteHeader .site-header` | `inset-block-start: 0` → a saturating `clamp()` gate, in **two** bands (66 rem, and 76 rem where the tagline shows) | *(consequence of the row above)* | Wrapping makes the header 244.59–350.86 px at root 32; sticky at that height covered `#main` by up to 157 px after the skip link. **Round 1's single-band `min()` ramped instead of stepping and left 20 of 300 grid points covered by 10–69 px** |
|
||
| `SiteHeader .brand-name` | `white-space: nowrap` removed | **63 px, all 22 pages** | Two words glued into one unbreakable box |
|
||
| `/bio/ .rates-list` | `overflow-wrap: anywhere` | **152 px** | `https://adr.smlcompany.ca/fees/` has no break opportunity, and the single grid track it sized stretched all five rows to 424 px |
|
||
| `/ .hero-h` | `overflow-wrap: anywhere` | 36 px | "contract," at 104 px held `.hero-copy` open |
|
||
| `/ .feature-body` | `overflow-wrap: anywhere` | 26 px | "party-appointed" at 202 px |
|
||
| `CredentialRow .credential-label` | `overflow-wrap: anywhere` | 38 px | "designation" needs 143 px in an 88 px track |
|
||
| `SiteFooter .footer-contact a[href^='mailto:']` | `overflow-wrap: anywhere` | **14 px** | `info@smlcompany.ca` has no break opportunity and demands 310 px. Minimum-font-size only; also 38 / 30 / 19 px at 1024 / 1056 / 1100 px, by a different mechanism — see the footer block above |
|
||
| `SiteFooter .footer-brand` | `flex-wrap: wrap` | 12 px | Flex item at `min-width: auto` cannot shrink below "Lajevardi" |
|
||
| `PracticeCard .card` | `padding` clamped | *(the cause under `Pill`)* | The space scale is rem-based, so `--space-6` is 64 px a side at root 32 — 128 px of padding in a ~224 px box |
|
||
| `Pill` | `overflow-wrap: anywhere` | 33 px → **1 px** | ⚠️ **SYMPTOM, NOT CAUSE.** A one-word pill cannot wrap at a space that is not there |
|
||
|
||
`flex-wrap: wrap` on the footer brand rather than `anywhere`, because it reflows
|
||
without hyphenating a person's name.
|
||
|
||
⚠️ **`Pill`'s `anywhere` IS A BACKSTOP AND THE TRADE IS RECORDED RATHER THAN
|
||
HIDDEN.** It removes the min-content floor of a shrink-to-fit `inline-flex` box,
|
||
so the pill collapses to whatever its parent gives it. The real cause was
|
||
`PracticeCard`'s padding: with it clamped, "Construction" at root 32 / 320 px
|
||
went from **94 × 220 px broken into six two-character lines** to **158 × 85 px on
|
||
two lines**, and root 16 is unchanged at 121 × 27 px on one line. **`anywhere` is
|
||
still load-bearing** — removing it leaves 1 px of document overflow on `/`,
|
||
`/mediation/` and `/practice/`, and renders the pill 240 px wide inside a 224 px
|
||
card, escaping its own rounded border. Two lines is the better of those. The
|
||
clamp does not change any normal size: 10vw holds 32 px from 320 px up.
|
||
|
||
**TEN instrument findings, and each one hid a real defect. Items 4–6 are from the
|
||
2026-09-01 header pass and all three produced a CLEAN-LOOKING result; 7–10 are
|
||
from the footer pass the same day. Each is recorded once — the footer block above
|
||
points here rather than restating them.** ⚠️ **Finding 2 RECURRED in that footer
|
||
pass**, in a probe written by the person who had written finding 2 down: it is
|
||
described where it did its damage, beside the footer fix, and not duplicated as
|
||
an eleventh item.
|
||
|
||
|
||
4. ⚠️ **A UNIFORM PASS FROM A TAUTOLOGY.** The skip-link probe reported
|
||
`0.0 px of #main covered` on **946 of 946** page-widths, before and after,
|
||
under every mechanism. `global.css` sets `scroll-behavior: smooth`, which
|
||
makes `scrollIntoView()` **asynchronous**, so a synchronous read afterwards
|
||
measures `scrollY = 0`; and at `scrollY = 0` the target sits exactly at the
|
||
header's bottom edge, so `covered` is 0 **by construction**. Pass
|
||
`behavior: 'instant'` and assert that `scrollY` equals its predicted value —
|
||
once fixed the same probe reported 10–26 px covered on 22 of 22 pages, a
|
||
pre-existing defect the tautology had hidden.
|
||
5. ⚠️ **A RUNNING TRANSITION MAKES A PROPERTY READ RETURN THE OLD VALUE.**
|
||
Forcing reduced motion — by CDP emulation *or* by
|
||
`--force-prefers-reduced-motion` — made `documentElement.style.fontSize =
|
||
'32px'` read back as **16 px**, while the inline `style` attribute plainly
|
||
said `font-size: 32px`. This codebase's reduced-motion block sets
|
||
`transition-duration: 0.01ms !important` on `*`, which starts a transition on
|
||
**every** property change, and a synchronous `getComputedStyle` then returns
|
||
the transition's *start* value. The instrument now injects
|
||
`transition:none;animation:none` instead. Same family as *a running
|
||
transition outranks an `!important` author declaration*.
|
||
6. **A DOM-INJECTED ELEMENT RECEIVES NO SCOPED STYLES.** Astro scopes component
|
||
CSS with a `data-astro-cid-*` attribute, so the hand-built
|
||
`<a class="nav-link">` used to test a seventh nav item got **none** of
|
||
`.nav-link`'s rules — no `min-inline-size`, no `min-block-size`, no
|
||
`font-size` — and measured narrower and shorter than a real item, which is
|
||
the direction that makes a width test pass. **Clone a real node** and assert
|
||
its computed styles match a sibling's.
|
||
|
||
7. ⚠️ **A CAUSE READ OFF A TRUNCATED LIST.** The header harness capped its
|
||
offender array at `slice(0, 20)` while reporting `offenderCount` beside it,
|
||
and **44 of the 88 failing rows exceeded the cap** — so the enumeration that
|
||
was supposed to name the footer's cause was silently missing 34 of the 54
|
||
boxes at 320 px. The truncation was visible only to a reader who compared the
|
||
two numbers. Same family as the `tail -3` rule in `CLAUDE.md`, through a
|
||
different pipe.
|
||
8. ⚠️ **`grep -c` COUNTS LINES, NOT OCCURRENCES, AND MINIFIED CSS IS ONE LINE.**
|
||
`grep -c 'overflow-wrap:anywhere' dist/_astro/*.css` returned `1` for a file
|
||
that holds **three** such declarations. It was being used to confirm the new
|
||
rule had reached `dist/`, which it could not do. Extract and print the bytes
|
||
instead — the rule that actually settled it was reading the emitted selector,
|
||
`.footer-contact[data-astro-cid-nns7i3if] a[data-astro-cid-nns7i3if][href^=mailto\:]`.
|
||
|
||
9. ⚠️ **`getClientRects().length` IS 1 FOR A `display: flex` ELEMENT HOWEVER MANY
|
||
LINES OF TEXT IT HOLDS** — it is one block box, not a fragmented inline. The
|
||
wrap probe therefore reported `lines=1` for the footer email while its own
|
||
`height` said **102.38 px = 2 × 51.19**, i.e. two lines. Line counts come from
|
||
`Range` rects over the text, which do fragment per line. **Two metrics of the
|
||
same quantity disagreeing is the cheapest defect detector in this section** —
|
||
it is what caught this one.
|
||
10. ⚠️ **`Page.captureScreenshot`'s `clip` IS IN PAGE COORDINATES, NOT VIEWPORT
|
||
COORDINATES.** Given a `getBoundingClientRect()` taken after a scroll, it
|
||
captured a **blank cream plate** — of a footer that is ink. The screenshot
|
||
existed to be the independent second method for a break the numbers could not
|
||
judge, so a silently wrong one is worse than none. Add `window.scrollX/Y`, or
|
||
pass `captureBeyondViewport: true` and page coordinates.
|
||
|
||
The original three:
|
||
|
||
1. **`overflow-wrap: break-word` permits a break at layout time but does not
|
||
reduce min-content size** — `anywhere` does. Five of the fixes are this
|
||
distinction. `global.css:166` sets `break-word` on **`p` AND `h1`–`h6`** —
|
||
*(this line said "`h1`–`h6` and nothing else, so body copy inherits no
|
||
wrapping at all", which was wrong in a way that mattered: `.feature-body`
|
||
**is** a `<p>` and already had `break-word`, so the premise given for its fix
|
||
was false while the fix itself was right)*. Nothing else gets one, so a
|
||
`<span>`, `<li>`, `<dd>` or `<a>` inherits no wrapping.
|
||
2. **`getBoundingClientRect()` reports BORDER BOXES, so an element sweep cannot
|
||
see text spilling outside its own box.** `.credential-label`'s words ran 86 px
|
||
past an 88 px cell while every element's rect stayed inside the viewport —
|
||
the document was 38 px over and the offender was invisible to the predicate
|
||
this table's own command uses. Read `scrollWidth - clientWidth` per element as
|
||
well as per document.
|
||
3. **Under `Emulation.setDeviceMetricsOverride({mobile: true})` the LAYOUT
|
||
viewport expands to fit the content**, so `innerWidth` becomes 472 at a
|
||
requested 320 and `scrollWidth - innerWidth` reads **0** while the reader is
|
||
still scrolling sideways. The honest formula is
|
||
`documentElement.scrollWidth - documentElement.clientWidth`, which is correct
|
||
in both modes.
|
||
|
||
✅ **THE FOOTER RESIDUAL IS FIXED, 2026-09-01 — ONE DECLARATION, AND THE
|
||
EARLIER RECORD MISDIAGNOSED HALF OF IT.** Under Chrome's minimum font size the
|
||
document overflowed at four widths on all 22 pages — **14 px at 320, 38 px at
|
||
1024, 30 px at 1056, 19 px at 1100** — and 0 from 1200 px up. One cause at all
|
||
four: `info@smlcompany.ca` has no break opportunity, so its **min-content width
|
||
is 310 px**.
|
||
|
||
| Element | Was | Fix | Effect |
|
||
|---|---|---|---|
|
||
| `SiteFooter .footer-contact a[href^='mailto:']` | `overflow-wrap: normal` | `overflow-wrap: anywhere` | **88 of 352 → 0 of 352** |
|
||
|
||
⚠️ **TWO MECHANISMS, ONE CAUSE — and this is the part the earlier record got
|
||
wrong.** It said the 1024–1100 px half "looks like one `overflow-wrap: anywhere`
|
||
on the footer contact links" and that "the 320 px half is a column-sizing
|
||
question and is not the same fix". **It is the same fix.** The column sizing at
|
||
320 px is *driven by* that same 310 px:
|
||
|
||
- **At 320 px** `.footer-grid`'s implicit track is `auto`, whose minimum is
|
||
min-content, so the track — and every `.footer-col` in it — measures **310 px
|
||
inside a 272 px content area**. The boxes themselves cross the viewport edge:
|
||
**54 elements** did, right edge 334 px against a 320 px client width.
|
||
- **At 1024 / 1056 / 1100 px** the contact column is `minmax(0, 1fr)` — a **0**
|
||
minimum, so the track does not grow — and the box stays **224 / 232 / 243 px**
|
||
while the text spills **86 / 78 / 67 px outside it**. **No element's border box
|
||
crosses the viewport edge at all** at those widths.
|
||
|
||
Reducing one min-content width closes both halves.
|
||
|
||
⚠️ **AND IT CLOSED A THIRD CASE THAT THE FOUR-METHOD TABLE REPORTED AS CLEAN —
|
||
"MEASURE THE ELEMENTS, NOT ONLY THE PAGE", WHICH IS ALREADY A RULE IN THIS
|
||
SECTION.** With the fix disabled as a control, over 420 points (3 pages × 10
|
||
widths × 6 minimum-font-size presets and 8 root sizes), **69 failed: 12 by
|
||
document overflow and 57 by the link overrunning its OWN box while the document
|
||
measured 0.** Those 57 sit at **root style 26 / 28 / 30 / 32 px** and at
|
||
**`minimumFontSize` 24 and 32** — and root style 32 px is a row the table above
|
||
reports as **0 of 352**, truthfully, because it is a document measurement.
|
||
|
||
**Why it never reached the viewport edge, measured rather than surmised:**
|
||
`.wrap` carries a **96 px** right gutter at that size, and the spill is smaller
|
||
than the gutter. At root 32 on `/about/` the link's box ends at 928 px and its
|
||
text at **1007.3 px** against a **1024 px** client width — 79 px of spill sitting
|
||
inside 96 px of padding. The spill shrinks as the viewport widens (79 / 71 / 60 /
|
||
35 / 15 / 0 px at 1024 / 1056 / 1100 / 1200 / 1280 / 1440), which is why the case
|
||
disappears at 1440 px rather than at a breakpoint.
|
||
|
||
**After the fix: 0 of 420, and 0 clipping at every preset and every root size.**
|
||
The control is what makes that number mean anything — a sweep returning zero on
|
||
every cell is the shape this section warns about twice, so it was re-run with the
|
||
declaration forced back to `normal` and it failed 69.
|
||
|
||
⚠️ **AND THE ELEMENT SWEEP THAT LOOKED FOR THE CAUSE REPORTED "NONE" AT THREE
|
||
OF THE FOUR WIDTHS** — instrument finding 2 in this section, committed again by
|
||
the person who wrote it down. `getBoundingClientRect()` reports **border boxes**,
|
||
so a `right > clientWidth` predicate cannot see text spilling out of a box that
|
||
is itself inside the viewport, which is precisely the 1024–1100 px mechanism.
|
||
The document figure was right and the offender list was empty, and those two
|
||
facts together are the signature of this bug. **Read `scrollWidth − clientWidth`
|
||
per element as well as per document.**
|
||
|
||
⚠️ **And the harness's offender list was truncated at 20 entries on 44 of the 88
|
||
failing rows**, so it could not have named the cause either — instrument finding
|
||
**7** below.
|
||
|
||
**What the fix costs, measured rather than asserted.** At the default text size,
|
||
**nothing**: the address is one line, **44.00 px** tall — exactly the touch-target
|
||
floor — at all 16 widths, and the normal-settings identity check is 0 differences
|
||
on 352 page-widths. Under minimum font size it wraps to two lines and the break
|
||
is mid-token — `info@smlcompany` / `.ca` at 320 px, `info@smlcomp` / `any.ca` at
|
||
1024 px, `info@smlcompa` / `ny.ca` at 1100 px — read out of the DOM one character
|
||
at a time and confirmed on a screenshot, not inferred from a width. The `href`
|
||
is untouched (`mailto:info@smlcompany.ca`), every painted glyph is inside the
|
||
viewport in **176 of 176** rows, and the link never clips its own box. A
|
||
mid-token break in an address is cosmetic; a document that scrolls sideways is a
|
||
WCAG 1.4.10 failure.
|
||
|
||
**`anywhere`, NOT `break-word` — and that was established with a NEGATIVE
|
||
CONTROL rather than from the rule.** `break-word` permits a break at layout time
|
||
without reducing min-content size, so it leaves the defect untouched: injected in
|
||
place of `anywhere` it failed **88 of 176** rows, the same rows as the unfixed
|
||
baseline. Both the baseline and the control had to fail for the trial to mean
|
||
anything — **a trial in which every candidate passes is a broken trial**, and
|
||
this section already carries five instrument findings of exactly that shape.
|
||
|
||
**On the link, not on the column, and not in the markup.** Three candidates all
|
||
reach 0 of 176: `anywhere` on the email link, on `.footer-contact`, and on
|
||
`.footer-col a`. The address is the only string in the footer that produces
|
||
**document** overflow, so the rule sits on the address and the two broader
|
||
selectors were declined as broader than the cause.
|
||
|
||
⚠️ **THE REASON FIRST GIVEN FOR THAT SCOPING WAS THE WRONG TEST, AND IT IS WORTH
|
||
MORE THAN THE SCOPING.** It said *"the address is the only string in the footer
|
||
with no break opportunity — the location line, the response sentence and the
|
||
sixteen nav labels all have spaces"*. **A space is not the test. Min-content is
|
||
set by the longest WORD**, so a label with spaces still overruns its track when
|
||
one of its words does not fit. `adversarial-reviewer` found one that does, and it
|
||
reproduced at a width the review had not sampled:
|
||
|
||
| width | element | own-box overrun | clearance to the next column | document |
|
||
|---|---|---|---|---|
|
||
| 640 px | `.footer-col a` "Construction & Infrastructure" | **24 px** out of a 176 px box | **7.7 px** | 0 |
|
||
| 640 px | `.footer-col a` "Shareholder & Family Business" | 2 px | 7.7 px | 0 |
|
||
| 700 px | `.footer-col a` "Construction & Infrastructure" | 4 px out of a 196 px box | 27.7 px | 0 |
|
||
|
||
`minimumFontSize=32`, all 22 pages, 12 widths from 320 to 1024 px = 264 rows;
|
||
clean at every other width sampled, including 768 and 834 px. **"Infrastructure"
|
||
is 14 characters and does not fit a 176 px track at 32 px** — the space in the
|
||
label is irrelevant to that.
|
||
|
||
⚠️ **AND THE 12-WIDTH GRID STEPPED OVER TWO MORE**: round 2 found the same label
|
||
at **17 px @660 and 11 px @680**, widths this grid does not sample. That is this
|
||
section's own caveat holding — *the grid is 16 columns, not a continuum* — and it
|
||
is the second time in one session that a between-columns width mattered.
|
||
|
||
**A FOURTH CASE OF THE SAME FAMILY, and it should be ruled on together with the
|
||
footer label rather than separately.** `minimumFontSize=32`,
|
||
`/practice/construction/` @320 px: `nav.crumbs > ol` overruns its own 272 px box
|
||
by **7 px**, furthest text edge 302.9 px against a 320 px client width,
|
||
`overflow-x: visible`, no clipping, **document overflow 0**. Clean at 360 / 390 /
|
||
414 px and on the other five practice pages. **No success criterion fails**, so
|
||
like the footer label it is recorded rather than fixed — but if
|
||
`.footer-col a { overflow-wrap: anywhere }` is ruled in, the same call covers
|
||
`.crumbs` and the ruling should be taken once for both. Raised by
|
||
`adversarial-reviewer`, round 2.
|
||
|
||
**Not fixed here, and the reason is a scope judgement rather than a measurement.**
|
||
It produces **no document overflow at any width**, so it fails no success
|
||
criterion; `.footer-col a { overflow-wrap: anywhere }` is measured to close it
|
||
and to be inert at normal settings. What it would change is how six practice-area
|
||
names break on a marketing surface under enlargement, which is a copy-adjacent
|
||
call. ⚠️ **The maintenance risk is the real finding: 7.7 px of clearance is one
|
||
label rename away from two columns colliding**, and nothing in the build measures
|
||
it. Pouya's to rule on; `docs/06` carries it. `word-break: break-all` also
|
||
reaches 0 and was declined for the same reason: it breaks where a normal
|
||
opportunity exists. **And NOT a `<wbr>` after the `@`**, which would give a
|
||
prettier break: it splits a §4-registered fact across an element boundary, so
|
||
`info@smlcompany.ca` would no longer be greppable in `dist/` — the surface
|
||
`npm run check:claims` reads — and its longest unbreakable run (`smlcompany.ca`,
|
||
~224 px) does not fit the 224 px column at 1024 px anyway, so it would need
|
||
`anywhere` as a backstop regardless.
|
||
|
||
**Not header-side, and that survives the fix:** no header element extends the
|
||
document at any width under any of the four mechanisms, and the nav items and CTA
|
||
are on-screen in **1408 of 1408** measurements. *(This said "946 of 946" for one
|
||
revision. 946 is a different sweep — the four runs made with the tautological
|
||
skip probe in instrument finding 4 — and reusing it for the final build was the
|
||
two-similar-totals mistake this section keeps warning about.)* *(The Practice
|
||
dropdown panel's contents overflow their own box by 84 px under minimum font
|
||
size and 40 px at the default, on all 22 pages — unchanged by this fix, and it
|
||
never extends the document because the panel sits inside a **closed**
|
||
`<details>`. Re-checked after the fix rather than carried forward.)*
|
||
|
||
🛑 **THE MINIMUM-FONT-SIZE STICKY RESIDUAL IS BACK OPEN, AND IT IS NOW A
|
||
CONFORMANCE FAILURE RATHER THAN A CONVENIENCE LOSS. IT NEEDS A FRESH RULING —
|
||
THE ONE OF 2026-09-01 WAS TAKEN ON TWO FACTS THAT ARE BOTH WRONG.** Pouya ruled
|
||
it accepted on the basis *"you proved no CSS mechanism can see
|
||
minimum-font-size; the only fix is JS and zero-JS is a founding decision."*
|
||
`adversarial-reviewer` attacked both halves of that and both attacks reproduced.
|
||
|
||
**(1) THE PREMISE IS FALSE.** The proof was of `rem`/`em` and of queries written
|
||
in them. The font-metric units `ch`, `ex`, `cap`, `lh` and `rlh` all read the
|
||
*used* font size and double under the setting — in property values, in `@media`
|
||
and in `@container`. The table at the top of this section carries the
|
||
measurement. **A pure-CSS detector for this mechanism exists**, so the residual
|
||
is not unfixable and "the only fix is JS" does not hold.
|
||
|
||
**(2) THE COST WAS UNDERSTATED, AND BY A CATEGORY RATHER THAN A NUMBER.** The
|
||
record described it as the skip link landing short — 68–174 px of `#main` behind
|
||
the header — and argued *"one of the two is a WCAG 1.4.10 failure while the other
|
||
degrades the convenience of a skip link that still works."* What is actually
|
||
happening is that **ordinary keyboard focus lands entirely behind an opaque
|
||
sticky header**, which is **WCAG 2.2 SC 2.4.11 Focus Not Obscured (Minimum),
|
||
Level AA** — the same conformance level as the 1.4.10 failure it was traded
|
||
against. **So the asymmetry that was the whole argument does not exist: both
|
||
sides are AA failures.**
|
||
|
||
**Measured, and the instrument is checked in three ways.** Shift+Tab walk — the
|
||
ordinary way a keyboard user returns to a link they passed — 6 pages × 5 widths
|
||
≥ 1056 px, 70 steps per cell, elements inside the header excluded:
|
||
|
||
| build | mode | focus stops | entirely hidden | zones |
|
||
|---|---|---|---|---|
|
||
| working tree | default | 1,455 | **0** | — |
|
||
| working tree | `minimumFontSize=32` | 1,455 | **290** | 254 footer, **36 `#main`** |
|
||
| `fce89d4~1` (pre-header-fix) | `minimumFontSize=32` | 1,455 | **0** | — |
|
||
| `fce89d4~1` (pre-header-fix) | default | 1,455 | **0** | — |
|
||
|
||
⚠️ **THE THIRD ROW IS THE ONE THAT MATTERS: THE HEADER FIX CREATED THIS.** Before
|
||
it, `flex-wrap: nowrap` kept the masthead one row under this setting, so it was
|
||
short enough that focus landed clear; the overflow went sideways instead. After
|
||
it the masthead wraps to 164.58–270.56 px and covers the 97 px landing. This is
|
||
a **regression introduced by the 2026-09-01 header fix**, not a condition it
|
||
inherited — and the ruling to accept it was taken without that comparison
|
||
existing.
|
||
|
||
**Instrument checks, because a focus-obscuring count is easy to fake in both
|
||
directions.** *(a)* The default-mode run returns **0**, so the predicate is not
|
||
tautological. *(b)* ⚠️ **Geometry alone was WRONG and said 43.** All 43 were the
|
||
**skip link**, which is stacked deliberately *above* the header and is not
|
||
covered by it at all — so `elementFromPoint` at the focused box's centre is
|
||
authoritative and geometry only nominates candidates. Under the setting there
|
||
are 333 geometric candidates and **290** survive the hit test. *(c)* Sampled
|
||
hits agree 6 of 6, e.g. `/` @1056: focused `a` "Technology, AI & Data" at
|
||
97 → 199.4 px inside a header at 0 → 228.6 px, `position: sticky`,
|
||
`inset-block-start: 0px`, background `rgb(250, 247, 242)` — opaque cream.
|
||
|
||
✅ **FIXED AND CLOSED — Q61 RULED *fix now*, 2026-09-01. Candidate B SHIPPED.**
|
||
Two candidates existed, and the second was better on the exact ground that had
|
||
deferred the first. The measurements below are the pre-fix state; the acceptance
|
||
sweep that closed it is at the end of this block.
|
||
|
||
*Candidate A, round 1:* a third gate term `calc((15px - 1ch) * 100000)`,
|
||
un-sticking the header under the setting while leaving `inset-block-start: 0px`
|
||
at the default. **Deferred:** 15 px is fitted to Geist's `ch`, so it needs
|
||
checking under fallback metrics and Chrome's other font presets, and the
|
||
reviewer's own injection of it was flaky.
|
||
|
||
*Candidate B, round 2 — recommended:* `scroll-padding-top`, the property that
|
||
already exists for this job, rather than un-sticking anything. Two declarations
|
||
in the existing `@media (min-width: 66rem)` block:
|
||
|
||
```css
|
||
html {
|
||
scroll-padding-top: calc(var(--header-h) + var(--space-4)); /* fallback: no `lh` */
|
||
scroll-padding-top: max(
|
||
calc(var(--header-h) + var(--space-4)),
|
||
calc(10lh - 83px)
|
||
);
|
||
}
|
||
```
|
||
|
||
**Verified independently — every value read from `getComputedStyle`, not
|
||
reasoned:**
|
||
|
||
| condition | `1lh` on `<html>` | computed offset | header | verdict |
|
||
|---|---|---|---|---|
|
||
| default, fonts loaded | 18 px | **97 px — byte-identical to shipped** | 81 px | clears |
|
||
| `minimumFontSize=32`, fonts loaded | 37 px | **287 px** | 270.56 px | clears |
|
||
| default, **every `.woff2` blocked** | **18 px** | **97 px** | 81 px | clears |
|
||
| `minimumFontSize=32`, **`.woff2` blocked** | **37 px** | **287 px** | 270.56 px | clears |
|
||
| `minimumFontSize=32`, family Georgia | 36 px | 277 px | 270.56 px | clears |
|
||
| `minimumFontSize=32`, family Verdana | 39 px | 307 px | 270.56 px | clears |
|
||
|
||
⚠️ **Rows 3 and 4 are why B beats A: `1lh` on `<html>` is immune to the webfont
|
||
fallback.** `<html>` keeps the UA default family — `--font-sans` is set on
|
||
`body`, and `<html>`'s computed `font-family` measures `Times` — so blocking every
|
||
font file changes nothing, and the fallback-metrics caveat that deferred
|
||
candidate A does not apply to B at all.
|
||
|
||
**What was said against B before it shipped, and what the sweep did with each.**
|
||
`10lh - 83px` is two fitted constants — the difference from A being that a
|
||
`max()` ramp degrades by pixels if they are off, where A's `* 100000` step flips
|
||
stickiness the wrong way. **The first declaration is load-bearing:** an engine
|
||
without `lh` support drops the whole `max()` as invalid and `scroll-padding-top`
|
||
falls back to `var(--space-4)` = 16 px, which is *worse* than no fix — so it is
|
||
written first, as a plain declaration, and must stay there. The focus probe is
|
||
**Chrome-only**, which is still true and is a limit on the evidence rather than
|
||
on the fix. The two remaining objections were discharged by measurement:
|
||
|
||
**THE ACCEPTANCE SWEEP — 777 cells over 37 settings, plus the focus walk.**
|
||
|
||
Per cell: navigate, kill transitions *before* any root-size change, set the
|
||
root, `location.hash = 'main'`, then read how much of `#main` sits behind the
|
||
header. The cell count is asserted, so a silently-truncated grid cannot pass.
|
||
|
||
| setting | before | after |
|
||
|---|---|---|
|
||
| root style 9..32 (504 cells) | 0 | 0 |
|
||
| `defaultFontSize` 9 / 12 / 16 / 20 / 24 (105) | 0 | 0 |
|
||
| `minimumFontSize=9`, `=12` (42) | 0 | 0 |
|
||
| `minimumFontSize=16` (21) | 6 cells, max 52 px | **6 cells, max 52 px** |
|
||
| `minimumFontSize=20` (21) | 12 cells, max 64 px | **6 cells, max 14 px** |
|
||
| `minimumFontSize=24` (21) | 15 cells, max 75 px | **0** |
|
||
| `minimumFontSize=32` (21) | 15 cells, max 174 px | **0** |
|
||
| fonts blocked (21) | 0 | 0 |
|
||
| fonts blocked + `minFont=32` (21) | 15 cells, max 174 px | **0** |
|
||
| **total** | **63 of 777** | **12 of 777** |
|
||
|
||
**The focus walk, which is the criterion itself: 290 entirely-hidden stops of
|
||
1,455 → 0.** Same grid (6 pages × 5 widths ≥ 1056 px, 30 cells, 344
|
||
header-internal stops skipped), hit-tested with `elementFromPoint`. **The
|
||
pre-fix tree was rebuilt in a git worktree and put through the identical probe:
|
||
it still reports 290** — footer 254, `#main` 36, geometry candidates 333 against
|
||
the fixed build's 43. That control is what makes the 0 a result rather than a
|
||
hope: both modes reading 0 with identical step counts is the shape `CLAUDE.md`
|
||
warns *ends* a check instead of starting one.
|
||
|
||
**Default settings unchanged: 0 differences over 352 page-widths × 17 fields =
|
||
5,984 comparisons**, full-page geometry fingerprint included, positive control
|
||
detecting exactly 1 injected difference.
|
||
|
||
⚠️ **AND THE FIRST ATTEMPT AT THAT COMPARISON REPORTED 4,224 DIFFERENCES.** It
|
||
keyed rows on `page`/`width`; this harness names them `url`/`w`, so every row
|
||
collapsed onto one map entry and was compared against an arbitrary single row.
|
||
It was caught only because the result was *uniformly bad*. **The fix is not the
|
||
corrected field names — it is that the comparison now asserts the key fields
|
||
exist and are unique before comparing anything.**
|
||
|
||
⚠️ **ONE COLUMN OF THE SWEEP WAS MEASURING NOTHING, AND TWO METRICS
|
||
DISAGREEING IS WHAT SURFACED IT.** A summary column reported
|
||
`min(scroll-padding-top − headerH)` of **−71.89 px** on root-style rows whose
|
||
`covered` read **0**. Cause: `insetBlockStart` is **−900 px** in those rows —
|
||
the header is `position: sticky` and **not pinned**, because that is exactly how
|
||
the saturating `clamp()` gate un-sticks it. Comparing the offset to the header
|
||
height is meaningless where the header is not pinned. `covered` — real geometry
|
||
after a real hash navigation — is the valid metric. **A `position` of `sticky`
|
||
is not the same fact as "the header is pinned", and any future probe that
|
||
compares an offset against a header height has to read
|
||
`inset-block-start` too.**
|
||
|
||
**THE 12 REMAINING CELLS ARE PRE-EXISTING, REDUCED, AND DELIBERATELY NOT
|
||
FIXED.** `minimumFontSize=16` leaves 52 px of `#main` behind the header on
|
||
`/about/` and `/contact/` at 1280, 1440 and 1920 px; `=20` leaves 14 px on the
|
||
same six. Every one is unchanged or better than before the fix and none is new.
|
||
**Why the ramp cannot see them, which is the part worth keeping:** the setting
|
||
*floors* computed sizes, so at `minimumFontSize=16` the root is genuinely 16 and
|
||
`1lh` on `<html>` is 18 px — identical to the default — while the header grows to
|
||
**149.38 px** because the **sub-16 px** type (the 11 px tagline, the 14 px
|
||
eyebrow and nav) is floored up. **The ramp reads a quantity the setting did not
|
||
move.** Closing it needs a different measurement — `1lh` taken from an element
|
||
that carries the small type, or a floor on the header — which is a header change
|
||
with site-wide reach. Pouya's instruction on this step was *"if the sweep
|
||
surfaces anything beyond this one declaration's blast radius, stop and report —
|
||
do not widen"*, so it is reported: `docs/06` carries it as its own item. It is
|
||
**not** an SC 2.4.11 failure, which is about a component *entirely* hidden.
|
||
|
||
**`docs/06`'s item is ticked and carries the figures.** The decision was
|
||
`AGENTS.md` **Q61**, now closed.
|
||
|
||
⚠️ **AND ONE MORE LATENT CASE, AT THE DEFAULT TEXT SIZE — NO READER SETTING
|
||
INVOLVED: FALLBACK FONT METRICS PLUS THE SEVENTH NAV ITEM.** With every `.woff2`
|
||
blocked — what a reader on the Slow 4G profile `docs/04` budgets against sees
|
||
first, since the fonts are `font-display: swap` — and a seventh nav item cloned
|
||
from a real node, the header measures **141 px at every width from 1056 to
|
||
1091 px**, and 81 px from 1092 px up. With the webfonts loaded it is 81 px at all
|
||
of them. **Two consequences, and the record first carried only the smaller one:**
|
||
|
||
1. **A 60 px collapse** when Geist swaps in — a CLS contribution on all 22 pages,
|
||
against the CLS < 0.05 budget. **Larger, not new:** the previous build gives
|
||
86.59–86.97 px in the same band, so a ~5.6 px shift already existed.
|
||
2. ⚠️ **AND 44 px OF `#main` BEHIND THE STICKY HEADER** after the skip link,
|
||
because 141 px exceeds the 97 px `scroll-padding-top`. **This one IS new** —
|
||
the previous build's 86.97 px stayed under 97 px, so it covered **0**. Framing
|
||
this case as "a CLS contribution" understated it, and a reader ruling on a
|
||
layout shift would weigh it differently from one ruling on the skip link.
|
||
|
||
*(The band was recorded as "1056 / 1064 / 1072 / 1084 … 81 px from 1092 up",
|
||
which reads as though 1086–1090 were checked and clear; they are all 141 px. It
|
||
is a contiguous **36 px** band, 1056–1091. Both errors found by
|
||
`adversarial-reviewer`.)*
|
||
|
||
**It is latent, not shipped:** with six items there is no wrap at any width, and
|
||
`showInsights` turns the seventh on only at two published articles. **Not fixed
|
||
here, and deliberately:** the two candidates are raising the desktop breakpoint
|
||
past 1091 px, which changes the layout at normal settings in that band, or giving
|
||
Geist a metric-matched `size-adjust` fallback, which is a font-stack change well
|
||
outside "the header and its consumers".
|
||
|
||
⚠️ **AND IT IS NOW GATED RATHER THAN MERELY PARKED — Pouya's ruling,
|
||
2026-09-01: NO SEVENTH NAV ITEM SHIPS UNTIL THIS IS FIXED.** Fixing it is a
|
||
**prerequisite of publishing the second Insights article**, because publishing
|
||
article #2 is what adds the item — `SiteHeader` computes `showInsights` from the
|
||
collection, so nothing else stands between that editorial decision and both
|
||
consequences above. The gate is recorded in three places on purpose: `AGENTS.md`
|
||
R20, `docs/06`'s `/insights/` state item, and the comment on `showInsights` in
|
||
`SiteHeader.astro` — the last of those being the only one a person editing an
|
||
article's front matter is likely to be looking at. The fix itself is *"to be
|
||
designed then, not now"*.
|
||
|
||
**What is still latent, measured and not a defect today:** `.hero-h` has **16
|
||
scoped rules, one per page, and only 2 carry `overflow-wrap: anywhere`** (`/` and
|
||
`/about/`). The other 14 measure 0 because their headlines' longest words are
|
||
shorter. The single-source fix would be to change `global.css`'s `h1`–`h6` rule
|
||
from `break-word` to `anywhere` and delete both overrides; that is a design-system
|
||
change with a site-wide blast radius on intrinsic sizing and it has not been
|
||
measured, so it is recorded here rather than done.
|
||
|
||
- **Measure the elements, not only the page.** A document-level overflow check
|
||
passes while a flex child absorbs the deficit by being crushed — that is how
|
||
step 1 shipped a logo at aspect 0.891 under a green "0 overflow at every
|
||
width". Assert the rendered geometry of the thing you care about. On `/`
|
||
every infinity-mark instance measures 1.5654–1.5657 against the master's
|
||
1.5657.
|
||
- **A touch-target measurement of the wrong box is not a finding.** The eight
|
||
cards on `/` report 26–39 px-tall `<a>` elements and are fine: each card's
|
||
whole box is the link's hit area via `::after { inset: 0 }`, verified by
|
||
hit-testing nine points per card at three widths (24 cards, 9/9). Hit-test
|
||
before enlarging anything. WCAG 2.5.8's inline exception also applies to a
|
||
link sitting mid-sentence, and one such link on `/` is deliberately left at
|
||
164 x 21.
|
||
- Every page must be readable and navigable with JavaScript disabled.
|