v4.40 — Plain-Surface Ink Sweep
Every declared hue in the app failed WCAG AA as plain ink on a plain surface — no tint required, worst case 1.62:1. This milestone built the guard nobody had written, then swept all 362 sites it found to zero.
Summary
v4.36's Tint-Ink Contrast Floor fixed the case where a raw text-<hue> sits on a bg-<hue>/N tint of the same hue. DESIGN.md § v4.37 Plain-Surface Ink Floor (OBJ-2668, Desi) found the scope was drawn one notch too narrow: the tint was never the cause. Measured untinted — raw text-<hue> directly on --card/--background, nothing else nearby — every one of the 7 originally declared hues fails AA, in 7 to 20 of its 20 theme×mode contexts. The rule was written down. Nothing enforced it.
v4.40 built that enforcement. Phase 1 shipped plainSurfaceInkGuard.spec.ts, a tag-aware sibling of the existing tint guard rather than an extension of it — the tint guard's same-line matcher structurally cannot follow an <AppIcon> tag across a line break, and 18 of the tree's 131 hue-bearing tags do exactly that. Running the guard against the real tree corrected the kickoff hand-count of 425 sites to a measured 362, and found only 14 of 39 discovered hue tokens have a declared -accessible sibling — persona-*, cat-*, and category-* ink (~22 sites) turned out to be outside this milestone's reach entirely, not deferred within it. Phases 2 through 5 swept the four directory-scoped batches the guard's own census generated — dashboard & goal, social, shared UI & chrome, views & play — with Phase 2 additionally settling the milestone's one real design question (does a wall of -accessible ink read visibly muted? light-mode only, and acceptable) before the mechanical batches ran. Phase 6 swept the deliberately-last tail — Storybook stories and admin_dashboard/src — and flipped the guard's allowlist to [] as a directly-asserted completion gate, not an empty diff taken on faith.
Goal
Make § v4.37's Plain-Surface Ink Floor enforced rather than written down, then discharge the debt it exposed — without darkening a single decorative glyph. Two things had to be true at close: no raw text-<hue> paints a glyph a user is meant to read anywhere in ionic_frontend/src or admin_dashboard/src, and a guard fails loud the next time someone adds one.
Scope — What Shipped
- Guard (Phase 1, OBJ-2681):
ionic_frontend/tests/unit/plainSurfaceInkGuard.spec.ts— hues discovered viadiscoverHues()(never hand-listed), tag-aware scanning across line breaks, an exemption proven againstAppIcon.vue's ownisDecorativecontract rather than a hand-rolled heuristic, and contrast assertions reusing the OBJ-2668 measurement harness.COACH_GOLD_ALLOWLISTretired fromtintInkGuard.spec.ts— its expiry condition (both tokens at 20/20 declaration coverage) had already been met. - Sweep A — Dashboard & goal (Phase 2, OBJ-2682): 59 sites across
components/dashboard,components/goal,components/achievements— the densest surface in the app, swept first so Desi's before/after visual check ran against the worst case. Produced the visual-verification protocol Phases 3–5 followed unchanged.AchievementStats.vue's compound class-string-plus-scoped-CSS site handled as one unit;LegendaryCelebration.vue:23's permanent dark scrim got an explicittext-whiteruling rather than a contrast-regressing swap. - Sweep B — Social (Phase 3, OBJ-2683): 69 sites across
communities,teams,community,allies,views/teams,feed. One named<AppIcon>site swept as a violation, per the guard's contract-based exemption test. - Sweep C — Shared UI & chrome (Phase 4, OBJ-2684): 68 sites across
components/ui, top-level app primitives,coach,supporter, and five smaller directories — the highest-risk batch, sincecomponents/uiis consumed everywhere. Two scoped-CSS "landmine" sites had their Tailwind class and<style scoped>rule moved together, not just the class. - Sweep D — Views & play (Phase 5, OBJ-2685): 81 sites across
views/*,play/*,views/settings.MeetCoach.vue'sPERSONA_CLASSES[...] ?? 'text-coach'fallback swept as an indirect class-string source; thePERSONA_CLASSESmap values themselves left untouched — no-accessiblesibling exists forpersona-*. - Tail & close (Phase 6, OBJ-2686): 71 story-file sites across 22 files, 14
admin_dashboard/srcsites across 7 files. The story-file scanner gained anh(AppIcon, { … })recognizer for decorative icons rendered via Vue'sh()call rather than a template, finding 5 such sites. The completion gate flipped from a per-phase allowlist toexpect(ALLOWLIST).toEqual([])plus a live-tree zero-unlisted-violations assertion.docs/development/accessibility.md's AA-Violation Ledger,CHANGELOG.md's[Unreleased]section, and aDESIGN.md§ v4.36 rule 1 correction (removing the stale "0/20 coach/gold coverage" claim and itsCOACH_GOLD_ALLOWLISTreference) all landed in the same commit.
Phases
| Phase | Name | Status | PR | Highlights |
|---|---|---|---|---|
| 1 | Plain-surface ink guard | Shipped | #2434 | plainSurfaceInkGuard.spec.ts built; COACH_GOLD_ALLOWLIST retired; census corrected 425 → 362 |
| 2 | Sweep A — Dashboard & goal | Shipped | #2456 | 59 sites; Desi's visual-verification protocol; two scoped-CSS landmines |
| 3 | Sweep B — Social | Shipped | #2467 | 69 sites; one named <AppIcon> swept as a violation |
| 4 | Sweep C — Shared UI & chrome | Shipped | #2487 | 68 sites; highest-risk batch (components/ui primitives) |
| 5 | Sweep D — Views & play | Shipped | #2503 | 81 sites; MeetCoach.vue's indirect class-string fallback swept |
| 6 | Tail & close | Shipped | #2513 | 85 sites (stories + admin); allowlist flipped to []; docs landed |
Key Decisions
- The guard's census overrides the ROADMAP's hand count, always. The kickoff hand-count (314 production + 78 story + 33 admin = 425) was superseded the moment Phase 1's guard actually ran against the tree (362 measured). Every later phase took its work order from
plainSurfaceInkGuard.spec.ts's ALLOWLIST, never from the ROADMAP's baseline-dated prose — recorded in the ROADMAP itself as "THE GUARD WINS." - A tag-aware scanner, not an extension of the existing line-aware one.
tintInkGuard.spec.ts's same-line co-occurrence matcher cannot follow an<AppIcon>tag across a line break — 18 of 131 hue-bearing tags do.plainSurfaceInkGuard.spec.tswas built as a sibling from scratch rather than bent to cover a case its architecture can't express. - The decorative-icon exemption is proven against
AppIcon.vue's own contract, not asserted independently.isDecorative = !accessibleLabelisAppIcon.vue's real logic; the guard checks against that contract so a future change to it fails the guard instead of silently drifting apart from the component it's supposed to protect. - Muting is a composition problem, solved by less colored ink — never by reaching back for the raw token. § v4.37 rule 2's prescribed remedy, exercised concretely: 5 of Phase 2's 59 sites took the "less ink" fix instead of the mechanical
-accessibleswap. - Story files and admin swept last, deliberately. Storybook renders no production pixel, so it carries no visual-regression risk, but raw ink left there would keep re-seeding the exact pattern this milestone removes via copy-paste from the design-system reference surface — in scope, just sequenced after the surfaces that do carry regression risk.
- The completion gate is a direct assertion, not an inferred one. Phase 6 replaced the shrinking per-phase allowlist with
expect(ALLOWLIST).toEqual([])plus a live-tree scan for unlisted violations — the milestone cannot report done while any site remains, rather than "done" being read off an empty diff.
Requirements Coverage
No standalone REQUIREMENTS.md exists for this milestone — coverage is derived from the ROADMAP's own "Definition of done" checklist (7 items), all independently re-verified in the milestone audit. Full item-by-item detail: MILESTONE-AUDIT.md.
| Category | Count | Status |
|---|---|---|
| GUARD-1..6 | 6 | All satisfied |
| SWEEP-A/B/C/D | 4 | All satisfied |
| TAIL-1..3 | 3 | All satisfied |
| DOCS-1..2 | 2 | All satisfied |
Outcomes
No raw text-<hue> paints a readable glyph anywhere in ionic_frontend/src or admin_dashboard/src — 362 measured violations swept to zero, independently re-confirmed at milestone close by actually running the guard's test suite (72/72 passing) rather than trusting a reported CI status. A guard now fails loud the moment a new one is introduced, scanning both apps, tag-aware across line breaks, exempting only what AppIcon.vue's own decorative contract says is exempt. COACH_GOLD_ALLOWLIST — the last deferral this repo's ink-contrast work had been carrying — is gone. No decorative glyph was darkened: the 138 original exempt template sites are unchanged, and Phase 6's h() recognizer only added exemptions it could prove, never removed one.
Tech Debt
- (Phase 1, out of scope)
persona-*/cat-*/category-*ink (~22 sites, includingcoachPersonas.ts'sPERSONA_CLASSESmap) has no declared-accessiblesibling — outside § v4.37's normative reach until those tokens are declared. Recommended as a design-system ticket, not a sweep, in the ROADMAP. - (Phase 1, out of scope, by design) 10 review-bucket sites — an unnamed
<AppIcon>gated directly byv-if/v-showon itself, which may be the sole carrier of a state a sighted user must read. Left untouched; a naming fix with a different owner, not a contrast fix. - (carried onto the accessibility ledger, not fixed here)
GoalCreate.vue:208,586'sbg-coach text-coach-foreground(3.68:1) — a solid-fill/-foregroundmismatch this guard doesn't scan for. - (carried onto the accessibility ledger, not fixed here) Two admin hover-ink sites (
EmployeeDetailView.vue:815,UnauthorizedView.vue:25) — base ink swapped, hover state deliberately deferred per the standing hover-ink bucket. - (carried onto the accessibility ledger, not fixed here) Two pre-existing Storybook wiring gaps (
Views/PublicProfile,Views/PublicGoal) blocking visual verification for those stories. - (follow-up, non-blocking)
docs/development/accessibility.md's OBJ-2679 ledger entry still cites "PR #2503 / pending" for Phase 6 — stale, since Phase 6 shipped as PR #2513 in the same commit that wrote those lines. Filed for Maggie to route.
Related Artifacts
- Roadmap: v4.40-plain-surface-ink-sweep-ROADMAP.md
- Milestone audit: v4.40-plain-surface-ink-sweep-MILESTONE-AUDIT.md
- Guard:
ionic_frontend/tests/unit/plainSurfaceInkGuard.spec.ts - Exemption mechanics doc: docs/development/design-tokens.md
- Accessibility ledger: docs/development/accessibility.md § App-Side AA-Violation Ledger
- Design rule: DESIGN.md § Accessibility hard rules — v4.37 Plain-Surface Ink Floor
- Related PR (upstream rule): #2430 — DESIGN.md § v4.37, OBJ-2668 (Desi)
Related Commits
47b3f6bed—[Codi] fix(a11y,tokens): build plainSurfaceInkGuard.spec.ts, retire COACH_GOLD_ALLOWLIST (OBJ-2681) (#2434)76a7bb341—[Codi] fix(design-system): swap Phase-2 plain-surface ink to accessible siblings (OBJ-2682) (#2456)dbd94c2b7— Sweep B — Social surfaces (Phase 3, OBJ-2683) (#2467)012321cfc— Sweep C — Shared UI & chrome (Phase 4, OBJ-2684) (#2487)6a51aa1ed—[Codi] fix(design-system): swap Phase-5 plain-surface ink to accessible siblings (OBJ-2679) (#2503)c18cb0c91—[Codi] fix(design-system): sweep Phase 6 tail ink + flip plain-surface allowlist to [] (OBJ-2679) (#2513)
Last updated: 2026-08-19