Skip to content

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 via discoverHues() (never hand-listed), tag-aware scanning across line breaks, an exemption proven against AppIcon.vue's own isDecorative contract rather than a hand-rolled heuristic, and contrast assertions reusing the OBJ-2668 measurement harness. COACH_GOLD_ALLOWLIST retired from tintInkGuard.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 explicit text-white ruling 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, since components/ui is 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's PERSONA_CLASSES[...] ?? 'text-coach' fallback swept as an indirect class-string source; the PERSONA_CLASSES map values themselves left untouched — no -accessible sibling exists for persona-*.
  • Tail & close (Phase 6, OBJ-2686): 71 story-file sites across 22 files, 14 admin_dashboard/src sites across 7 files. The story-file scanner gained an h(AppIcon, { … }) recognizer for decorative icons rendered via Vue's h() call rather than a template, finding 5 such sites. The completion gate flipped from a per-phase allowlist to expect(ALLOWLIST).toEqual([]) plus a live-tree zero-unlisted-violations assertion. docs/development/accessibility.md's AA-Violation Ledger, CHANGELOG.md's [Unreleased] section, and a DESIGN.md § v4.36 rule 1 correction (removing the stale "0/20 coach/gold coverage" claim and its COACH_GOLD_ALLOWLIST reference) all landed in the same commit.

Phases

PhaseNameStatusPRHighlights
1Plain-surface ink guardShipped#2434plainSurfaceInkGuard.spec.ts built; COACH_GOLD_ALLOWLIST retired; census corrected 425 → 362
2Sweep A — Dashboard & goalShipped#245659 sites; Desi's visual-verification protocol; two scoped-CSS landmines
3Sweep B — SocialShipped#246769 sites; one named <AppIcon> swept as a violation
4Sweep C — Shared UI & chromeShipped#248768 sites; highest-risk batch (components/ui primitives)
5Sweep D — Views & playShipped#250381 sites; MeetCoach.vue's indirect class-string fallback swept
6Tail & closeShipped#251385 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.ts was 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 = !accessibleLabel is AppIcon.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 -accessible swap.
  • 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.

CategoryCountStatus
GUARD-1..66All satisfied
SWEEP-A/B/C/D4All satisfied
TAIL-1..33All satisfied
DOCS-1..22All 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, including coachPersonas.ts's PERSONA_CLASSES map) has no declared -accessible sibling — 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 by v-if/v-show on 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's bg-coach text-coach-foreground (3.68:1) — a solid-fill/-foreground mismatch 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.
  • 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

Loading…