The ink-contrast SCANNER — an AST guard that can tell a faint GLYPH from faint TEXT, proven on fixtures before it is pointed at the repo
⚠️ RE-SCOPED 2026-08-08, mid-
motir run. This card was authored as "build a repo-wide guard AND fix every violation", sized 5 points / 90 minutes, against a defect count its own description said nobody had. The count was measured before any code was written: 262 defects across 134 files, plus the discovery that the DOM-reading guard the card prescribed cannot be built repo-wide. So the card keeps the mechanism — which is the only part that was ever a 90-minute job — and the two sweeps became MOTIR-2475 and MOTIR-2477. Filed as a planning bug: MOTIR-2481. The coverage gap it surfaced is MOTIR-2482.
Filed by MOTIR-2455, which settled what --el-text-faint means and fixed the one surface it had measured. This card builds the thing that can find the rest.
Repo: motir-core. One PR.
The rule, already settled and already written down
Measured with axe on a real route, light theme (the binding one — every ink clears AA on every dark surface):
| ink | page / card #ffffff | --el-surface | --el-muted | --el-surface-soft |
|---|---|---|---|---|
--el-text-faint | 2.61 ✗ | 2.39 ✗ | 2.37 ✗ | 2.50 ✗ |
--el-text-muted | 4.54 ✓ | 4.17 ✗ | 4.12 ✗ | 4.34 ✗ |
--el-text-secondary | 6.80 ✓ | 6.24 ✓ | 6.18 ✓ | 6.51 ✓ |
--el-text-faint is legitimate for exactly two things: decorative glyphs (aria-hidden, or a labelled role="img") and disabled / inactive text, which WCAG 1.4.3 exempts. --el-text-muted is safe only on the white page/card.
The rule is in motir-core/CLAUDE.md and in the token's own comment. Writing it down is what MOTIR-2455 already did; this card builds what enforces it.
Why an AST scanner, and why the original card's mechanism could not be built
The card originally said to model the guard on tests/settings/rolesPermissionsScreens.test.tsx, "that one reads rendered DOM, which is what makes the aria/disabled distinction visible; a source grep cannot make it." The first half is right and the second half is right, and together they do not yield a repo-wide guard: a DOM guard can only see components something RENDERS, so making it repo-wide means rendering every component in the product, which is not a guard, it is a second test suite.
The resolution is that "a grep cannot see it" is not the same as "only a rendered DOM can". A parser sees the element the class lands on — its aria-hidden, its role, its disabled, the ternary its class sits in, and the ancestors that paint a background — which is precisely the structure the distinction turns on. It cannot see across a file boundary; that limit is real, is stated in the scanner's own header, and is what MOTIR-2477 inherits.
Deliverables
tests/theme/inkContrastScan.ts—scanSource(fileName, text) → InkFinding[]. For eachtext-(--el-text-faint)site:decorative(the element isaria-hidden, a labelledrole="img", or a labelled control whose content is glyphs only),disabled(adisabled/aria-disabledelement, or the disabled branch of a ternary),unattributable(a class constant attached to no element), elseviolation. For eachtext-(--el-text-muted)site: filter out glyphs and disabled controls first — 1.4.3 measures neither — then walk up to the nearest background-painting ancestor within the file and flag the three tinted surfaces.tests/theme/inkContrastScan.test.ts— the scanner's own fixtures, including its negative case.
Acceptance criteria
- The scanner exports
scanSource, and aviolations()helper that treatsunattributableas failing — "I cannot see what this is" is a violation, not a pass (the stancetests/work-items/activity-registry-totality.test.tsalready takes on unanalyzable keys). - The negative case is a real fixture, not a comment: a synthetic source carrying faint ink on a
<p>of text makes the scanner report a violation. A guard whose failure path is never exercised is a guard nobody knows is running. - Fixtures also pin each PASS: an
aria-hiddenglyph, a labelledrole="img", a labelled glyph-only control, adisabledelement, adisabled ? faint : inkternary — each clears; and an unlabelledrole="img"does NOT clear, because nothing else states its meaning. - Muted fixtures pin both directions: muted text under
bg-(--el-surface)is a violation; the same text underbg-(--el-card)is not; muted on anaria-hiddenglyph over a tinted surface is not. - The scanner's header states, in prose, the limit it cannot pass: surface resolution stops at the file boundary, so a background painted by another module is invisible to it.
- This card ships no repo-wide assertion and changes no product code — pointing the scanner at the tree is MOTIR-2475 (faint) and MOTIR-2477 (muted), each landing with the sweep that makes its arm green.
- The PR body records the measured inventory over
origin/main— the four verdict counts per ink, and the count of violation files that sit on routestests/e2e/shell-a11y*.spec.tsnever loads — so the two sweeps start from a number rather than re-deriving one.
Context refs
tests/settings/rolesPermissionsScreens.test.tsx— the component-local guard this generalises, and the reason it cannot be generalised by rendering.tests/theme/swapLayerLint.test.ts— the repo-wide token-lint mould the sweeps will follow.tests/work-items/activity-registry-totality.test.ts— the existing TypeScript-AST structural guard in this repo.packages/design-system/theme.css— the two tokens and their comments.motir-core/CLAUDE.md§ The token map — the measured table and the rule.- MOTIR-2455 — the incident and the decision.