# Wave 31 — engine-level polish (site-chrome)

Owner's brief, verbatim: *"so many polish issues still exist, not even og mentioned one was fixed, so look into engine and do online research for this polish"*.

So this pass started by **reproducing the original complaints on the live tenants** rather than by editing CSS, and then fixed things at the level that makes them stay fixed — the shared sheet and the derived tokens, not one selector per symptom.

Tenants measured (all three run the same 26-listing / 20-post Kelowna fixture, so any difference is design, not data):

| tenant | family | display font | `--size-h2` | `--button-height` | `--section-pad-y` |
| --- | --- | --- | --- | --- | --- |
| `demo-kelowna-8rs0k` | journal / salt | Instrument Serif | 42 | 46 | 84 |
| `demo-kelowna-luxury` | luxury | Fraunces | 46 | 46 | 96 |
| `demo-kelowna-modern` | modern | Manrope | 38 | 46 | 76 |

Routes: `/`, `/listings`, `/about`. Viewports: 1440 and 390.

---

## 1. Verdict on each original complaint

| # | Complaint | Verdict | Evidence |
| --- | --- | --- | --- |
| 1 | Stat numerals render as old-style ("I7") | **Fixed, but only for 5 classes** — then fixed properly | `.t-stat-value` already measured `lining-nums tabular-nums`; **136 of 242** other scanned numbers were still `normal`. After: **12 of 255**, and those 12 are one cross-file `tabular-nums` shorthand (§2a) |
| 2 | Quote mark too small | **Fixed, then over-corrected so far that it broke 390 px** | 187.2 px on desktop (was the complaint), but at 390 the quote body was 82 px wide with 15 px overflow (§2b) |
| 3 | Kicker dash/indent | **Not reproducible** — 30/30 section heads aligned at 0 px | The first readings (105 px, 221 px) were probe artefacts; the research pass independently confirms 0.0 px text-edge alignment on all three families (§2c) |
| 4 | Two h2 scales on one page (42/48/16) | **Partly real, and deeper than reported.** The `16` is not a scale — it is `.t-sr-only`. The real defects: `h1 == h2` on every About page, two bespoke `clamp()`s for one role, and a **ramp that inverted at 390 px** | §2d, §2i |
| 5 | Cramped search fields | **Not reproducible** | 46 px tall, 12 px inset, 1 px border on both families that have them (§2e) |
| 6 | Spacing / hierarchy drift | **Already token-driven and consistent** | base ×1, `-tight` ×0.55, stats ×0.38, stats-inline ×0.28 of `--section-pad-y` (§2f) |
| 7 | *(found while measuring, not on the list)* | **Fixed** | 2 serious `color-contrast` failures on the luxury inverse surfaces (§2g) |
| 8 | *(found while measuring, not on the list)* | **Fixed** | `--card-media-ratio` emitted as `data-*` only, so eight `aspect-ratio` declarations were dead (§2i) |
| 9 | *(found while measuring, not on the list)* | **Fixed** | The featured lead card's price was anchored to the card bottom, so it painted white **across the address line** and failed contrast; now a grid overlay on the photo (§2j) |

A complaint that is reported as "not reproducible" means the current build does not show it — not that the owner imagined it. The oldstyle numerals and the `h1 == h2` flattening are the two that were genuinely still shipping. A crop for each claim is in `shots/` (`before-crop-*` / `after-crop-*`).

---

## 2. Findings, fixes and measurements

### a. Figures — one rule for every number, not one per component

The old-style "I7" complaint had already been answered, by adding `font-variant-numeric: lining-nums tabular-nums` — applied **twice**, to the classes in front of whoever was fixing it at the time (first stats, then card prices), and never to the rest.

Measured across the three tenants (element whose *own* text is a price or a count):

| | before | after |
| --- | --- | --- |
| numbers scanned | 242 | 251–255 across runs¹ |
| missing **lining** figures | **136** (`font-variant-numeric: normal`) | **12** |
| missing **tabular** figures | 124 | 55–59 |

¹ the demo inventory was being edited by another workstream during the pass, so the number of price/count elements on the pages moves between runs; the 12 and the 55–59 are stable across every run after the fix.

