Skip to content

v4.66 — Bound-Class Guard Reach

Both contrast guards read the element tree through one function each, and both read only a static class="..." attribute — a :class binding was invisible to every tier. This milestone taught them to see it.

Summary

v4.36's Tint-Ink Contrast Floor and v4.37's Plain-Surface Ink Floor are enforced by tintInkGuard.spec.ts and plainSurfaceInkGuard.spec.ts, both walking the element tree through staticClassOf() — four lines that find the class attribute and return its literal value. A :class/v-bind:class binding parses as a Vue compiler directive, not an attribute, so it was never filtered out — it was never looked at. Both guard modules had named this gap in their own header comments since v4.36 Phase 5, and it had already leaked past a green build twice: TeamMemberRow.vue:35 (:class="member.isInactive ? 'text-warning' : ''", 2.04:1 worst case) shipped and was caught by a human reading a template, and MeetCoach.vue:87's PERSONA_CLASSES fallback needed a hand swap the guard couldn't see either.

Orion's census at kickoff found 776 live :class/v-bind:class bindings across ionic_frontend/src and admin_dashboard/src (1,033 files, 136 multi-line). Of those, only 17 raw-ink and 69 tint-bearing bindings carried an actual known defect — the remaining 114 bare-identifier/member-access bindings turned out to hide no known defect population at all, which is what let this milestone close complete on the literal tier alone. Phase 1 built the AST census harness and ruled the branch semantics (mutually exclusive branches of one binding never pair across each other); Phase 2 shipped the shared boundClassResolver.ts both guards now read through; Phases 4 and 5 swept the raw-ink and tint/ink-pair survivors the widened scan surfaced; Phase 6 re-asserted the completion gate at full strength, added a sanity assertion that the resolver still sees real bindings on the live tree, and landed the rule amendment in DESIGN.md.

