Contrast system
August 31, 2026 · View on GitHub
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", alsodata-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, insight'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: campaign 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 — campaign'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 <text> 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 <tspan> continuing a marked <text> 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):<rect>/<image>/<path>, gated by bothMIN_BG_REGION_AREA(8000px²) andMIN_BG_OPACITY(0.5). Untouched by the fix.paintedShapes/PaintedShape— text-background attribution, floor-free: every opaque-enough (MIN_BG_OPACITY-gated)<rect>/<circle>/<ellipse>/<path>becomes a candidate regardless of area (<image>keepsregions' same floor — this renderer only ever paints large, page-covering photos, so an unfloored image candidate has no real case to serve). A<text>/<tspan>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<circle>/<ellipse>— 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<rect>/<image>, and for<path>an exact sector test (sectorShape) whendis recognized asrenderDonut/renderPie's own wedge idiom (parseWedgePath, see the closed limitation below), else the samerectShape/pathBoundingBoxfallback.
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. <g data-decor> 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-campaign-motif.tsx's crayon strokes are large, opaque <path>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 (<rect fill="#C3272B">) 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: <rect> (rounded corners included — rectShape cuts them back exactly), <circle> and <ellipse> join attribution; <path> and <polygon> (bounding boxes) and anything under a transform this walk does not model (rotate/skew/matrix/non-uniform scale, which parseTransform silently drops — motif-pulse-motif.tsx's rotated capsule and motif-terra-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 <path>'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 <path>'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 <path> 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 <g data-decor> 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 <rect>'s own real
extent when there is one, or, for a text-only box with no such rect,
TEXT_DESCENT_RATIO applied to each <text>'s baseline. Width starts from
that declared w too, but (borrow-wave Task 4, inventory-first) is no
longer a hard ceiling: each <text> 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:
layoutContentFitshrinks 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 bydeck-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 deliberateopts.wrap = falsechoice 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 ownfitSvgLinecall. That is the estimator/layout shared-blindness structural gap (borrow- wave Task 3 review's Important-1 finding) — extendingcollectLeafBoxesnarrows 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 (
measureTextUnitsgained a{ bold, fontFamily }parameter,isBold()'s threshold mirroringsvg2pptx/text.ts's own OOXMLb="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 andcollectLeafBoxes's overlap widening (the twin consumer this fix restored parity on) both read the element's realfont-weightviaisBold()before measuring. - The estimate only ever reaches text inside a live
data-audit-boxscope. 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<text>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' rawslide.footnoteall render as page-level frame — siblings of, never nested inside, anydata-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_evidencerender their supporting components through a barerenderComponent(...)with nodata-audit-boxwrapper 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'sx_title(rendered with zero width fit before this task) andchart.tsx'saxes.x_title/axes.y_title(rendered insidesvg-content.tsx's owndata-audit-boxwrapper, same as any other normally-placed component) — the concrete cases this fix protects today for text that actually renders inside a live tracked box.
- 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:
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 aratioMin/ratioMaxband plus aTEXT_SHAPE_GUARDregex 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 atech/fashion-mastheadentry for that cover layout's org/date meta line (B tier, ~4.16:1 against tech'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 frommetaInk(measured againstcolors.primary, the background it actually paints) and carriesdata-contrast-tier="meta", sodeck-auditgrades it against the real 3:1 floor directly, no allowlist needed. The migration also surfaced a genuine pre-existing gap the old entry'stech-only scope never covered —insight's same composite measured 2.886:1, a real sub-3:1 missmetaInknow 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 tokencolors.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): everyCOMPONENT_TYPESentry needs a human-reviewed classification of where itscolors.mutedtext renders (no-muted-fill/page-bg/flat-surface/needs-fixture/known-gap) —Object.hasOwnagainstCOMPONENT_TYPESfails 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 missedcontent-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/<id>.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 <image> 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):
__collectImageBackedTextRuns(deck-audit.ts) — the same background-resolution walk above, just reading itsbackground === nullruns instead of discarding them. A page with none skips every step below entirely.stripTextNodes(pixel-audit.ts) — a plain string removal of every<text>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).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), nativeImage/OffscreenCanvas/<canvas>in a browser (src/platform/browser.ts, spec §11.8, zero new dependency). Missing capability is an explicitauditDeckrejection, never a silent clean report — the same "未检查≠通过" contract extended to a platform's own capability (spec §11.7).- 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 atImageCoverPage'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 — seepixel-audit.ts'sworstCaseSamplefor 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. - 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-extremeImageCoverPagecase (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 <img> 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.