The 136 were: `.t-area-count` ×24, `.t-small.t-muted` price ranges ×24, `.t-absolute` map pins ×32, `.t-eyebrow` ×6, `.t-area-tile-count` ×6, `.t-listing-facts` ×4, `.t-editorial-row-price` ×4, `.t-search-range-value` ×4, plus anonymous spans (`$190K`, `5+`).

The **12** still missing lining are all anonymous range-bound labels (`$18M`, `$70K`, `$50K`) inside the price-range popover. Nothing in this sheet styles them: `apps/sites/app/template-listings.css` sets `font-variant-numeric: tabular-nums` on them, and that shorthand **resets** the inherited `lining-nums` — the same trap I fixed in my own two declarations. One-word change needed in a file owned by map-search (`:865`, `:1078`, `:1312`); reported, not edited.

Fix, in `apps/sites/app/template.css`:

```css
.t-site { font-variant-numeric: lining-nums; }          /* no role can regress */
```
…plus **one** consolidated data-role list (`lining-nums tabular-nums` + `"lnum" 1, "tnum" 1`) that replaced the two overlapping blocks. Prose keeps proportional widths; columns of prices/counts get tabular.

The 59 still without *tabular* are deliberately left: 31 map-pin buttons (Tailwind-classed inside map-search's component), 20 anonymous count chips (`5+`, `36 properties`), 8 card sub-labels with price *ranges*. All of them now render **lining** figures — the owner's actual complaint — and none of them is a scanned column, so proportional widths read better than a tabular grid.

Two `font-variant-numeric: tabular-nums` declarations (mine: `template.css` map-list switch, `template-detail.css`) silently *re**set* lining on those elements, because the shorthand replaces the inherited value; both now say `lining-nums tabular-nums`. Three more exist in `apps/sites/app/template-listings.css` (`:865`, `:1078`, `:1312`) — **owned by map-search**, same one-word change needed; reported, not edited.

Crops: `shots/before-crop-stats-numerals-demo-kelowna-8rs0k-1440.png`, `…after…`.

### b. Pull-quote — the desktop rule was beating the mobile rule

At 1440 the mark is 187.2 px (`clamp(140px, 13vw, 200px)`), which is why "too small" is no longer the complaint. At 390 the same two root-level rules — `grid-template-columns: minmax(0, 210px) minmax(0, 1fr)` and the 13vw font-size — landed **after** the `@media (max-width: 760px)` block that stacks the quote, and at equal specificity a later root rule wins. Measured:

| | before @390 | after @390 |
| --- | --- | --- |
| quote columns | `210px 122px` (journal) / `210px 82px` (luxury) | single column |
| quote body width | **122 px** / **82 px** | 342 px / 302 px |
| `scrollWidth` vs `clientWidth` | **317 > 302** (15 px overflow, luxury) | equal — no overflow |

Fix: both rules scoped to `@media (min-width: 761px)` (box behaviour stays global, size and measure do not). Crops: `shots/*-crop-pullquote-*`.

### c. Kicker / eyebrow alignment — no defect found

`align.mjs` compares the eyebrow's **text edge** with the heading's text edge, and switches to **centres** when both are `text-align: center`; `.t-sr-only` and clipped headings are skipped.

* first pass: 105 px (journal) and 363.8 px (luxury) — both probe artefacts: the journal hero's only `h1` is `.t-sr-only` at `x = -1`, and the luxury eyebrow/heading pair are both centred inside one band, so left edges are meaningless.
* after rewriting: **30/30 heads aligned at 0 px** — identical before and after my changes, and the independent research pass reaches the same conclusion by a different route (`text-node x identical to the heading x` on all three families at both viewports, class 104/104, luxury 104/104, modern 90/90).

No CSS change. The old "40 px rule + gap" bug the sheet's own comment describes has already been fixed in the current build; the research pass notes the same trap for future auditors — the eyebrow *box* sits 40 px left because its hairline hangs in the margin, so a box measurement will wrongly call it a misalignment.

### d. Heading roles — one ladder, derived

The QA harness's `42 / 48 / 16` on one page:

* **`16` is not a role.** It is `.t-sr-only` (`position:absolute; width:1px; height:1px; clip:rect(0,0,0,0)`) on the testimonials `h2` — a screen-reader label. It is *supposed* to be 16 px and is invisible. Fixing the "anomaly" by changing that heading would remove an accessible name.
* **`42 / 48` is real, and worse than reported.** Two separate scale escapes:
  * `.t-h1-compact { font-size: var(--size-h2) }` — every About page rendered its `h1` at exactly its `h2` size: **42/42, 46/46, 38/38**.
  * `.t-statement-heading { font-size: clamp(32px, 3.4vw, 48px) }` and `.t-statement-band-heading { font-size: clamp(30px, 3.6vw, 52px) }` — a *viewport*-derived size for a role that should follow the template. At 1440 the luxury home showed `46` (h2), `51.84` (= 3.6vw) and `16`.

Fix — one derived step, no new token contract (`resolve.ts`/`validate.ts` untouched, so `@ontempo/templates` cannot drift):

```css
.t-site { --size-h1-compact: calc(var(--size-h2) * 1.142857); }   /* 8/7 */
.t-h1-compact,
.t-statement-heading,
.t-statement-band-heading { font-size: min(var(--size-h1-compact), 9vw); }
```

| measurement | before | after |
| --- | --- | --- |
| About `h1` — journal | 42 (= h2) | **48** |
| About `h1` — luxury | 46 (= h2) | **52.57** |
| About `h1` — modern | 38 (= h2) | **43.43** |
| luxury home "statement" `h2` | **51.84** (= 3.6vw) | 52.57 (token-derived) |
| journal home statement | 48 | 48 (unchanged — the designed value the 8/7 step reproduces) |
| statement @390 — journal | 32 (= 3.4vw floor) | 35.1 (= 9vw) |
| statement @390 — luxury | 30 (= 3.6vw floor) | 35.1 (= 9vw) |

`h1 > h2` now holds on all three families at both widths, the statement role is proportional per family, and the only remaining `42,48,16`-shaped list is the one whose `16` is clipped off-screen.

The mobile cap is `min(…, 9vw)` rather than a second token because bones owns the responsive contract — a template cannot express a breakpoint, so every display role is `min(token, Nvw)` exactly like the existing `.t-h1` (11vw) and `.t-h2` (7.5vw).

### e. Search fields — not cramped

| | journal | luxury | modern |
| --- | --- | --- | --- |
| input height | 46 px | — (no search field) | 46 px |
| inline padding | 12 px | — | 12 px |
| border | 1 px | — | 1 px |
| radius | 0 | — | 10 px |

Every field is `--input-height` (46) with a 12 px inset; nothing is below the 44 px touch target. Radius differs by family because `--input-radius` is a token. **No change.**

The one dense cluster is the home hero search sub-row, and the only defect there was a button 8 px shorter than its neighbours — see (h).

### f. Section rhythm — already token-driven

Every band derives its own padding from `--section-pad-y`: base **×1**, `.t-band-tight` **×0.55**, `.t-stats-band` **×0.38**, `.t-stats-band.t-stats-inline` **×0.28**. Measured ratios are exactly those on all nine page/family combinations (84 / 46.2 / 31.92 and 96 / 52.8 / 26.88 and 76 / 41.8). The fractional values are exact sub-pixel multiples of the token, not drift. **No change** — inventing "nicer" numbers here would have broken the relationship the tokens already express.

### g. Inverse surfaces — two serious contrast failures (found, fixed)

axe (tags `wcag2a, wcag2aa, wcag21a, wcag21aa`) on the luxury home found, at **both** 1440 and 390:

| element | measured pair | ratio | floor |
| --- | --- | --- | --- |
| `.t-section-head > .t-lead` on `.t-band-inverse` | `#6e6659` on `#15130f` | **3.27:1** | 4.5 |
| `.t-btn-outline` label on the `.t-cta-card` | `#a94e08` on `#15130f` | **3.33:1** | 4.5 |

Cause: the palette's muted and accent inks are *light-surface* choices; the token contract validates `--color-inverse-ink` on `--color-inverse-bg` but nothing validates the muted prose or an outline button **as used inside a band**, so a template can pass the contract and still ship unreadable copy. Fix in the shared sheet: re-derive `--color-muted` / `--color-subtle` / `--color-accent-soft` inside every surface that paints `--color-inverse-bg` (`.t-band-inverse`, `.t-cta-card`, `.t-agent-band`, `.t-hero-spotlight`, `[data-footer-style="inverse"] .t-footer`, `.t-stats-band:not(.t-stats-inline)`), plus the outline label. `--color-accent` itself is deliberately **not** re-pointed: a filled button on the same band paints `--color-accent-ink` on it, so overriding accent there would turn that label white-on-cream.

| | before | after |
| --- | --- | --- |
| luxury home, 1440 | 1 serious (`color-contrast`) | **0** |
| luxury home, 390 | 1 serious (`color-contrast`) | **0** |

Mix percentages are the smallest that clear AA with margin (74% → 9.6:1, 62% → 7.1:1) so muted copy still reads as secondary.

### h. Buttons — one derived compact step

| control | journal | luxury | modern |
| --- | --- | --- | --- |
| body `.t-btn` | 46 | 46 | 46 |
| `.t-header-cta` | 38 | 38 | 46 |
| `.t-search-range-apply` | **38** | — | **38** |

Body buttons all sit on `--button-height`. Two controls were shorter, and both said `min-height: 38px` — a literal that only makes sense for a family whose `--button-height` is 46. Replaced with **one derived step**:

```css
.t-site { --button-height-compact: calc(var(--button-height) - 8px); }
```

consumed by `.t-header-cta` and `.t-search-range-apply`. Heights are unchanged today (38 stays 38); what changes is that a template with `--button-height: 60` no longer gets 38 px controls beside 60 px ones. The modern family's 46 px header CTA is not drift: it does not take the uppercase chrome that the compact rule belongs to.

`[data-nav-style="plain"] .t-nav-link { min-height: 38px }` is a nav hit-area, not a button; left alone (it is above the 24 px minimum).

Radius and focus states were measured and are consistent: focus is one ring (`outline: 2px solid var(--color-accent); outline-offset: 2px`) across chips, fields, buttons and range inputs; radius comes from `--button-radius` / `--input-radius` / `--card-radius` per family (0 journal/luxury, 999/10/… modern).

### i. Three P0/P1 defects the independent research pass found, adopted

`docs/research/polish-engine.md` (35 KB, landed mid-task) measured the same tenants against the hand-built specimens and listed twenty findings with an ordered top-8. Four of my fixes are recorded there in §7 as verified; three of its findings were in my files and I took them:

**1 · The display ramp inverted below 390 px.** `.t-h2` was `min(--size-h2, 7.5vw)` — a viewport cap with no floor — while `.t-h3` was the raw `--size-h3`. On the luxury home at 390 that is **h2 29.25 beside h3 30**: two heading steps out of order, the h3 visually bigger than its own h2.

```css
.t-h2 { font-size: clamp(calc(var(--size-h3) + 2px), 7.5vw, var(--size-h2)); }
.t-h3 { font-size: clamp(calc(var(--size-h4) + 2px), 6.2vw, var(--size-h3)); }
```

Each step's floor is derived from the step below it, so the ramp cannot invert at *any* viewport, for *any* family, without a second token.

| | before @390 | after @390 | @1440 |
| --- | --- | --- | --- |
| luxury h2 / h3 | **29.25 / 30 (inverted)** | **32 / 24.18** | 46 / 30 (unchanged) |
| journal h2 | 29.25 | 32 | 42 (unchanged) |
| modern h2 | 29.25 | 29.25 (floor 28 < 29.25) | 38 (unchanged) |

**2 · `--card-media-ratio` was emitted as `data-*` only.** `tokens.ts` listed it in `DATA_ONLY_TOKENS`, but eight rules in the shared sheet read `aspect-ratio: var(--card-media-ratio)`, so every one of them was an invalid declaration that silently painted nothing — the ratio the catalogue advertises simply never reached the page. Adding it to `DATA_AND_CSS_TOKENS` (`tokens.ts`) makes it emit both ways; the raw enum (`4/3`) is already valid CSS, which is why no `ENUM_CSS` mapping was needed.

| | before | after |
| --- | --- | --- |
| `--card-media-ratio` on the root | *absent* | `4/3` |
| team / area / post card media | intrinsic (nothing declared) | `4 / 3` |
| listing card media | `16 / 10` (hardcoded, higher specificity) | `16 / 10` (unchanged — intentional) |

`@ontempo/templates` tests stay **27/27** after the change; no token default, seed or validator moved, because a ratio enum is not a length.

**3 · `.t-h4` was on the body scale.** It inherited `--leading-body` (1.6) and had no tracking, so a 19 px h4 had a 30.4 px line box — 97 % of the 31.2 px h3 above it, which is why "h4 next to h3" read as one wobbly step. It now takes `calc(var(--leading-display) * 1.15)` and half the display tracking, like h3.

**Not adopted** (deliberately, all reported in the research doc with measurements so whoever owns them can act):
* *fluid `--section-pad-y`* and *`--size-h1-compact` as a catalogue token* — both change the token contract and the seeds, which would move `@ontempo/templates` and every template; the research itself notes the CSS-only derived value is what kept my other fixes safe.
* *`--color-focus` derivation* — it lives in `tokens.ts` defaults plus a competing accent rule; a palette-wide change, not a polish tweak.
* *card meta onto the type scale* (`13/9/9.5px → --size-small/--size-label`) — those literals are inside the card components' CSS block, which `map-search` is actively editing this wave; doing it here would collide.
* *button height by casing* — the research wants `min-height: var(--button-height)` (38 → 46) for both compact controls to meet the 44 px target. I kept the derived `--button-height-compact` (38) instead: the masthead CTA is desktop-only and the range button lives in a popover, so the 44 px floor is a *touch* rule that neither hits at 390 — changing 38 → 46 here would alter the design of two families on a judgement call, so it is the owner's to make.

### j. The lead card's price was on the address, not on the photo

The last two axe findings in the suite looked like an artefact and were not. Reproduced at 390 on the luxury home, the price label was painted **across the address line**:

```
2875 Shaylor Road, Kelowna, British Colu⟨$1,480,000⟩ia V1W2R1
```

`.t-editorial-lead-price` was `position: absolute; left: 20px; bottom: 18px` — anchored to the bottom of the whole `<a>` (photo **plus** the copy block below it), so it hung 18 px above the bottom of the *copy*, not the bottom of the *image*. It only looked like an overlay on the two smaller cards beside it, because those are a different component. At 1440 axe then read it as `#ffffff` on the page's `#d7…` placeholder (1.41:1) — the contrast failure was the symptom, the misplacement was the bug.

Fix — a two-row grid, so the image and the label share a row and the copy takes the next one; no pixel offset in it, so it holds at any copy length:

```css
.t-editorial-lead       { display: grid; grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; }
.t-editorial-lead-media { grid-column: 1; grid-row: 1; aspect-ratio: 4 / 3; background-color: var(--color-inverse-bg); }
.t-editorial-lead-price { grid-column: 1; grid-row: 1; align-self: end; justify-self: start; margin: 0 0 18px 20px; }
.t-editorial-lead-copy  { grid-column: 1; grid-row: 2; }
```

The media also now paints `--color-inverse-bg` while it loads or if it 404s. A white overlay label over a *light* placeholder is unreadable, and the other seven `aspect-ratio` fixes above only matter if the box they size has a colour.

| | before | after |
| --- | --- | --- |
| price box @1440 | x 20, **y 542** (below a 512 px image, on the copy) | inside the image box, bottom-left |
| price box @390 | white over the address text | inside the image box, bottom-left |
| image fill before load | transparent (page cream shows through) | `#15130f` |
| axe on luxury home (both widths) | 1 serious `color-contrast` | **0** |

---

## 3. Regression evidence

`evidence.mjs` walks all 18 tenant × route × viewport combinations and records the screenshot, axe serious/critical count, horizontal overflow and `h1` count. A route that is not 200 is recorded as such — never as "0 violations".

```
18/18 pages 200 · 0 with a violation, overflow or h1 count
```

That is every combination: three families × `/`, `/listings`, `/about` × 1440 and 390, each HTTP 200, **0** serious/critical axe violations under `wcag2a, wcag2aa, wcag21a, wcag21aa`, no horizontal overflow, exactly one `h1`.

Caveat worth stating plainly: the tenants were **down four times** during this pass, all from concurrent edits on another workstream (`ReferenceError: searchListings is not defined` in `lib/template-listings.ts`, `Export decodeBounds doesn't exist` in `lib/map.ts`, `listSiteListingIndex` renamed out of `lib/queries.ts`, and a `map/listing-map.tsx` runtime error). Nothing measured during those windows is reported — several earlier runs were discarded whole rather than quoted, which is why the run above is the only one cited.

`@ontempo/templates` tests: **27/27 pass**. `@ontempo/sites` typecheck: **clean**. (`@ontempo/templates` typecheck: clean.) Both were red earlier in the pass on the same concurrent work — `components/template/listings.tsx(318,8)`, `(360,12)` and `lib/template-listings.ts(268,19)` — and cleared when that workstream finished; they were never caused by this task, and the `tokens.ts` change was re-verified after them.

---

## 4. Files changed

| file | change |
| --- | --- |
| `apps/sites/app/template.css` | heading-role table (`--size-h1-compact`, 8/7 of `--size-h2`) + `.t-h1-compact` / `.t-statement-heading` / `.t-statement-band-heading` consume it; **h2/h3 floors derived from the step below** so the ramp cannot invert at 390; **h4 leading/tracking moved onto the display scale**; root `font-variant-numeric: lining-nums` + one consolidated data-role list replacing two overlapping blocks; pull-quote desktop rules scoped to `min-width: 761px`; inverse-surface ink derivation + outline label; `--button-height-compact` consumed by the masthead CTA and the range "Apply"; **lead card re-laid as a grid so its price sits on the photo**, with an inverse-bg media fill for the loading state; two `tabular-nums` → `lining-nums tabular-nums` |
| `apps/sites/app/template-detail.css` | one `tabular-nums` → `lining-nums tabular-nums` (line 410) |
| `packages/templates/src/tokens.ts` | `--card-media-ratio` added to `DATA_AND_CSS_TOKENS` (eight dead `aspect-ratio` declarations now resolve) — no default, seed or validator moved, tests stay 27/27 |
| `packages/design-engine/src/themeStyles.ts` | **not changed** — it emits the legacy `--site-*` layer, not the `--size-*` scale these pages read |
| `audit/ux/wave31/polish/*` | harness + evidence (below) |

Deliberately untouched: `components/template/{home,primitives,listings,listing-detail,cards,contact-form,journal}.tsx`, `app/template-listings.css`, `packages/templates/src/resolve.ts`. Two of the fixes above could be finished off by their owners in one line each:

1. `apps/sites/app/template-listings.css` — three `font-variant-numeric: tabular-nums` (`:865`, `:1078`, `:1312`) should be `lining-nums tabular-nums`, otherwise those counters drop the lining figures the rest of the page now has.
2. Map price pins (`apps/sites/components/template/listings*`) are Tailwind-classed buttons with no `t-` hook; they inherit the root lining default now, but a `t-map-pin` class would let them get tabular figures without a brittle `[class*="absolute"]` selector.

---

## 5. Harness in this folder

| file | what it does |
| --- | --- |
| `measure.mjs <label>` | headings, kickers, fields, bands, buttons, overflow → `measurements-<label>.json` |
| `numerals.mjs <label>` | every element whose own text is a price or a count, with the computed `font-variant-numeric` → `numerals-<label>.json` |
| `align.mjs <label>` | section-head alignment, text-edge for start heads / centre for centred heads → `align-<label>.json` |
| `crops.mjs <label>` | per-finding element crops → `shots/<label>-crop-*.png` |
| `evidence.mjs <label>` | full-page screenshot + axe + overflow + `h1` count for all 18 combinations → `evidence-<label>.json`, `shots/<label>-<tenant>-<route>-<width>.png` |

Run with the repo's node from the workspace root, e.g.
`node audit/ux/wave31/polish/evidence.mjs after`.
Every script takes the label as the only argument so `before` and `after` stay comparable, and every one *skips and reports* a region that does not exist on a variant rather than writing a blank file.
