--- summary: 'Ink/contrast machinery (readableOn, accessibleInk, ctx.defaultBg, deck-audit measurement) and the exemption/calibration discipline behind it' read_when: - adding or editing a layout/component that paints its own background or bakes a text fill - a full-matrix-contrast.test.ts failure, or a new colors.muted usage - deciding whether a low-contrast finding is a real defect or an adjudicated exception - adding to or debugging the optional --pixels pixel-contrast audit --- # Contrast system The densest defect history in this project — most of what's below exists because a first-principles-looking fix turned out wrong against real render geometry. Read before touching text color in render code. ## Three-tier depth paint The depth contract used to fade every midground leaf to stay under the 3:1 content-decor ceiling. That flattened colors that *are* the page, and also flattened colors that *are* the theme. Pieces opt out by an explicit `DecorPiece` role (`docs/designing-themes.md` rule 8), never by guessing at a hex: - **Structure** (`role: "structure"`): the page's own chrome. A Swiss top bar, a memo double rule, a vermilion head rule, a luxe invitation frame. Partition lifts it into the foreground. Theme color, no fade, no intensity cap. Gallery L1's midground contrast walk skips it (`skipsMidgroundCeiling`) if it still appears in mid. - **Identity** (`role: "identity"`, also `data-identity`): a midground mark whose color is the theme. An ink vermilion seal. It stays under type so it cannot cover copy. The saturation cap and the 3:1 fade do not touch it. - **Ordinary decor**: pattern, ghost numerals, wash, ledger's baseline. Recedes under the 3:1 ceiling. Do not mark a whole motif to dodge it. ## The three-tier contrast policy Every piece of text this renderer paints falls into exactly one of three tiers. The policy exists because "dim it a bit" used to be a per-layout, per-reviewer judgment call with no shared floor. The contrast-policy wave (`.issues/2026-07-28-contrast-policy/plan.md`) formalized it after two real violations shipped under that ad-hoc discipline (a copyright line at 2.93:1, a period-ending heading at 1.57:1), neither caught by any gate. | Tier | What it covers | Floor | Why | |---|---|---|---| | **A, content** | Body copy, headings, labels, chart values: anything a reader is expected to read as the page's actual content | 4.5:1 (3:1 for large text, ≥24px) | WCAG 2.1 SC 1.4.3, unchanged. This project's existing baseline (`requiredContrastRatio`, below) | | **B, meta-information** | Copyright lines, page numbers, organization names, dates: real information, deliberately understated | **3:1, hard, at every size** | See below | | **C, pure decoration** | Ghost-digit watermarks, motif lines, ambient background elements | No ratio floor, but every instance must live in `full-matrix-contrast.test.ts`'s ratio-banded `ALLOWLIST` | See below | **Why B is not "incidental text" (WCAG's own carve-out for decoration/logos):** a copyright line carries real content, attribution and legal notice, and a reader can and does read it on demand (verifying who owns a deck, whose logo this is). WCAG's incidental-text exemption is for text nobody is expected to read at all. That is not this. At the same time, a copyright line is short, low-priority, and conventionally rendered quiet, so holding it to the full 4.5:1 body floor would force it to compete visually with actual content. 3:1 (the same numeric floor WCAG already grants *large* text) is the honest middle: legible on demand, still visually subordinate. Treating B as a distinct tier, not "A, but we'll allow a violation" and not "C, so anything goes," is what makes the floor enforceable instead of aspirational. **Mechanism:** `metaInk(preferredFill, bgHex)` (`src/render/ink.ts`, below) is B tier's ink-selection counterpart to `accessibleInk`. It keeps the theme's preferred color when it already clears 3:1, otherwise nudges it toward `readableOn`'s neutral ink in small steps rather than jumping straight to full strength. A B-tier text element also carries `data-contrast-tier="meta"` in the rendered SVG. `deck-audit.ts`'s contrast walk reads that attribute and holds the element to 3:1 instead of the default size-driven 4.5:1/3:1 split, a minimal extension of the existing data-audit-attribute family (the same idiom `DECORATIVE_ALPHA`-gated skipping already established: audit behavior varies by attribute, not just by measured geometry). `full-matrix-contrast.test.ts` picks this up automatically since it runs the same `auditDeck` path, no separate test wiring needed. This tier split **superseded** an earlier per-file adjudication. `ending-banner-ending.tsx`/`ending-rail-ending.tsx` used to each hardcode a private orphan constant (`COPYRIGHT_FAINT`) as a "pin the exact hex, forever, cross-theme" exception, on the theory that a policy-free codebase had no better option than sealing each instance individually. Once a real policy exists, a fixed cross-theme gray is itself the defect: it has no way to stay legible on a background the token system doesn't know about (a dark theme can render a hardcoded light-mode gray unreadable). Both files now derive their copyright-line ink from `metaInk(colors.muted, bg)` instead. See each file's own header comment for the full overturned-verdict history. C tier (pure decoration) is unchanged in mechanism: it is the existing `ALLOWLIST`/`ratioMin`/`ratioMax` discipline the "Full-matrix regression net" section below already documents, just named explicitly now. The policy's only new requirement on C is that *every* decorative low-contrast instance actually lives in that allowlist (drift is a defect the net catches, not a silent pass), nothing here changes how an entry is written. ## Ink selection (`src/render/ink.ts`) `readableOn(bgHex)` (lines 114-121) picks whichever of near-black/white **actually measures the higher WCAG contrast ratio** against `bgHex` — real dual-ink comparison, not a fixed luminance cutover. Its predecessor used a fixed 0.4-luminance threshold that "leaned toward white on a large block," silently giving the wrong ink to every background with luminance in (~0.19, 0.4] (dark ink's true break-even is ~0.19). That bug drove several theme-curation exclusions, all reverted now that the real comparison is in place (`src/themes/definitions.ts`). `accessibleInk(preferredFill, bgHex, fontSizePx)` (135-139) is the call every layout makes at a flagged text element: keep the preferred color if it already clears the size-appropriate ratio (3:1 large text ≥24px, else 4.5:1. `requiredContrastRatio` mirrors `deck-audit.ts`'s own constants by design), else fall back to `readableOn`. `accessibleOpacity(inkHex, bgHex, fontSizePx, preferredOpacity)` (260-268) does the same for a dimmed secondary-text tier: keep the requested opacity if the alpha-blended result still clears the ratio, else force full opacity. `graphicInk(preferredFill, bgHex)` is the same keep-or-fall-back shape for a *graphic* mark rather than text — an icon stroke, a state rule — against WCAG 2.1 SC 1.4.11's non-text floor of 3:1, held at every size because a graphic has no font size to relieve it. Icons need their own call rather than a hand-picked token because several themes give `colors.primary` a block-fill job instead of an ink job: `rally` names it "更深一档给横幅/色块" and measures 1.08:1 against its own page background on purpose, since the only thing ever painted *on* primary is white type. Handing that token to `Icon` as a stroke inherits the block-fill luminance and paints a mark nobody can see — rally's bento and kpi-card icons sat at 1.25:1 against `colors.surface` until the gallery review caught them. Call sites: `components/kpi.tsx`, `components/icon-cards.tsx`, `layouts/content-bento-panel.tsx`, each measuring against the `colors.surface` shell it actually paints. The call is a no-op wherever the theme's token already clears 3:1, so a theme whose primary is a real ink keeps its icons byte-identical. `groupValueInks(inputs, fallbackFill)` applies that policy to one component instance's sibling values. Each input carries its preferred graphic fill, rendered background, and font size. The helper keeps all preferred fills only when every input clears its own floor. If any input fails, every sibling uses `accessibleInk(fallbackFill, backgroundFill, fontSizePx)`, with callers passing `colors.text` as the fallback. This group decision exists because crayon's bubble-row palette once straddled the floor and produced colored values beside fallback-dark values in the same comparison row. Components must never make the keep-or-fallback decision separately for sibling values. `metaInk(preferredFill, bgHex)` (314) is B tier's own ink pick (see "The three-tier contrast policy" above), the same "keep the preferred color if it already passes" shape as `accessibleInk`, but against a fixed 3:1 floor (`META_CONTRAST_RATIO`, never size-relieved: a meta line's floor doesn't change just because it happens to render large) and a gentler fallback. Instead of `accessibleInk`'s single jump to `readableOn`'s full-strength neutral ink, it walks `preferredFill` toward that neutral ink in `META_INK_STEPS` (20) discrete blend steps and returns the first one that clears 3:1, the smallest nudge that works, since a meta line is deliberately the faintest legible tier on the page and jumping straight to full-strength ink would read as a different tier entirely. Termination is guaranteed: `readableOn(bgHex)` (the walk's own `alpha = 1` end) always measures >= ~4.398:1 against any real background (the dark/light break-even luminance is ~0.1888, see `readableOn`'s own doc comment), comfortably above the 3:1 floor, so the loop always finds a passing step by the last one at the latest. A text element rendered with `metaInk` also needs `data-contrast-tier="meta"` set on its own element so `deck-audit.ts` measures it against the same 3:1 floor instead of the default. See `ending-banner-ending.tsx`/`ending-rail-ending.tsx`'s copyright-line call sites for the paired pattern (ink pick plus attribute, always together). **Two self-painted-surface precedents**, both real call sites worth copying: `content-tone-adaptive-content.tsx` calls `accessibleInk(colors.text, "#FFFFFF", …)` — a component drawing its own white card ignores `ctx.defaultBg` entirely and measures against the literal fill it just painted. `kpi.tsx` does the same against `ctx.colors.surface` (`accessibleOpacity(colors.muted, colors.surface, 11, 0.7)`). The rule: if you paint the surface, measure against the color you painted, never the ambient page background. **A third precedent, in-sentence styled text:** `ending-constellation-ending.tsx`'s trailing heading period keeps `colors.accent` when it clears the large-text ratio against `ctx.defaultBg ?? colors.bg` (the real rendered background), else falls back to the heading's own `colors.text`, deliberately not `accessibleInk`'s usual neutral `readableOn` fallback. The standalone-accent-text precedent (`insight-panel.tsx`/`roadmap.tsx`'s title/period text, see the arc-bbox fix below) has no sibling ink to match, so a neutral fallback there is the right call; the period instead sits mid-sentence in the same `` as the rest of the heading line, painted `colors.text` a few pixels away, so a generic near-black fallback would visibly split one sentence into two different darks on any theme whose `colors.text` isn't already near-black. Falling back to the heading's own ink keeps the sentence visually one piece, and is always safe since every theme's `colors.text` already clears 4.5:1 against its own `colors.bg`, comfortably above this 3:1 large-text floor — verified across all 24 themes in `deck-audit.test.ts`'s dedicated regression block. ## `ctx.defaultBg` `full-slide-svg.tsx` resolves one scalar default background per slide (`buildCtx`, doc comment at lines 142-161, assignment at 307-309) for layouts that paint no panel of their own: theme's per-slide-type default (`resolveBackgroundHex`) → overridden by `slide.background` when set (`resolveOverrideBackgroundHex` — a gradient reduces to its midpoint, not the `.from` stop) → for an asset background, the *painted scrim color* (`themeDefaultBg`, the exact value `autoScrimColor` paints at `AUTO_SCRIM_OPACITY = 0.66`), never `colors.surface`. This chain exists to agree with what `deck-audit.ts` actually measures — an ink decision that disagrees with the rendered pixels is exactly the defect class this file's history is full of. ## Audit measurement (`src/audit/deck-audit.ts`) **Tier-aware floor:** every run's required ratio comes from `requiredRatioFor(tier, renderedFontSize)`, the existing size-driven A-tier split (3:1 large ≥24px / 4.5:1 body) unless the run carries `data-contrast-tier="meta"` (`META_CONTRAST_TIER`, search by name), in which case it's a hard 3:1 regardless of size. The marker is read the same "own attribute wins, else inherit from the nearest ancestor" way `fill`/`font-size` already are, so a `` continuing a marked `` run inherits the tier without repeating the attribute. This is the audit-side half of the three-tier policy above. A render-side `metaInk` call with no matching `data-contrast-tier="meta"` attribute (or vice versa) is a bug, not a style choice: the two must always ship together at a call site, same as `ending-banner-ending.tsx`/`ending-rail-ending.tsx`'s copyright-line pattern. `findContrastIssues`/`runContrastWalk` (search `deck-audit.ts` by name) resolve background from rendered SVG geometry, not theme tokens, via two parallel tables built in one tree walk (bench-driven fix round, defect A — full history in `MIN_BG_REGION_AREA`'s own doc comment): - **`regions`/`BgRegion`** — page-level candidates only, `__collectBgRegions`'s contract (its own region-*count* test pins this exactly): ``/``/``, gated by both `MIN_BG_REGION_AREA` (8000px²) and `MIN_BG_OPACITY` (0.5). Untouched by the fix. - **`paintedShapes`/`PaintedShape`** — text-background *attribution*, floor-free: every opaque-enough (`MIN_BG_OPACITY`-gated) ``/``/``/`` becomes a candidate regardless of area (`` keeps `regions`' same floor — this renderer only ever paints large, page-covering photos, so an unfloored image candidate has no real case to serve). A ``/`` is graded over its estimated ink box (anchor-aware left/right, 0.75em above the baseline to 0.25em below), sampled on a grid at most half an em apart and reduced to the least favorable background found — `fix/decor-contrast-attribution`, replacing a single sample at the run's anchor point, which sits on the baseline at one *end* of the run and so missed any shape covering the glyph bodies but not that corner. Each sample resolves against whichever shape, searched most-recent-first (topmost in paint order), actually *contains* it: an exact ellipse test (`ellipseShape`, `((px-cx)/rx)²+((py-cy)/ry)²<=1`) for ``/`` — a corner-anchored text inside a circle's bbox corner but outside the disk itself correctly falls through rather than matching a cruder AABB — a bounding-box test (`rectShape`/`pathBoundingBox`) for ``/``, and for `` an exact sector test (`sectorShape`) *when* `d` is recognized as `renderDonut`/`renderPie`'s own wedge idiom (`parseWedgePath`, see the closed limitation below), else the same `rectShape`/`pathBoundingBox` fallback. The split exists because a badge/chip small enough to correctly *not* count as a page background is still unambiguously the real background of text painted directly on top of it — gating attribution by the page-level floor used to make such text fall through to whatever larger region happened to sit underneath (a card shell, the page background) instead, checked against a color it was never actually rendered on. Resolution is paint-order-safe by construction: a shape only becomes visible to `backgroundAt` once its own element has been visited, so a shape painted *after* a given text in document order is never a candidate for it. Attribution is per-tspan so a multi-tspan meta line doesn't inherit the wrong color. Text below `DECORATIVE_ALPHA` (0.4, line 166) is skipped as intentional decoration. `` subtrees never join the page-level `regions` table at all — decoration layers over the background, never stands in for it. **Decoration participates in attribution by shape, not by layer (`fix/decor-contrast-attribution`).** Decor used to be excluded from *both* tables. Half of that was right and half was a wrong answer. The half that was right: `motif-rally-motif.tsx`'s crayon strokes are large, opaque ``s whose bounding box covers far more of the page than the stroke's ink, so letting them attribute convicts text nowhere near the paint. The half that was wrong: the ink theme's motif signs off with a 32px vermilion seal (``) and `cover-tone-adaptive-header.tsx` paints its date line straight across it — real contrast 1.07:1, audit verdict 5.44:1, measured against a page background the seal completely covers. A missing check is a hole; a wrong number is the audit contradicting itself. So the gate now turns on one criterion, whether the containment test registered for a shape *is* its painted outline: `` (rounded corners included — `rectShape` cuts them back exactly), `` and `` join attribution; `` and `` (bounding boxes) and anything under a transform this walk does not model (`rotate`/`skew`/`matrix`/non-uniform `scale`, which `parseTransform` silently drops — `motif-clinic-motif.tsx`'s rotated capsule and `motif-almanac-motif.tsx`'s rotated leaf group are the live cases) stay out. The collisions this surfaced are real and stay reported: they are registered in `full-matrix-contrast.test.ts`'s `ALLOWLIST` against `.issues/2026-08-17-spatial-contract`, whose §4 traces them to one cover layout's corner slot rather than to any single motif. **Resolved (`fix/arc-bbox`):** `pathBoundingBox` is now SVG path-grammar-aware — it walks a ``'s `d` attribute command-by-command (`M`/`L`/`H`/`V`/`C`/`S`/`Q`/`T`/`A`/`Z`, absolute and relative) instead of extracting every numeric token and taking the min/max. Line commands are exact by construction; `C`/`S`/`Q`/`T` curves use exact derivative-root extrema; an `A`/`a` arc uses the standard endpoint→center parameterization (SVG 1.1 appendix F.6.4/F.6.5) plus the ellipse's own axis-extremal angles, so its `rx`/`ry`/`x-axis-rotation`/flag operands are read as grammar, never mistaken for more coordinates. A malformed `d` the grammar walk can't parse falls back to the old blind token min/max rather than throwing. `insight-panel.tsx`/`roadmap.tsx`'s shared `roundedTopBarPath` accent bar — the concrete case that used to inflate a real ~6px-tall bar to a ~1184×1182px bbox dwarfing the 1280×720 canvas — now measures tight to the bar's own extent. Fixing the misattribution also *exposed* a real, previously-masked defect: both components' `colors.accent`-filled title/period text used to resolve against the bar's own bogus phantom region (same fill color as the bar itself, so every theme scored a trivial ratio=1 "pass"); fixed via `accessibleInk` (this file's own §"Ink selection" precedent — see `deck-audit.test.ts`'s "arc-bbox reclassification ink fixes" for the red→green pin). Historical detail of the original defect stays in `.issues/notes/engineering-history.md`'s "本轮新发现 (a)" record. **Resolved (`fix/donut-annulus-attribution`):** the residual gap the arc-bbox wave left open — `rectShape`'s own containment test stayed an axis-aligned-box check, never the path's true outline, so a donut/pie wedge's *exact* bbox (already tight since `fix/arc-bbox`) could still legitimately span across the ring's own hole or a pie slice's own un-swept "bite," misattributing the donut's center total-label (or any text sitting in that gap) to a wedge's fill instead of whatever's really behind it. Closed the same way `ellipseShape` already closes the equivalent gap for a circle: `parseWedgePath` recognizes a ``'s `d` as `renderDonut`'s or `renderPie`'s own wedge idiom (`chart-svg.tsx`) — two fixed, tokenizer-matched token shapes, reusing `tokenizePathD`'s own glued-flag-safe grammar walk rather than a raw-string regex — and extracts a `Sector { cx, cy, ri, ro, startA, span }`. A pie's center is written directly into `d` (the `M` point), so no trig is needed to recover it, `ri = 0` (no hole). A donut's center is never written to `d` directly, only two arc endpoints per angle, so it's solved from the one linear relationship those endpoints satisfy (`center = (ro·inner − ri·outer) / (ro − ri)`, exact and non-degenerate since `DONUT_HOLE_RATIO < 1` always keeps `ro ≠ ri`), cross-checked against the opposite endpoint pair before being trusted. `sectorShape` then tests radius-band membership (`ri ≤ dist ≤ ro`) *and* angular range together, instead of `rectShape`'s box-only test. A reclassification sweep (13 themes × balanced/skewed-weight donut and pie fixtures, mirroring the arc-bbox wave's own unshipped probe) found 88 pre-fix misattributions, every one resolving to a `chartPalette` wedge color rather than any real page/card background, and 0 post-fix — full elimination in that probe, not the arc-grammar fix's partial halving, because the two defects were never the same mechanism (arc-parameter misreading vs. AABB-vs-real-outline approximation). A positive control (text genuinely on a wedge's own band) still resolves to the wedge both before and after — this is a precision upgrade, not a new exclusion. Recognition is narrow by design: any `d` that isn't one of `renderDonut`/`renderPie`'s own two exact token shapes — including every other `` this codebase or a caller might render — keeps the unchanged `rectShape`/`pathBoundingBox` AABB fallback. That general-path limitation is unresolved and not a goal here: "document the tool limitation, don't chase it" still applies to an arbitrary path's true outline, same precedent this file's history is already full of — only the donut/pie idiom, a closed and parseable family, is special-cased. **The chart-depth wave keeps that family closed:** the new `gauge` progress arc and the dedicated `donut` chart_type both emit the *same* annulus token shape through a shared `annulusSectorPath` helper (`chart-svg.tsx`), so `parseWedgePath` recognizes them with no new grammar. That recognition is exactly what makes a `gauge`'s centered value safe: the number is anchored inside the ring hole (distance from center below the inner radius), so `sectorShape`'s radius-band test excludes it and it attributes to the page background, never the opaque arc fill its bounding box would otherwise cover. A 16-theme `auditDeck` sweep over both radial subtypes (`full-matrix-contrast.test.ts`) reports zero low-contrast findings, the same "a misattribution would surface as a finding" proof this fix's own 13-theme reclassification sweep used. **Hardening (same branch, post-review):** token *shape* alone is falsifiable — a review pass found a hand-authored `d` unrelated to either renderer can satisfy the same 15/23-token grammar with numbers that don't actually describe a circle, silently deriving a *wrong* sector instead of correctly falling back, which could flip a real `findContrastIssues` verdict (a genuine finding disappearing) rather than just missing the precision upgrade. `parseWedgePath` now round-trips every point it reads (`x1,y1`/`x2,y2` for a pie, all four outer/inner points for a donut) against its own claimed radius from the resolved center, and checks the large-arc-flag against the span its own endpoints imply — a genuine wedge always round-trips exactly (the points *are* `cx + r·cos(θ), cy + r·sin(θ)` by construction), so this only ever rejects a near-miss, never a real wedge. Reject-only by construction: it can turn a wrongly-accepted sector back into the correct AABB fallback, never the reverse, so it cannot regress a deck that already round-tripped cleanly (the 13-theme sweep above re-run unchanged: 0 findings). One case surfaced during this hardening that no geometric check can (or should) resolve: `motif-rail-motif.tsx`'s `ARC_PATH_BL` decoration is built from the identical "center point, two points on a shared circle" idiom, because it genuinely *is* a quarter-circle sector, not a coincidental near-miss — rejecting it would also reject a real wedge that happens to land on clean numbers. Its own safety net is orthogonal to recognition: `RailMotif` only ever renders it inside `` at `opacity="0.06"`, two already-tested, independent exclusions that gate the whole attribution push regardless of what `parseWedgePath` returns — pinned directly in `deck-audit.test.ts` against `ARC_PATH_BL`'s own real geometry, not a stand-in. ## Overlap detection boundary (`findOverlapIssues`, same file) `findOverlapIssues` pairwise-compares `collectLeafBoxes`' output — one box per leaf `data-audit-box`, which only ever carries `x,y,w` (never a height, by the existing protocol's own design). Height is reconstructed per box from whatever geometry is drawn inside it — a background/icon ``'s own real extent when there is one, or, for a text-only box with no such rect, `TEXT_DESCENT_RATIO` applied to each ``'s baseline. Width starts from that declared `w` too, but (borrow-wave Task 4, inventory-first) is no longer a hard ceiling: each `` leaf also widens its box's `x`/`w` to the union of the declared span and its own estimated ink extent — `measureTextUnits`, or `measureMonoTextUnits` when `isMonoFontFamily` reliably reads the mono role off the rendered `font-family` — anchored by that element's own `text-anchor`, the same choice `svg-audit.ts`'s sibling h-overflow check already makes. Either way, this is still **container-declared-geometry precision, not glyph-ink precision** — the same "measured vs. real" distinction this file's ink/contrast sections keep surfacing, here applied to position instead of color: widening a box from an *estimate* narrows the gap to real rendered ink, it does not close it. That makes the detector structurally blind to two collision classes, both a direct consequence of comparing declared/estimated boxes instead of genuinely rendered ink, not of insufficient calibration: - A padded declared box can overlap a neighbor while the real glyphs inside stay far apart — a possible false positive. In practice this needs no chasing: `layoutContentFit` shrinks gaps or drops components rather than ever letting two placed components' boxes collide, so a real, IR-driven positive fixture isn't reachable through this renderer's normal layout path at all (this function's own doc comment records that directly). Unaffected by Task 4's width estimate — widening a box only ever grows it, never shrinks it, so this half behaves exactly as before (pinned unchanged by `deck-audit.test.ts`'s Case A synthetic test). - Text that would render wider than its declared box can overflow into a neighboring box the detector still reports as clear — a false negative. Task 4 **narrows** this, it does not close it. Two gaps remain, both recorded rather than chased: - The added width is still an *estimate*, not a real glyph-metrics measurement, so it carries the same per-exported-font calibration gap this file already tracks from the color side: `render.ts`'s deliberate `opts.wrap = false` choice lets a width-estimate miss surface as visible horizontal overflow instead of silent re-wrapping, and a font substitution PowerPoint makes at open time that the estimate didn't anticipate can still under-shoot this detector's now-wider box, the same way it could always under-shoot the renderer's own `fitSvgLine` call. That is the estimator/layout shared-blindness structural gap (borrow- wave Task 3 review's Important-1 finding) — extending `collectLeafBoxes` narrows the amount of *unwidened* text this detector misses, it does not make estimate-vs-real-glyph drift go away. - Worth separating from that kerning gap, which is permanent and weight-independent: this branch's bold-metrics fix (2026-07-24) made the renderer bold-aware first (`measureTextUnits` gained a `{ bold, fontFamily }` parameter, `isBold()`'s threshold mirroring `svg2pptx/text.ts`'s own OOXML `b="1"` decision), which for one window left both auditors still assuming Regular weight against real bold render geometry — a structural under-widening on bold text specifically, not kerning-scale rounding noise. That gap is now closed, not just narrowed: `svg-audit.ts`'s h-overflow check and `collectLeafBoxes`'s overlap widening (the twin consumer this fix restored parity on) both read the element's real `font-weight` via `isBold()` before measuring. - The estimate only ever reaches text inside a live `data-audit-box` scope. Task 4's grep inventory (task-4-report.md, borrow-wave scratchpad, not shipped in this repo) found the codebase's largest concentration of unprotected, user-content-level `` sits *outside* that scope entirely, by the same construction that already excludes decoration/motif layers from this walk: `branding.tsx`'s footer (org/date/version), 18 cover/chapter/ending layouts' own org label, `image-pages.tsx`'s raw org/date lines, and three layouts' raw `slide.footnote` all render as page-level frame — siblings of, never nested inside, any `data-audit-box`. None of that surface is touched by this fix. Being inside a tracked box is also arrangement-dependent, not a component-type property: `big_number`/`assertion_evidence` render their supporting components through a bare `renderComponent(...)` with no `data-audit-box` wrapper at all, so the very same component this fix covers under a boxed arrangement goes uncovered there (only the page-overflow check still sees it). Two confirmed, shipping instances the inventory found *inside* a tracked box: `matrix.tsx`'s `x_title` (rendered with zero width fit before this task) and `chart.tsx`'s `axes.x_title`/`axes.y_title` (rendered inside `svg-content.tsx`'s own `data-audit-box` wrapper, same as any other normally-placed component) — the concrete cases this fix protects today for text that actually renders inside a live tracked box. Recorded here as a known, narrowed-but-not-closed boundary, not a settled one — same discipline as this section's donut/pie AABB gap above. ## Full-matrix regression net (`full-matrix-contrast.test.ts`) Sweeps every theme × slide type × curated layout for the W4 defect class. Two guardrails worth knowing about: - **`ALLOWLIST`** (`full-matrix-contrast.test.ts`, search by name): named, adjudicated exceptions only — never silent. Each entry documents *why* — e.g. `fashion-chapter`'s decorative chapter-number watermark (deliberately faint by design; carries a `ratioMin`/`ratioMax` band plus a `TEXT_SHAPE_GUARD` regex so a future regression on the same digit still fails the net instead of silently matching the old exception). Since the contrast-policy wave (task T3, tier-annotation ruling), each entry also carries an explicit tier annotation — `fashion-chapter`'s watermark is C tier (pure decoration, exempt from any ratio floor by policy, on the condition that it's recorded here, which it is). The list used to also carry a `terminal`/`fashion-masthead` entry for that cover layout's org/date meta line (B tier, ~4.16:1 against terminal's primary block — comfortably clears the 3:1 B floor, only ever fell short of the 4.5:1 body line this entry pre-dates the tier split for). That entry is gone (fashion-masthead metaInk migration, `.issues/2026-08-04-fashion-masthead-metaink/task-1-report.md`): the line now derives its ink from `metaInk` (measured against `colors.primary`, the background it actually paints) and carries `data-contrast-tier="meta"`, so `deck-audit` grades it against the real 3:1 floor directly, no allowlist needed. The migration also surfaced a genuine pre-existing gap the old entry's `terminal`-only scope never covered — `ledger`'s same composite measured 2.886:1, a real sub-3:1 miss `metaInk` now corrects to ~3.094:1 — closed by the same change, not a separate fix. `chapter-fashion-chapter.tsx`'s own org/institution label (a different file, different background token `colors.accent`, different dim ratio) is a related but *separate* code path, still unmigrated — its worst measured case (`runway`, ~4.056:1) already clears 3:1, so it stays deferred, not a current defect. - **`MUTED_SURFACE_CLASS`** + its completeness guard (same file, search by name — line numbers drift with every test insertion, so this doc cites symbols only): every `COMPONENT_TYPES` entry needs a human-reviewed classification of where its `colors.muted` text renders (`no-muted-fill`/`page-bg`/`flat-surface`/`needs-fixture`/`known-gap`) — `Object.hasOwn` against `COMPONENT_TYPES` fails the test the moment a new component ships unclassified. Exists because a first calibration pass probed only two surfaces (page background, bento-panel card) and missed `content-matrix`'s tone-blended cell background entirely — this guard closes the *class* of blind spot, not just that instance. ## Muted calibration discipline `colors.muted` recalibration preserves hue and saturation while changing only minimum lightness. See any `src/themes/builtin/.ts` inline comment on its `muted` token for the pattern: adjust luminance just far enough to clear 4.5:1 against every real background it renders on, and change nothing else about the color. ## The optional pixel layer (`--pixels`, audit-v2 phase B) `findContrastIssues`'s `PaintedShape` walk (above) resolves a text's background from rendered SVG geometry — but a bare or too-faintly-scrimmed `` gives it nothing to resolve: the walk correctly returns `null` rather than guess, and the text is skipped. `image-pages.tsx`'s `ImageCoverPage` (the cover/chapter takeover for an asset background) is the one real layout this happens on — its `DarkScrim` is three stacked `fill-opacity` bands (0.3/0.28/0.3), each individually below `MIN_BG_OPACITY` (0.5), so none of them ever become a `PaintedShape` and every heading/caption on that page resolves to "unknown, skip". `auditDeck(ir, { pixels: true })` (`src/audit/pixel-audit.ts`) closes that one gap, and only that one. It does not re-check anything the SVG walk already resolved. Flow (spec §4.3): 1. `__collectImageBackedTextRuns` (`deck-audit.ts`) — the same background-resolution walk above, just reading its `background === null` runs instead of discarding them. A page with none skips every step below entirely. 2. `stripTextNodes` (`pixel-audit.ts`) — a plain string removal of every `` element, not a DOM round-trip (SVG has no reflow, so removing an element can't move anything else, and a round-trip risks a serializer producing markup that doesn't byte-match a real browser's). 3. `rasterizeSvg(stripped, 1280, 720)` — the platform seam's own primitive (`src/platform/registry.ts`): Sharp in Node (`installNodePlatform()`, spec §11.9's pre-authorized default — see the escape clause below), native `Image`/`OffscreenCanvas`/`` in a browser (`src/platform/browser.ts`, spec §11.8, zero new dependency). Missing capability is an explicit `auditDeck` rejection, never a silent clean report — the same "未检查≠通过" contract extended to a platform's own capability (spec §11.7). 4. A dense, deterministic 5px-stride grid is sampled across each run's estimated box (font-metric left/right/baseline, the same estimate `svg-audit.ts`'s overflow walker uses — not a real glyph bbox), each sample point itself a small 3×3-pixel window average rather than one raw pixel, tracking the least-favorable (lowest) contrast ratio found overall — spec's own "worst-case band". Replaced a fixed 5×3-point grid (deep-acceptance review, post-v0.4): that grid's own "15 points is enough" justification was falsified by a hand-verified repro — a real sub-1.5:1 patch 35px from the nearest sample column (columns were 70px apart at `ImageCoverPage`'s real org-line scale) produced zero findings, yet the identical patch was caught the moment it happened to land on a column. The 5px stride (half of a 10px "glyph-scale" minimum patch size) guarantees any contiguous bad patch that size or larger is always fully covered by at least one sample window regardless of alignment — see `pixel-audit.ts`'s `worstCaseSample` for the covering proof — while the 3×3 window average keeps a lone noisy pixel (rasterizer antialiasing, photo grain) from flipping a genuinely-safe patch into a false finding. Cost stays negligible: ~2-5ms per audited page measured against a worst-case-ish two-run scenario (a full-width heading-scale run plus the org line, both image-backed simultaneously — headings don't actually reach this blind spot today, only the short org/date line does), well under the Sharp rasterization call it rides alongside. 5. Only a ratio below **1.5:1** becomes a finding (`code: "low-contrast"`, `detail.source: "pixels"` distinguishes it from an SVG-resolved one) — deliberately far below either real WCAG floor (3:1 large text, 4.5:1 body) to control false positives, since pixel sampling carries antialiasing/rasterizer noise the SVG-only walk never has to deal with. A real, non-extreme `ImageCoverPage` case (org-line text, single 0.3-opacity scrim layer) tops out around ~1.8-1.9 even against a pure-white photo — clearing 1.5 by design, not a gap: v1 favors under-flagging a borderline pairing over false-positiving on one that would read fine. Remote (`http(s):`) image references never reach a rasterizer at all — `findRemoteAssetRef` (`registry.ts`) scans the markup and rejects before either platform implementation runs, shared by both: spec §3.1/§7 promise the default audit chain never makes a network request, and a browser `` load of a remote asset in this restricted context silently drops rather than reliably tainting the canvas, which would otherwise sample a blank region as if it were the real background — exactly the "checked nothing, reported clean" failure this whole wave rules out. **Sharp escape-clause verdict (spec §11.9):** the pre-authorized trigger to swap Sharp for `@resvg/resvg-js` is "a real render out of this repo's own SVG subset comes back visibly wrong" — `src/platform/node-rasterize.test.ts`'s probe suite renders gradient bands, a rounded arc path built with `insight-panel.tsx`/`roadmap.tsx`'s own `roundedTopBarPath` grammar, an embedded PNG bitmap, and `DarkScrim`-shaped stacked transparency, each against independently-computed expected colors. Sharp passed every case cleanly (solid fills and gradient bands are pixel-exact, three-layer transparency compositing lands within 1/255 of hand-computed sequential blending). The clause does not trigger. Sharp stays. **Determinism footnote (spec §11.10):** the pixel layer does *not* extend the main audit's cross-run byte-stability promise (same IR → byte-identical JSON) to a cross-*platform* one — Sharp/librsvg and a browser's own canvas implementation antialias differently, so the same deck can sample a different worst-case pixel (and therefore a different ratio) on Node versus in a browser. Same platform, same input still produces a stable result. The 1.5:1 gate sits far enough below both real WCAG floors that this noise is not expected to flip a verdict in practice.