Six phases were planned; five executed. Phase 3 (the identifier tier — resolving a bare identifier or member access against the same file's <script> block) was deferred at Phase 1's own dispatch boundary, on evidence rather than schedule pressure: CENSUS-3 measured the bare-identifier/member-access population at 114, not the kickoff estimate of 163, with only 23 trivially resolvable and zero of the 17 raw-ink or 69 tint candidates depending on it. The milestone's stated goal — a :class-bound contrast defect fails the build the same way a static one does — was already fully satisfiable without Phase 3, so it was fenced out to OBJ-4030 rather than run for its own sake.

Goal

One thing has to be true at milestone close:

A tint/ink or plain-surface-ink contrast defect written as a :class binding fails the build the same way the identical defect written as a static class string does — and where a binding still cannot be resolved, the guard says so out loud instead of passing silently.

TeamMemberRow.vue:35 is one instance. The milestone is the reach.

Scope — What Shipped

  • Census + branch-semantics ruling (Phase 1, OBJ-4014, PR #3367): an AST-based census harness (not a regex sweep) reading the real tree the same way the guards do — 776 bindings, 136 multi-line, ten distinct expression shapes classified with no residual "other" bucket. Ruled @babel/parser (via @vue/compiler-sfc's babelParse export) as the expression parser, and ruled branch semantics: within one binding, branches are mutually exclusive and evaluated separately; across two elements, branch sets pair as an independent cross-product.
  • Shared literal-tier resolver (Phase 2, OBJ-4015, PR #3377): ionic_frontend/tests/unit/helpers/boundClassResolver.ts — one module both guard helpers consume, replacing each one's own staticClassOf(). Resolves string literal, ternary (nested), object literal, array literal, a template literal with no interpolation, and logical &&/??/|| to string[] branch alternatives; explicitly buckets bare identifier, member/index access, call expression, and string concatenation as unresolved rather than letting them fall through a default case. v4.40's expect(ALLOWLIST).toEqual([]) completion gate was re-expressed (not weakened) into a static-class half plus a temporary bound-class allowlist, with every newly-visible site named to its owning phase.
  • Raw-ink sweep (Phase 4, OBJ-4017, PR #3391): the 17 raw-ink :class-bound candidates (10 ionic_frontend, 7 admin_dashboard — both apps independently, the same finding v4.36/v4.40 recorded for the static case) classified and swept to their -accessible sibling; PHASE_4_BOUND_ALLOWLIST shrank to [] and was deleted.
  • Tint/ink pair sweep (Phase 5, OBJ-4018, PR #3395): the 69 tint-bearing bindings' pairings evaluated under Phase 1's branch-semantics ruling; PHASE_5_BOUND_ALLOWLIST shrank to [] and was deleted. Taught opaqueFillHueIn() to recognize a token-backed bg-gradient-<hue> utility as an opaque fill (derived from the token's own resolved value, never the class name) and made the ceiling check element-scoped for a bound class, not just line-scoped.
  • Completion gate, rule, and docs (Phase 6, OBJ-4019, PR #3399, this issue): both guards' completion gates re-asserted [] over the widened denominator with the widening itself now asserted (GUARD-10 — a non-zero resolved-binding count on the live tree, proven to fail red under a stubbed-empty resolver); DESIGN.md § v4.36/§ v4.37 amended with the binding-parity clause, plus two stale "no guard exists" paragraphs corrected in the same commit; this milestone's documentation.

Phases

PhaseNameStatusPRHighlights
1Census the binding population, rule the resolution semanticsShipped#3367776 bindings, ten expression shapes, branch-semantics ruling; Phase 3 deferred to OBJ-4030 on CENSUS-3 evidence
2Literal-tier :class resolver in both guard helpersShipped#3377boundClassResolver.ts shared by both guards; v4.40's gate re-expressed, not weakened
3Identifier tier — same-file script-scope resolutionDeferred → OBJ-4030Zero of the known raw-ink/tint defects depended on it; milestone closes complete without it
4Sweep A — raw-ink survivors in bindings, both appsShipped#339117 sites (10 ionic + 7 admin) swept to -accessible; PHASE_4_BOUND_ALLOWLIST[], deleted
5Sweep B — tint/ink pair survivors in bindingsShipped#339569 tint-bearing bindings evaluated under RULE-1; gradient-fill + element-scoped ceiling taught to the guard
6Re-establish the completion gate, write the rule, land the docsShipped#3399GUARD-9/10 gate restored at full strength; DESIGN.md amended; this milestone's docs

Key Decisions

  • The guard's own AST census overrides a regex census, always. Orion's kickoff census was regex-based and explicitly not authoritative — Phase 1's AST harness (the same parse path the guards use) reconciled the headline population exactly but corrected two figures a regex measurement got wrong (bare-identifier/member-access at 114, not 163; hue-token-bearing literal bindings didn't reconcile to 97 at all). Every later phase sized itself off the AST numbers.
  • A deferred phase is a fence exercised on evidence, not a scope cut absorbed silently. RULE-2 deferred Phase 3 to OBJ-4030 because CENSUS-3 measured zero of the known defect population depending on it — the milestone's own goal sentence stayed fully achievable without it, and the residue-naming obligation (GUARD-8) that would have shipped in Phase 3 migrated forward into Phase 2's GUARD-8a instead, so the milestone never closed with an unnamed blind spot.
  • Branches of one binding are evaluated separately, never joined. RULE-1's ruling — within one binding, mutually exclusive; across two elements, an independent cross-product — is conservative in the direction that matters (it can over-report into a phase-tagged allowlist, never hide a real defect) and is what the resolver's string[] branch-alternative return shape encodes structurally rather than as a runtime check.
  • The v4.40 completion gate is restored, not merely widened. Phase 2 had to re-express expect(ALLOWLIST).toEqual([]) into a static-class half plus a temporary bound-class allowlist to land the resolver without breaking a shipped gate outright; Phase 6's job was explicitly to fold those back into one assertion covering the full widened denominator, with GUARD-10 proving the widening is real rather than an artifact of the resolver quietly matching nothing.
  • A sanity assertion you never saw fail is not evidence. GUARD-10's acceptance criterion required stubbing the resolver to return empty and confirming the new assertion actually goes red before trusting it — done and reverted cleanly in both guards, independently reproduced by Roy's review.

Requirements Coverage

28/28 items across the five executed phases (CENSUS-1..4, RESOLVE-1, RULE-1..2 in Phase 1; GUARD-1..6, GUARD-8a in Phase 2; SITE-1..5 in Phase 4; SITE-6..8 in Phase 5; GUARD-9..10, DOCS-1..4 in Phase 6) — all satisfied. Phase 3's three items (RESOLVE-2, GUARD-7, GUARD-8) are recorded verbatim in the milestone ROADMAP as the deferred record, carried into OBJ-4030, and are not counted against this milestone's own closed scope.

CategoryCountStatus
CENSUS-1..4, RESOLVE-1, RULE-1..27All satisfied
GUARD-1..6, GUARD-8a7All satisfied
SITE-1..55All satisfied
SITE-6..83All satisfied
GUARD-9..10, DOCS-1..46All satisfied

Full requirements list: v4.66-bound-class-guard-reach-ROADMAP.md on GitHub.

Outcomes

A tint/ink or plain-surface-ink contrast defect written as a :class binding now fails the build exactly as the identical defect written as a static class string does, on the literal tier that covers 100% of the known defect population. Both guards' completion gates assert [] over that widened denominator, and a sanity assertion (GUARD-10) guarantees "zero violations" can only mean "swept," never "the resolver quietly stopped matching." DESIGN.md § v4.36 and § v4.37 now say so explicitly, in the same commit that corrected two paragraphs that had drifted stale claiming no such guard existed. The identifier tier (bare identifier, member access resolved against the same file's script block) remains unenforced, named as residue rather than discovered silently later, and tracked as OBJ-4030.

Tech Debt

  • (OBJ-4030, deferred by RULE-2 at Phase 1) The identifier tier — same-file script-scope resolution of a bare identifier or member access inside a :class binding — is unenforced. CoachQuote.vue's containerClass/glyphClass computed strings are the acceptance case once this ships. One confirmed site (MeetCoach.vue:127, importing PERSONA_CLASSES from coachPersonas.ts) is cross-file and will remain unresolved even after OBJ-4030 ships, since that follow-on is scoped to same-file resolution only.
  • (OBJ-4054, named not fixed, Phase 6) scanSolidFillTemplateAst() (ionic_frontend/tests/unit/helpers/tintInkTemplate.ts:547), the AST tier for the separate Solid-Fill Own-Foreground Floor (§ OBJ-2669), still reads only a static class attribute — the identical blind-spot shape this milestone closed for the v4.36/v4.37 rules, one function above it in the same file. Every live :class-bound solid-fill site today happens to keep its class list on one physical line, so the guard's per-line Tier B scanner catches them by coincidence, not by design.
  • (carried onto the accessibility ledger, flagged during Phase 6, not fixed here) DESIGN.md § v4.37's "Blast radius, sized but not swept" paragraph (~line 836) still reads "the sweep needs a guard that can tell a named glyph from an aria-hidden one, and that guard does not exist yet" — stale since v4.40 Phase 1 shipped plainSurfaceInkGuard.spec.ts, the guard that paragraph says doesn't exist. Out of this milestone's own Phase 6 scope (Orion's task package named only DESIGN.md:802/:832 for that commit); filed for Maggie to route.
  • Cross-component tint/ink pairs (tint on a parent component's element, ink inside a child component's own template) — a standing, separately-named residue of both guards since v4.36 Phase 5, orthogonal to bindings and untouched here.
  • Runtime-composed class strings (string concatenation, cva/clsx-style builders, values arriving as props from a parent) — structurally out of reach of any per-file static walk; named, not chased.
  • 18450a3bbfeat(v4.66-phase-1): AST census + branch-semantics rulings for bound-class guard reach (OBJ-4014) (#3367)
  • 16c6bb51a[Codi] feat(ionic_frontend): shared :class branch-alternative resolver for contrast guards (OBJ-4015) (#3377)
  • 3fb470ad6fix(a11y): sweep bound-class raw-ink survivors to -accessible siblings (OBJ-4017) (#3391)
  • ba2a05712fix(a11y): teach tint/ink guard gradient fills + element-scoped ceiling (OBJ-4018) (#3395)
  • fca5a28b3test(a11y): lock v4.66's bound-class completion gate, add resolved-bindings sanity guard (OBJ-4019) (#3399)

Last updated: 2026-09-20

Loading…