Skip to content

Design tokens — primary/accent foreground contrast contract

The rule, the failure mode that broke it, and the guards that now stop it recurring. Written after v4.29 — Primary Foreground Contrast Remediation (OBJ-2055), which fixed 7 of 10 themes failing WCAG AA on their primary CTA and 9 of 10 failing on accent, then reconciled onto v4.26 — Light-Mode Contrast Floor's independently-shipped fix (OBJ-1958) once both were found to have landed the same defect from different branches. Extended by v4.31 — Gradient Primary Dark-Mode Redesign (OBJ-2075), which closed the gradient family's own dark-mode declaration gap using the identical silent-fallthrough shape v4.29 found in --accent. Extended again by v4.33 — Primary Ink Adaptive Swap (OBJ-2266), which closed the consumer-layer half of the same defect — hardcoded text-white sitting on a --primary-family fill instead of the adaptive text-primary-foreground — across both frontends, and ruled on the twelve category-cover/tone-chip gradients that carry no token-level ink guarantee at all. See docs/milestones/ once each milestone's narrative publishes; until then the full history is .planning/milestones/v4.29-primary-foreground-contrast-remediation-ROADMAP.md, .planning/milestones/v4.31-gradient-primary-dark-mode-redesign-ROADMAP.md, and .planning/milestones/v4.33-primary-ink-adaptive-swap-ROADMAP.md.

For component-level styling conventions (spacing, type ramp, card recipes), see Desi's Design Context reference. For the general WCAG standard and testing tools, see Accessibility Standards. This page is scoped to one thing: the --primary/--accent token family's contrast contract, including the gradient tokens derived from --primary.

The rule

Every --primary/--primary-foreground pair and every --accent/--accent-foreground pair must clear WCAG AA 4.5:1 against each other, in both light and dark mode, across all ten themes (Default + 9 Supporter presets). A light theme block must never default to flat white (0 0% 100%) foreground without checking — that was the original defect: every light block hardcoded white while every dark block correctly derived same-hue near-black ink, and nothing checked either one.

Live values are in design_system/css/tokens.css (Default, :root + html.dark) and ionic_frontend/src/theme/supporter-themes.css (the 9 Supporter presets, html[data-theme="…"] + html.dark[data-theme="…"]). Don't transcribe exact HSL values into this doc — read them from source, and trust themeContrast.spec.ts (below) for the current pass/fail state. Values here have already drifted twice in one milestone (see "Two independent fixes, one reconciliation" below); a hardcoded table is the thing that goes stale.

The token family

Eight token pairs/singles now carry a contrast contract, not just --primary-foreground/--accent-foreground:

TokenRolePredicateGuard
--primary / --primary-foregroundBrand fill + its glyph ink≥4.5:1 vs each otherthemeContrast.spec.ts
--accent / --accent-foregroundAccent fill + its glyph ink≥4.5:1 vs each otherthemeContrast.spec.ts
--primary-interactiveHover/pressed fill for --primary (not text-bearing on its own)vs --primary-foreground ≥4.5:1themeContrast.spec.ts (dedicated describe block)
--primary-ink / --primary-ink-foregroundSolid ink for a glyph that would otherwise sit on the primary gradient (background-clip: text or a fill on --gradient-primary)≥4.5:1 vs each othertokenDrift.spec.ts
--primary-accessibleText/ink role — used as text-primary-accessible, e.g. --focus-ring's alias target≥4.5:1 against the full binding-surface set (see below)tokenDrift.spec.ts
--accent-accessibleText/ink role for accent, including composited accent-tint surfaces≥4.5:1 against the full binding-surface set (see below)tokenDrift.spec.ts
--gradient-primary / --primary-light / --primary-glowBrand gradient fill + its lighting tokens (glow, drop-shadow, shimmer sweep)Worst-stop ≥4.5:1 vs that same block's --primary-foreground — a per-gradient predicate, not a pairgradientPrimaryGuard.spec.ts
--gradient-accent / --accent-lightAccent gradient fill + its lighting/ramp tokenWorst-stop ≥4.5:1 vs that same block's --accent-foreground--accent-light is presence-only, not contrast-bound (see "The accent contract" below)gradientPrimaryGuard.spec.ts

Three separate predicates live in this table, and conflating any two of them is the likeliest documentation mistake here. --primary-accessible and --accent-accessible are not members of the 40-pair themeContrast.spec.ts contract — they're each checked against their own full composited binding-surface set (not a flat --card/--background pair — see "The composited-tint predicate gap" below for why that distinction matters), not against --primary/--accent themselves, and tokenDrift.spec.ts is their backstop. --gradient-primary's worst-stop predicate is a third, distinct shape again — it isn't a pair check at all, it's the minimum contrast across every stop in a multi-stop gradient against a single foreground, and gradientPrimaryGuard.spec.ts (not themeContrast.spec.ts, not tokenDrift.spec.ts) is its backstop. Don't say "themeContrast.spec.ts covers the -accessible tokens" or "covers the gradient family" — it covers neither. See "The gradient family" below for the full contract.

The dead zone — why a foreground swap alone doesn't always work

The instinct is "if white fails, use black (or same-hue near-black) instead." That works for most failing themes, but not all of them, because some primaries sit at a mid-lightness where the primary itself is the problem: at that lightness, no foreground — full white, full black, or any same-hue tint in between — reaches 4.5:1 against it. bestForeground() (ionic_frontend/src/lib/customTheme.ts) returns its best available candidate and it still fails.

v4.29's Phase 1 investigation measured this directly against the pre-fix tree: Rose's best available foreground reached only 4.36, Ember's 4.25, Default's 3.76 — all short of the 4.5 floor, no matter which ink was tried. Those three are dead-zone primaries. Every other failing theme (Sunset, Ocean, Forest, Lavender) had real headroom — swapping their flat-white foreground for same-hue near-black ink cleared the bar without touching --primary itself.

A dead-zone primary has two escape routes, not one:

  1. Darken the primary, keep white ink. Move --primary to a lower lightness until white foreground clears 4.5:1. Ember shipped this route: its primary darkened and its foreground stayed flat white.
  2. Lighten the primary, switch to same-hue tinted ink. Move --primary to a higher lightness — counterintuitive, but it's what lets a tinted (not pure-black) same-hue foreground clear the bar, and it keeps the theme inside the same-hue-ink convention every dark block and Gold Rush's light block already follow. Rose shipped this route: its primary lightened and its foreground switched from flat white to same-hue tinted ink.

Both routes are valid; which one to pick is a design call (which direction reads better on-brand), not a mechanical one — bestForeground() + clampLightness() (the same pair deriveCustomTheme() already runs for a user's custom Supporter pick) can derive either, but a human has to choose the direction.

Default's shipped fix took neither route. Its --primary never moved. The original defect was a token-reuse mistake, not a dead zone: --primary-foreground had been set to the same value as --background (an off-white, not true white), which is worse than either real escape route and was the actual source of Default's failing ratio. Swapping to literal pure white (0 0% 100%) was enough on its own. Worth naming as its own gotcha: check that a foreground token wasn't accidentally borrowed from an unrelated surface token before reaching for a lightness change.

Worked example — Rose, Ember, Default. Keep these three in mind for the next dead-zone theme: Rose (lighten + tint), Ember (darken + keep white), Default (fix the token reuse, no lightness change at all). A future Supporter preset that fails AA on --primary/--primary-foreground is one of these three shapes, not a fourth.

The dark-mode accent cascade

A second, independent bug, found while investigating the primary fix: no Supporter dark block declared --accent or --accent-foreground at all. html.dark[data-theme="…"] and html[data-theme="…"] have equal CSS specificity, and a rule that doesn't declare a custom property doesn't reset it — so in dark mode, a Supporter theme was silently inheriting its own light accent pair instead of getting a dark-tuned one. Accent never got the "lighter/adjusted in dark mode" treatment --primary always had; it was invisibly stuck on light-mode values.

The fix wasn't a value change — it was making every html.dark[data-theme="…"] block explicitly redeclare --accent/--accent-foreground (today they're equal to the light value in every shipped preset, but they're declared, not inherited by cascade accident). The generalizable rule for the next token added to this file: a value that isn't explicitly declared in every mode × theme combination is not "inherited" in any safe sense — it's whatever the cascade happens to resolve to, and that can silently be the wrong theme's value. Verify by declaration count, not by eye. See "The gradient family" below for the precise mechanism behind that "whatever the cascade happens to resolve to" — it isn't :root for 9 of the 10 themes, and getting that detail wrong is what let this same trap recur once already.

The undeclared-token trap — --primary-interactive and deriveCustomTheme()

--primary-interactive (the hover/pressed fill for a primary button) was undefined in every Supporter light block for a while — UiButton's hover:bg-primary-interactive fell through the cascade to the Default theme's own interactive value. The visible symptom: hovering a Sunset primary button turned it Default blue. Not a contrast bug on its own, but the same silent-fallthrough shape as the accent cascade above, and it shipped in the same file. Fixed by declaring --primary-interactive explicitly in the default theme plus all 9 Supporter light blocks (themeContrast.spec.ts asserts the declaration count is exactly 10).

The same trap still applies to a user-generated custom theme. deriveCustomTheme() (ionic_frontend/src/lib/customTheme.ts) is what a Supporter's Theme Creator pick actually runs through, and as of this writing its light return block emits 16 keys — --primary, --primary-foreground, --primary-light, --primary-glow, --primary-accessible, --accent, --accent-foreground, --accent-light, --accent-accessible, --gradient-primary, --gradient-accent, --gradient-hero, --gradient-card, --shadow-glow, --shadow-accent, --ring — and its dark block emits the same list minus --shadow-glow and --ring (14 keys). OBJ-2257 closed the dark block's prior gap on the accent family: --accent, --accent-foreground, --accent-light, --gradient-accent, and --shadow-accent are now emitted there too, each equal to its light counterpart — the accent anchor and --accent-foreground are mode-invariant (see "The accent contract" below), so no separate dark derivation was needed the way --gradient-primary required one. Neither block emits --primary-interactive or --primary-ink/--primary-ink-foreground. A custom theme today silently falls through to the Default theme's values for those two, the identical failure mode the hardcoded presets had before FG-INTERACTIVE-1 fixed them. Not yet remediated — flag it if you're touching deriveCustomTheme() next.

The composited-tint predicate gap — OBJ-2425

Resolved 2026-08-10 — OBJ-2425. --primary-accessible's predicate emitted a value that cleared 4.5:1 against flat --card and --background only (ionic_frontend/src/lib/customTheme.ts:216-227), while every real text-primary-accessible consumer — 81 anchors repo-wide — paints on a tinted composite (bg-primary/10, bg-primary/20, and stacked layers over --muted/--secondary/.glass-card). The gap was invisible to themeContrast.spec.ts (which never checks the -accessible family, see "The token family" above) and to the old, narrower tokenDrift.spec.ts guard, which asserted the flat pair only.

Scale of the flat-check gap, swept across the Theme Creator's full reachable input space (hue 0–360 × saturation 0–100, 36,461 inputs): light mode's bg-primary/10-over---card composite failed 4.5:1 on 64.6% of inputs (worst 3.97:1); dark mode failed on 40.8% (worst 4.00:1) against the same surface. Dark also had an independent, second hole the flat check couldn't see at all: --muted is lighter than --card/--background in dark mode, making it the hardest flat dark surface, and the predicate never evaluated it.

The fix generalizes the mechanism --accent-accessible already used, rather than inventing a second one. clearsAccentAccessibleSurfaces() widened into one shared helper taking an ordered layer-stack shape ({base, layers: [{token, alpha}]}) so a composite like --card@.70 → --secondary@.30 → primary@.10 can be expressed and folded to one effective alpha, plus a non-card/background flat backdrop (--muted) neither token's old constant union could express. PRIMARY_ACCESSIBLE_LIGHT_SURFACES (17 entries at ship time — grown to 18 by OBJ-2359, see below) / PRIMARY_ACCESSIBLE_DARK_SURFACES (19 entries at ship time — grown to 21) are now the enumerated binding-surface set both deriveCustomTheme()'s clamp and 12 preset --primary-accessible ink values (7 light + 5 dark) are verified against — hue and saturation untouched, lightness-only movement, 0 failures across the full input space in both modes. The binding-surface set itself is documented in exactly one canonical place: docs/ui-specs/obj-2425-primary-accessible-binding-surfaces.md — this page and DESIGN.md cross-reference it rather than duplicating the surface list.

Two markup re-anchors landed alongside the constant (not preset-value changes): GoalPartnerChip.vue's shared-state avatar disc moved off a nested bg-primary/20-in-/8 composite (which folded above the .20 threshold the spec treats as "authored tint, not a composition accident") onto bg-card; AmbientSidebar.vue's Level-chip plinth gradient stop lowered from /0.25 to /0.15. Both are ink-preserving, fill-only changes with no layout, type, or copy impact.

Guarded going forward: tokenDrift.spec.ts gained a step-5 grid deriver sweep (1,533 inputs, both modes) asserting 0 failures against the full surface set, demonstrated failing on the pre-fix tree before being trusted, plus a preset guard widened from the single /10-over---card case to the full 17/19-surface set (now 18/21, see below). --focus-ring's existing ≥3:1 guard (tokenDrift.spec.ts:485-500) was re-run rather than rewritten — the fix only moves ink further from every surface in both modes, which cannot regress that floor. See docs/development/accessibility.md § App-Side AA-Violation Ledger for the ledger entry and the [Unreleased] OBJ-2425 entry in CHANGELOG.md for the fix writeup.

Known, related, not-yet-fixed: --accent-accessible's dark surface set has the same flat---muted hole this ticket closed for primary — ACCENT_ACCESSIBLE_DARK_SURFACES still doesn't check --muted, and accent ink sits on bg-muted at CoachingAccordion.vue:45 and the same SettingsRowAction.vue disc. Routed out as a separate finding during OBJ-2425's Task 1, not fixed here — flag it if you're touching ACCENT_ACCESSIBLE_DARK_SURFACES next.

OBJ-2359 — ios action-sheet surfaces folded into the same clamp

Resolved 2026-08-11 — OBJ-2359. Ionic's ios <ion-action-sheet> paints enabled-row, cancel-row, and header ink on its own unthemed surfaces — #f9f9f9 light group, #1a1a1a dark group, #262626 dark cancel-row ::after — none of which are --card, --background, or --muted, so the OBJ-2425 binding-surface set above didn't cover them yet. Folded in as three more base cases of the same generalized BindingSurface shape rather than a bespoke predicate: iosSheet (ACTION_SHEET_IOS_LIGHT/ACTION_SHEET_IOS_DARK) and iosSheetCancel (ACTION_SHEET_IOS_CANCEL_DARK only — the light cancel ::after surface is #ffffff, already covered by CARD_LIGHT/L1).

PRIMARY_ACCESSIBLE_LIGHT_SURFACES gains L18 (the ios group surface, #f9f9f9) for 18 entries total; PRIMARY_ACCESSIBLE_DARK_SURFACES gains D20 (ios group surface, #1a1a1a) and D21 (ios cancel-row surface, #262626) for 21 entries total — both in ionic_frontend/src/lib/customTheme.ts. One shipped preset needed a value move to clear the widened set: midnight's dark --primary-accessible moved from 250 70% 69% to 250 70% 74% (ionic_frontend/src/theme/supporter-themes.css) — a larger recompute that landed independently via OBJ-2425's own continued work and turned out to already clear both new ios surfaces with margin (6.24:1 group, 5.42:1 cancel), so no separate value was needed once the two branches reconciled.

Alongside the clamp fix, three ios-only CSS declarations land in the ion-action-sheet.ios block (ionic_frontend/src/theme/tailwind.css:563-584): --button-color/--color resolve to hsl(var(--primary-accessible))/hsl(var(--muted-foreground)) for enabled rows and the header pair, and the destructive row's red (--destructive-text) ships as Ionic's own three-selector shape (rest / .ion-activated / .ion-focused) plus an any-hover rule, so it survives Ionic's higher-specificity state rules instead of flashing back to --ion-color-danger on press. .md is untouched — md's --button-color is already --ion-color-step-850 (~15:1) and md has no .action-sheet-destructive rule at all, so extending the ios declaration to .md would both throw away contrast and make destructive rows indistinguishable from their neighbors.

Guarded going forward: tokenDrift.spec.ts widened its own independently-duplicated surface model (kept deliberately separate from customTheme.ts — see "The mechanical backstop" below) with the same three surfaces across all 10 presets × 2 modes at 4.5:1, plus an md-unchanged regression assertion. A new actionSheetEnabledContrast.spec.ts asserts the shipped CSS's computed values (not class presence) at rest and under .ion-activated, so a future edit that changes the selector shape back to a single rule fails loudly instead of silently losing the destructive row on press. See docs/development/accessibility.md § App-Side AA-Violation Ledger for the full ledger entry and the [Unreleased] OBJ-2359 entry in ionic_frontend/CHANGELOG.md and CHANGELOG.md for the fix writeup.

OBJ-2451 — the same predicate gap in a gradient, not a token

Resolved 2026-08-11 — OBJ-2451. The app-link tile picker's right-edge scroll fade (GoalForm.vue) was a colour-fill gradient (bg-gradient-to-l from-card to-transparent) assuming the surface behind it was flat --card. It wasn't: the sheet wrapper (GoalForm.vue:2) is bg-card/70 backdrop-blur-xl dark:bg-card/40 — a translucent, backdrop-blurred composite — and the mask span painted its colour on top of that composite, not inside it. In dark mode this painted a colour roughly 2.5× more opaque than the surface it was meant to disappear into: a lightening band with a hard edge at the fade's boundary, not a smooth dissolve.

The rule, generalized from "The composited-tint predicate gap" above: a colour-fill gradient is a child painting a colour on top of its parent's already-composited fill — over a translucent, backdrop-blurred parent, painting adds to what's there, so no gradient stop composites to "no visible change." This isn't quite "no colour exists" in the strictest sense — in this specific build the modal backdrop happens to be opaque, so a colour matching the composited result could in principle be computed today — but that colour would be a fourth constant derived from three upstream values (--card, its alpha, the modal background) with nothing keeping it in sync and no test that can catch it drifting, because a compositing identity isn't a contrast ratio. The precise rule for reuse: no token composites to no-change over a translucent, backdrop-blurred parent, and any raw value that happens to work today is unowned and undefended against the next value that moves upstream of it.

The fix: replace the colour-fill overlay with an alpha mask-image on the scroller itself — fading the content to transparent rather than painting a colour over it, so it's correct on any backdrop, in either theme, at any blur, with no derived constant at all. CoachingAccordion.vue:85 is the reference implementation ([mask-image:linear-gradient(to_right,#000_calc(100%-24px),transparent)], 24px), reused byte-identical rather than re-derived; its overflow guard (CoachingAccordion.vue:403-413, glanceOverflowing) was deliberately not copied here — GoalForm.vue's 10-preset track overflows its scrollport at every breakpoint, so a guard would compute a constant true for the cost of a ref, a listener, and a watch. See the calc(100%-24px) gotcha for the one trap in reusing that exact mask string outside a Tailwind class attribute.

The one legitimate exception: a colour-fill gradient is correct on an opaque surface — there's no translucent parent to add to, so a matching-colour stop composites to true no-change. Every from-card/to-card/via-card gradient in ionic_frontend/src was checked while diagnosing this defect (AchievementToast, CommunitiesHubCard, AllyLookupModal, PublicGoalSkeleton, PublicGoal, PublicProfile ×2) — all seven sit on opaque surfaces and are correct as shipped, not swept or modified. GoalForm.vue:424's scroller was the only defective site; this stayed a one-component fix, not a token-level sweep.

Not guarded by a new spec test. This is a mechanism swap on one presentational element, not a token whose value other consumers derive from — there's no shared constant here for a guard to pin. If a future scroll-edge fade needs to sit over a translucent or backdrop-blurred surface, start with an alpha mask-image; a colour-fill gradient is only safe when the surface behind it is opaque.

The decorative-icon exemption — DESIGN.md § v4.37 rule 3 (OBJ-2681)

Shipped 2026-08-18 — OBJ-2681, v4.40 Plain-Surface Ink Sweep, Phase 1. DESIGN.md § v4.37 Plain-Surface Ink Floor's rule 1 widens -accessible from a tint-only ink token (§ v4.36) to the ink token on any surface a glyph is meant to be read on, tinted or plain — the "composited-tint predicate gap" section above fixes a related but distinct gap, measuring -accessible's own predicate against real composited surfaces rather than a flat pair; it does not itself carry the plain-surface scope widening. Rule 3 carves out the one real exemption to the plain-surface rule: an <AppIcon> with no aria-label/title renders aria-hidden="true" structurally (ionic_frontend/src/components/icons/AppIcon.vue:56-61), driven by isDecorative (AppIcon.vue:38, computed(() => !accessibleLabel.value)) which is the negation of accessibleLabel (AppIcon.vue:37, props.ariaLabel || props.title) — no accessible name, nothing for a screen reader to announce. WCAG SC 1.4.11 (non-text contrast) governs graphics required to understand content; it does not apply to purely decorative ones at all, so a raw text-<hue> glyph in that state isn't an unfixed violation — there's no violation to fix. That's why darkening a decorative icon is its own anti-pattern rather than merely unnecessary work: the swap changes nothing a user perceives as information, while still paying rule 2's real cost (-accessible is the same hue at a different lightness, so it reads more muted). Spending that cost with nothing to show for it is worse than spending nothing. See docs/reference/desi-design-context.md § Exemption — decorative aria-hidden icons (SC 1.4.11) for the pre-guard, review-only statement of this same rule; this section is where the guard that mechanizes it lives.

The boundary is rule 3's second sentence, not its first. The exemption holds only while the icon stays nameless and silent. The moment an <AppIcon> gains an accessible name — aria-label or title set, so accessibleLabel.value is truthy and isDecorative flips false — or becomes the only carrier of a state a sighted user must read even with no name at all, it crosses into WCAG SC 1.4.11's actual scope and takes the 3:1 non-text floor and the -accessible token with it. Five sites in the current tree cross that line, and they're exactly the shape a naive "is it an <AppIcon>?" test would wrongly wave through, because each one carries both an accessible name and raw ink: CommunityAllyInviteInterstitial.vue:32, BetterTogetherInterstitial.vue:32, RepairConfirmationModal.vue:31, WelcomeBackInterstitial.vue:31, WelcomeBackInterstitial.vue:56. plainSurfaceInkGuard.spec.ts (ionic_frontend/tests/unit/) asserts all five as violations, not exemptions.

Guarded going forward: the guard proves the exemption against AppIcon.vue's own contract instead of assuming it holds — its GUARD-3 case reads the component source at runtime and requires it to still contain props.ariaLabel || props.title and !accessibleLabel.value verbatim, so a future change to how AppIcon.vue decides decorativeness fails this guard rather than silently drifting out of sync with it. GUARD-4 seeds the regression directly: a named <AppIcon class="text-gold"> fails, the unnamed equivalent passes, and — the shape most likely to break silently, since the file is tag-aware rather than line-aware (helpers/plainSurfaceInkTemplate.ts) — a named attribute on a continuation line of a multi-line tag still fails. An explicit aria-hidden="true" on any element is exempt by the same mechanism, independent of <AppIcon>.

Known, related, not fixed here — the guard's review bucket. Sampling the exempt set during this phase turned up unnamed <AppIcon>s directly gated by v-if/v-show, making them the sole visible signal of a state despite carrying no accessible name — HabitCard.vue's "checked in today" success glyph is the guard's own flagged example. These are correctly exempt under SC 1.4.11 as written today (no name, so no violation), but the state-carrying role is exactly what rule 3's second sentence describes once a name is added. Rather than silently folding these into exempt, the guard reports them in a distinct review bucket, so a follow-on ticket starts from a list instead of a re-sample. Not this phase's fix — out of scope for OBJ-2681, tracked for a later milestone phase.

Bound-class reach — a :class binding is governed exactly as a static class (OBJ-4015/OBJ-4019)

Shipped 2026-09-20 — v4.66 Bound-Class Guard Reach. Both contrast guards' element-tree read (tintInkTemplate.ts/plainSurfaceInkTemplate.ts) used to see only a static class="..." attribute — a :class/v-bind:class binding parses as a directive, not an attribute, and was invisible to every tier. resolveElementClasses() (ionic_frontend/tests/unit/helpers/boundClassResolver.ts) is the shared resolver both guard helpers now read through instead, so they cannot drift on what a class string is: it reduces a binding's expression to string[] branch alternatives (one entry per complete candidate class string the element can carry at runtime) and every existing hue predicate runs once per alternative, never on the alternatives joined together.

The contributor-facing consequence: a hue token written into a :class binding is governed exactly as one written into a static class string. If you'd reach for -accessible writing class="text-gold", reach for it just as reflexively writing :class="isRead ? 'text-gold' : ''" — the guard fails the build on both today.

Shapes resolved, and shapes that still land in a named unresolved bucket instead of silently passing: string literal, ternary (including nested), object literal, array literal, a template literal with no interpolation, and logical &&/??/|| all resolve to real alternatives. A bare identifier, a member/index access, a call expression, or string concatenation do not — each is explicitly cased into unresolved, never left to fall through a default branch. That gap is tracked as OBJ-4030 (same-file identifier-tier resolution only — a cross-file root, like MeetCoach.vue:127's import from coachPersonas.ts, stays unresolved even after OBJ-4030 ships).

Not the same gap as scanSolidFillTemplateAst()'s. The Solid-Fill Own-Foreground Floor's own AST tier (§ OBJ-2669 above, tintInkTemplate.ts:547) still reads a static class attribute only — the identical blind spot this section's resolver closed, one function above it in the same file, for a different rule. Tracked separately as OBJ-4054, not fixed by this work.

Two guard predicates widened at the same time, both worth knowing if you're adding a new gradient token or a multi-line binding: opaqueFillHueIn() now recognizes a token-backed bg-gradient-<hue> utility as an opaque fill of <hue> — derived from the --gradient-<hue> custom property's own resolved value across every theme × mode, never from the class name, so a real tint like --gradient-coach-tint can never qualify merely because its name starts with gradient-coach. And the tint/ink ceiling check (SHIPPED_CEILING[hue]) is now element-scoped for a bound class via scanResolvedClassSites(), not just line-scoped — it joins a multi-line binding's tint and ink into one string per branch alternative first, so a pairing split across physical source lines of the same alternative is still caught.

See docs/development/accessibility.md § App-Side AA-Violation Ledger for the OBJ-4019 ledger entry and DESIGN.md § Accessibility hard rules — v4.36/v4.37 for the binding-parity rule amendment this ships with.

The gradient family — --gradient-primary / --primary-light / --primary-glow

Resolved 2026-08-07 — v4.31 Gradient Primary Dark-Mode Redesign (OBJ-2075, Phases 1–4). This section used to document an open gap, in terms later found to be wrong (see the callout below). It now documents the shipped contract, because this is the one page a token author actually opens, and a stale "not yet fixed" here would be this milestone's own most-authoritative wrong document.

The general rule this milestone corrected — read this before touching any theme block

A token declared in :root and in every Supporter theme's light block, but in no dark block, is not "inherited" in any safe sense. The instinct is that an undeclared dark value falls through the cascade to :root's value, the same way it would if only Default existed. That is wrong for every theme except Default, and the wrong version is exactly what this milestone was planned and partly measured against for its first three days.

The actual mechanism: supporter-themes.css declares each Supporter theme in two blocks, html[data-theme="x"] and html.dark[data-theme="x"]. The first selector is not scoped away from dark modeuseTheme.ts leaves data-theme on <html> when dark mode is active, so in dark mode both blocks match, and the dark block only wins for the custom properties it actually declares. A token missing from the dark block therefore resolves from that same theme's own light block, not from :root. Only the Default theme, which has no [data-theme] attribute at all, falls through html.dark to :root.

Document both halves, always: (1) Default falls through to :root. (2) every other theme silently serves its own light-mode value in dark mode instead. Verify a new token's dark-mode safety by declaration count across all 20 html-level blocks (:root, html.dark, 9× html[data-theme], 9× html.dark[data-theme]), never by eye and never by assuming cascade fallthrough behaves like a single-theme app.

What this page used to say, and why it was wrong. Before this correction, this section stated the gap was "not yet fixed" and that a dark-mode read "falls through to :root's undifferentiated blue gradient regardless of active theme" for 10 of 10 themes, citing 26 failing cells. All three claims were false on the merged tree, and the middle one was false even at the time it was written — it was the project's own founding misreading (inherited from the OBJ-2075 issue into this milestone's ROADMAP), disproved in a real browser with getComputedStyle() during Phase 1: only Default ever reached :root; the other 9 themes silently wore their own light-mode gradient in dark mode the whole time. The corrected baseline is 20 of 80 failing cells, with dark bg-gradient-primary failing 4 of 10 themes (default 4.16, midnight 3.32, ember 4.41, slate 3.58), not 10 of 10. Kept here, labelled, because it's the misreading the next author would otherwise re-derive from the same starting premises.

The mechanism — anchor-and-travel

--gradient-primary anchors one stop exactly on that block's --primary and places the other stop 10 lightness points away, in whichever direction increases contrast against that block's --primary-foreground, carrying the theme's existing hue/saturation drift on the far stop.

This rides on an invariant already guarded elsewhere — --primary vs --primary-foreground is guaranteed ≥4.5:1 in all 20 blocks by themeContrast.spec.ts — rather than inventing a second one. Direction is per-theme, not global: 6 themes ship near-black same-hue ink and travel lighter; the 4 that ship white ink (default, midnight, ember, slate) travel deeper; all 10 dark blocks travel lighter (their ink is always near-black). A single global "always deepen" rule would have broken the 6 light-ink themes. deriveCustomTheme() (ionic_frontend/src/lib/customTheme.ts) implements the identical rule for a user-generated custom theme, so an 11th, Theme-Creator-derived theme gets the same guarantee with no extra code.

Alongside the token change, the ad-hoc from-primary to-primary-light/to-primary-glow pair was retired on text-bearing surfaces only and replaced with bg-gradient-primary — that pair held 12 of the 16 light-mode failures and is structurally unfixable (see "The fill-vs-ink rule," below). It remains sanctioned on purely decorative surfaces (progress bars, tints) where no text or icon sits on top.

The OBJ-1960 A4 reversal

tokenDrift.spec.ts used to carry a test titled "no dark block declares a --primary-light — the missing dark gradient stop stays missing (A4, Orion)", backed by a matching comment in tokens.css. That assertion is now reversed — the test and comment now assert the opposite, that every dark block does declare it.

Why, so the next author doesn't re-derive the original decision from the same premises: OBJ-1960's A4 reasoned that declaring a dark --primary-light would recreate a two-stop contrast problem, and that leaving it undeclared avoided one. A4 was right about the two-stop risk and wrong about the avoidance — an undeclared token doesn't vanish, it silently resolves to a light-mode value via the fallthrough mechanism above, which is exactly how every Supporter theme ended up wearing a flat or light-tuned gradient in dark mode. The fix is to declare the token and make sure its value clears the worst-stop guard, not to leave it undeclared.

The fill-vs-ink rule for --primary-light / --primary-glow

--primary-light and --primary-glow are lighting tokens — the CSS that consumes them directly is a radial glow, a drop-shadow, and a shimmer sweep, not body text. They are deliberately not held to the 4.5:1 pair contract the way --primary-foreground is; forcing them AA-safe as a text fill would destroy their role as a lighting effect for at least four themes. The rule this states is about the token as a background — no text or icon may sit on top of bg-primary-light or bg-primary-glow as a fill. Using either token as the ink/foreground color on a dark surface is a different thing, and is fine.

  • As fill (the failing polarity): bg-primary-light under white ink measured 2.45 in light mode and 1.84 in dark mode. admin_dashboard did this in 12 places — exactly what an undocumented rule buys. ⚠️ Past tense as of 2026-08-07: Phase 2 (PR #2167, 40430dac7) fixed all 12 by swapping to hover:bg-primary-interactive; a re-sweep of admin_dashboard/src for the same fill shapes now returns zero occurrences. Do not read "admin does this in 12 places" as a current fact — it's the history that motivated the rule.
  • As ink (the legitimate polarity): text-primary-light on a dark surface measures 10.25 against --background and 9.71 against --card. Seven sites do this today (2 in admin_dashboard, 4 in ionic_frontend — one of them, TemplatePickerModal.vue, at two separate lines — and 1 in design_system) and are correct as written; no work needed.

This whole token family was invisible to the original consumer census because bare bg-/text-/border-/ring-/shadow-/via-primary-light|glow usage wasn't one of the four shapes the census counted (Tailwind class, raw var() in template, raw var() in <style>, .stories.ts) — it took a fifth pass to find 10 net-new files this way. A token census is only as complete as its shape list; if you're writing one, enumerate every Tailwind utility prefix a token can appear under, not just the ones you expect.

deriveCustomTheme's dual-block obligation

A token added to the CSS layer but not emitted from both of deriveCustomTheme()'s light and dark return blocks ships broken for every Theme-Creator custom theme — the missing side silently falls back to the Default theme's value. This has now been the mechanism twice: --primary-accessible in v4.29, and the entire gradient family here (its light block used to emit --primary-light/--primary-glow/--gradient-primary; its dark block emitted neither). Fixed in Phase 1 — deriveCustomTheme() now emits all three tokens from both blocks, deriving the dark values with the same anchor-and-travel rule as the static theme files. --primary-interactive and --primary-ink/--primary-ink-foreground still have this gap (see "The undeclared-token trap," above) — not part of this milestone's scope, but the next person touching deriveCustomTheme() should check both return blocks for every token they add, not just the one they're focused on.

One remaining known gap, one resolved

  • --gradient-accent's Midnight WCAG AA gap — resolved 2026-08-07, OBJ-2257. This bullet used to describe an open gap, and one detail of the original measurement was wrong — corrected here so the next reader doesn't re-derive the wrong premise. The dark-mode read does not fall through to :root: a themed html[data-theme] block outranks :root in specificity and ties html.dark (see "The general rule" above), so pre-fix, Midnight's dark --gradient-accent/--accent-light actually resolved to Midnight's own light block value, never :root's. Midnight's dark cell measured 3.00, not the originally-recorded 2.12 — the verdict didn't change (2 of 20 cells failing, both Midnight, both modes), but the number was wrong and dark turned out to equal light rather than being a distinct fallthrough case. The fix moved Midnight's --gradient-accent far stop darker via anchor-and-travel — the identical rule "The mechanism" above uses for --gradient-primary — while --accent-light didn't move (a ramp token with no ink obligation, same ruling v4.31 gave --primary-light/--primary-glow). The accent family now carries the same dark-declaration contract v4.31 gave the primary family: html.dark plus all 9 html.dark[data-theme] blocks declare --accent-light and --gradient-accent, deriveCustomTheme()'s dark block emits both (see "The undeclared-token trap" above), and gradientPrimaryGuard.spec.ts was extended to cover it — see "The accent contract," below.
  • Hardcoded text-white on bg-primary fails AA in dark modeResolved 2026-08-08 — v4.33 Primary Ink Adaptive Swap (OBJ-2266). Tracked here as a follow-up since v4.31 Phase 5; see "The primary-ink rule" below for the full fix, the reason it survived multiple censuses, and the shared-ink ruling it produced for the twelve category-cover/tone-chip gradients that carry no token-level ink guarantee.

Both deferrals now resolved (OBJ-2257 above; OBJ-2266 below) — same pattern v4.29 used to produce this very milestone instead of letting either gap get lost.

The accent contract — gradientPrimaryGuard.spec.ts's accent-family guard

Resolved 2026-08-07 — OBJ-2257. Extending the shipped gradient guard, not writing a second one, per "One remaining known gap, one resolved" above.

The guard sentence: in every one of the 20 html-level blocks that declares --accent, both --accent-light and --gradient-accent must also be declared (40 declarations total), and --gradient-accent's worst stop must clear WCAG AA 4.5:1 against that same block's own --accent-foreground — plus the equivalent presence and worst-stop checks against deriveCustomTheme()'s emitted light/dark blocks, sampled across a spread of accent hues including the white-ink branch that used to be broken. --accent-light sits in the guard's presence set only, not its contrast set — Midnight's ceiling against white ink is L ≤ 56% and --accent itself (which can't move without regressing themeContrast.spec.ts) sits at 55%, making a 4.5:1 obligation on --accent-light arithmetically impossible there. This deliberately narrows the plain-English form of the rule stated for the primary family above; see gradientPrimaryGuard.spec.ts:184-189 for the reasoning inline.

This mirrors "The mechanical backstop" below's description of the primary family's guard: same file, same worst-stop predicate, same "must fail on the pre-fix tree" bar (demonstrated: 28 failures pre-fix, including Midnight's exact 3.64, all 28 passing post-fix).

The consumer-override blind spot — OBJ-2290

The generalizable defect class: a hardcoded ink literal (text-white) on a consumer element is invisible to every token-layer contrast predicate this page documents — themeContrast.spec.ts, tokenDrift.spec.ts, and gradientPrimaryGuard.spec.ts all measure a token's declared contrast against its own paired foreground; none of them look at what class a consumer actually applied. Every accent predicate above can report green while a component that never reads --accent-foreground at all keeps shipping white-on-white. This is "The primary-ink rule" below's shape, generalized: the token layer being correct proves nothing about a consumer that opted out of it.

The multi-tone trap. TeamLeaderboard.vue's podium rank numeral (TeamLeaderboard.vue:96, tone selected per rank by podiumTone() at 264-269) shared one text-white class across three differently-toned fills — from-gold to-warning (rank 1), from-slate-300 to-slate-400 (rank 2), and bg-gradient-accent (rank 3). The naive one-word fix, text-whitetext-accent-foreground on the shared class, would have passed every existing accent predicate while re-shipping the defect on a tenth theme: Midnight declares --accent-foreground: 0 0% 100% — literally white (supporter-themes.css:147,173) — so applying it to ranks 1 and 2 lands back at the pre-fix 1.69:1/1.48:1 ratios there (worst stops --gold and slate-300, re-derived independently against the merged token values, not copied from the issue thread), identical pixels to the bug it was meant to fix. The shipped fix moved ink into podiumTone() itself, pairing each branch with its own token (text-warning-foreground for ranks 1 and 2, text-accent-foreground for rank 3) so ink can't drift from the tone it renders on.

Guarded going forward: ionic_frontend/tests/unit/consumerContrastGuard.spec.ts's "Site 5" block asserts each podiumTone() branch pairs its correct ink token and that text-white is gone from both the static class and all three branches — the same anchored, hasClassToken/extractStringLiteral pattern (not a file-wide toContain) that block's own file header documents for the OBJ-2087 regression this guard family exists to prevent.

The primary-ink rule — text-primary-foreground, never hardcoded text-white (v4.33)

The rule: every element that fills with a --primary-family surface — bg-primary, hover:bg-primary-interactive, bg-gradient-primary, or one of the twelve category-cover/tone-chip gradients below — pairs its glyph or body ink with text-primary-foreground, never a hardcoded text-white.

Why this is not obvious, and why it survived undetected: light mode's --primary-foreground is 0 0% 100% — literally white. text-white and text-primary-foreground render the identical pixel in light mode, so wrong code looks correct there and only fails once --primary-foreground flips to near-black (222 47% 8%) in dark mode. Measured on the pre-fix tree: text-white on bg-primary was 2.45 in dark (fails 4.5) and a standing 4.55 in light — 0.05 of headroom over the AA floor, a known constraint this milestone did not touch, since the adaptive-ink swap is a no-op in light mode by construction. The clearest single before/after: admin_dashboard/src/views/AuditLogView.vue, which already used text-primary-foreground going into v4.31 Phase 2 and cleared both modes on the identical fill fix (dark 1.84 → 8.84) that left its 11 text-white siblings still failing — same change, same batch, the ink token the only variable.

This is a distinct guarantee from the gradient contract above, not a restatement of it. gradientPrimaryGuard.spec.ts measures --gradient-primary's worst stop against that block's own --primary-foreground — never against white — so any site pairing bg-gradient-primary with a hardcoded text-white sits entirely outside the v4.31 guarantee regardless of how solid that guarantee is for sites that use the adaptive token. The token layer being correct proves nothing about a consumer that opted out of it.

Both guard blind spots, recorded so a third one is spotted faster than these two were:

(The "Phase 1"/"Phase 2"/"Phase 3" references below are v4.33's own three phases — admin swap+guard, ionic swap+guard, design ruling+docs — not v4.29's or v4.31's phases of the same number.)

  1. A same-attribute regex misses ink and fill living in different elements. v4.33 Phase 1's admin_dashboard/tests/unit/primaryInkGuard.spec.ts matched fill and ink only when both tokens sat inside the same class="..." attribute — sufficient for admin_dashboard, where no site splits fill and ink across elements. Porting that same regex to ionic_frontend in Phase 2 would have missed 10 of that phase's 21 real sites: 8 where the fill is on a parent and the ink on a descendant (different elements, therefore different attributes, invisible to a same-attribute match by construction), and 2 :class-bound sites where the regex incidentally matched the class=" substring inside :class=" but the captured expression carried its own embedded quote characters, so the token comparison silently never matched — the guard looked like it covered these and didn't. The fix: an AST guard (vue/compiler-sfc, element-tree walk with ancestor-fill propagation) that treats fill and ink as tree-relative, not attribute-relative, backported to both apps.
  2. An AST guard that reads string literals still can't resolve a non-literal :class expression. The Phase 2 guard extracts every quoted string literal out of a :class="..." binding, which covers a ternary or an array (:class="cond ? 'a' : 'b'") — but a fill supplied by a computed property, a function call, or a map lookup never appears as a string literal in the template at all, so it is invisible to a literal-extracting scan by the same structural reason the regex missed parent/child pairs. This is exactly the blind spot that hid all eight of Phase 3's sites — TemplateCard.vue's gradientClass computed (7 category gradients), TemplatePickerModal.vue's GRADIENT_MAP[theme] ?? 'bg-gradient-primary' lookup, and five files' toneClass() function call (SubCommunitySwitcher.vue, ManageRoomsSheet.vue ×2, TeamSubCommunitiesTab.vue, CreateSubCommunityModal.vue, RoomCard.vue) — until a fourth, later audit found them by a different method entirely (grepping call-site usage, not the AST). The general lesson, stated once so the next census doesn't re-derive it: each scan technique undercounts by exactly the shapes it cannot represent — same-line grep missed parent/child, ancestor-propagating AST missed non-literal bindings. Assume a fourth blind spot exists before trusting any guard's silence as proof of correctness.

A third, narrower gap closed in the same milestone: the AST guard's ancestor-fill propagation didn't clear when an intervening element supplied its own opaque background, so a close-button icon shielded by its own bg-black/30 button (nested two levels under an unrelated bg-gradient-primary header) false-positived, and a z-order scrim sibling (not an ancestor — invisible to a tree-walk model by construction) still does and is documented as a permanent, structural non-fire rather than a deferral. ionic_frontend/tests/unit/primaryInkGuard.spec.ts's own header comment carries the full mechanism and the seeded fixtures for both cases.

The shared-ink ruling — the standing answer for "one ink over N unrelated gradients," so the next multi-gradient component doesn't re-litigate it. The eight v4.33 Phase 3 sites split into two families, each a single hardcoded text-white sitting over several gradients that are not all anchored to --primary — seven category-cover gradients (TemplateCard.vue) and five team tone-chip gradients (SubCommunitySwitcher.vue and four siblings), each family sharing one ink across gradients built from --accent, --gold, --success, --destructive, and --coach as well as --primary. Do not assume the adaptive token alone is the answer just because it worked for the flat-fill and --gradient-primary cases — measuring all twelve gradients against every candidate ink found that no single ink cleared 4.5 on 9 of the 12, because 9 of them were composed at the utility layer from fill tokens that never declared an ink and cannot carry one at any fixed value. The ruling that met the bar covered fill and ink together: each of the twelve became its own named, mode-matched CSS token pair (--gradient-cover-* × 7, --gradient-tone-* × 5, design_system/css/tokens.css, :root + html.dark), stops moved on lightness only — hue and saturation byte-identical to the pre-fix utility combos on all 24 stops, so wayfinding is unaffected — travelled just far enough that each gradient's own worst stop clears 4.5 against --primary-foreground's value in that mode. text-primary-foreground remains the ink on all twelve, because every one of the 24 stops was solved against --primary-foreground's own light and dark values — the pairing is real, not coincidental. No second ink token was introduced (--gradient-foreground was considered and rejected): a byte-identical sibling ink class would hand a future author two correct-looking answers to the same question, which is the exact drift this milestone exists to close. Both ?? 'bg-gradient-primary' fallback branches (TemplateCard.vue, and each of the five toneClass() copies) now alias var(--gradient-primary) through --gradient-cover-learning/--gradient-tone-users rather than reaching past the family into the brand gradient directly — same rendered pixels, but the next re-anchoring of --gradient-primary no longer silently redefines an unknown-category cover with nobody reviewing it as a cover decision. Full derivation, all 24 measurements, and the two named brand-brightness costs (deep warm hues reading as amber/bronze in light mode; two adjacent-hue category covers compressed further, pre-existing, follow-up OBJ-1755) are in .planning/phases/v4.33-phase-3-shared-ink-design/UI-SPEC.md.

One more note while you're reading this section

app.scss's html.dark .app-background rule consumes var(--gradient-primary) and has zero consumers anywhere in ionic_frontend/src — dead CSS, flagged during Phase 3, deliberately left untouched (surgical scope). Not yours to delete on sight; worth knowing so you don't count it as live usage evidence.

Guards

ionic_frontend/tests/unit/gradientPrimaryGuard.spec.ts and ionic_frontend/tests/e2e/specs/accessibility/gradient-runtime-verification.cy.ts are this family's backstop for the token layer — full description under "The mechanical backstop" below, including three traps worth knowing before writing another axe-in-Ionic spec. The consumer-layer backstop for the primary-ink rule above is ionic_frontend/tests/unit/primaryInkGuard.spec.ts and admin_dashboard/tests/unit/primaryInkGuard.spec.ts — a static AST source scan, not a contrast check (the adaptive token inherits the token-layer guarantee transitively, so no new contrast math was needed here). As of v4.33 Phase 3, ionic_frontend's allowlist holds exactly 2 permanent structural exclusions (a mutually-exclusive :class branch on RoadmapStone.vue, and the CommunityPreviewModal.vue scrim non-fire described above) and admin_dashboard's holds 0 — zero "deferred, flagged for Orion" entries in either. An allowlist entry that isn't a permanent structural exclusion is a defect the guard is choosing to lie about; don't add one without the same scrutiny the two existing entries got.

Two independent fixes, one reconciliation

v4.29 and a separately-numbered milestone, v4.26 — Light-Mode Contrast Floor (OBJ-1958), independently fixed the same --primary/--accent foreground defect on the same files, from branches that forked two hours apart. v4.26 reached master first. v4.29 reconciled onto v4.26's values rather than shipping its own (Josh's call) — both approaches passed AA, but master's v4.26 set was strictly more complete: it already carried --accent-accessible, --primary-ink/--primary-ink-foreground, and --primary-interactive in all 9 dark blocks, none of which v4.29's own branch had. Reconciling was a value merge, not a re-derivation; see the milestone ROADMAP's course-correction entry (2026-08-02) for the full mechanism and the two-rules lesson it produced (read the milestone index from origin/master, never a working branch; the index records what has shipped, not what's in flight).

Practical consequence for the next theme author: the numeric values in this file's source (tokens.css, supporter-themes.css) are the ones that matter. Don't trust a specific HSL value cited in a ROADMAP, PLAN, or PR description without re-reading the live file — this milestone is the concrete example of why.

The mechanical backstop

ionic_frontend/tests/unit/themeContrast.spec.ts is the regression guard for the 40-pair contract (--primary-foreground vs --primary, --accent-foreground vs --accent, × 10 themes × 2 modes) plus a dedicated --primary-interactive guard (declaration count + contrast, light-only by design). It parses the theme CSS directly — no hardcoded token copies, so a newly added theme block is picked up automatically — and imports contrastRatio from @/lib/customTheme rather than re-implementing the math. It was demonstrated failing on the pre-fix tree (31 declared pairs / 17 failing) before it was trusted as a guard; that "must fail on the pre-fix tree" bar is what makes it worth anything.

ionic_frontend/tests/unit/tokenDrift.spec.ts is the older, broader guard: it keeps tokens.css, marketing_landing/css/landing-2026.css, and design_system/src/tokens/colors.ts in sync (the three-way token-duplication invariant), and it's where --primary-accessible/--accent-accessible's composited binding-surface-set predicate lives (widened from a flat --card/--background pair for --primary-accessible by OBJ-2425 — see "The composited-tint predicate gap" above). During v4.29's reconciliation merge, this guard caught two real regressions a conflict-free git merge had silently introduced — a stale --primary left behind in landing-2026.css and colors.ts (because master never touched those exact lines, so git saw no conflict) and an invalid ink exception in customTheme.test.ts. Neither was visible in the diff. That's the argument for having built these guards at all: master had no static contrast guard before this milestone, and a clean merge is not proof of a correct one.

ionic_frontend/tests/e2e/specs/accessibility/theme-contrast-sweep.cy.ts is the real-browser tier — it drives all 20 html[data-theme] × html.dark permutations, confirms axe's color-contrast rule actually ran (not silently skipped, which is the failure mode that makes an axe-based guard worthless), and separately asserts the resolved hover fill on a real UiButton reaches the theme's own --primary-interactive value — the half a static CSS parser can't see, since it can't evaluate a :hover pseudo-class.

ionic_frontend/tests/unit/gradientPrimaryGuard.spec.ts is the gradient family's static guard (GRAD-GUARD-1, OBJ-2248, v4.31 Phase 2) — it asserts every one of the 20 html-level blocks declares all three gradient-family tokens (60 declarations total) and that each block's --gradient-primary clears the worst-stop ≥4.5:1 predicate against that block's own --primary-foreground, plus the equivalent for deriveCustomTheme()'s emitted light/dark blocks. It discovers theme slugs from the CSS the same way themeContrast.spec.ts does, and was demonstrated failing on the pre-fix tree (26 of 48 declarations/checks failing) before being trusted. One deliberate exception to "no test file needs editing": its declaration-count assertions (gradientPrimaryGuard.spec.ts:108-116,136-142) hardcode the aggregate totals (60 declarations, 20 blocks) alongside the per-block assertions that are discovered dynamically — an 11th supporter theme is picked up automatically by the per-block checks but fails those two aggregate lines until someone bumps the numbers. That's deliberate belt-and-braces (Roy reviewed and kept it), and it fails loudly with a clear diff, never silently — but bump those two numbers when you add a theme.

Same file, extended to the accent family (OBJ-2257) — see "The accent contract" above for the full guard sentence. --accent-light/--gradient-accent presence over the same 20 blocks (40 declarations), --gradient-accent's worst-stop against --accent-foreground, plus deriveCustomTheme() sampled picks across both modes including the previously-broken white-ink branch. Demonstrated failing on the pre-fix tree (28 failures, including Midnight's exact 3.64) before being trusted, same bar as the primary guard above.

ionic_frontend/tests/e2e/specs/accessibility/gradient-runtime-verification.cy.ts is the gradient family's real-browser tier (GRAD-E2E-1, OBJ-2250, v4.31 Phase 4) — it drives all 20 permutations against a real bg-gradient-primary text-primary-foreground surface and, in dark mode, asserts the resolved --gradient-primary value equals that theme's own dark-block value (never an inequality against :root — see "The general rule" above for why that would pass vacuously for 9 of 10 themes). Three traps it exists to avoid, all reusable beyond this milestone:

  • The axe silent-skip trap. An element that is never actually scrolled into the visible viewport reports zero applicable color-contrast nodes to axe — a silent skip that reads as a pass, not a failure. Compounding it: Cypress's own .scrollIntoView() does not cross ion-content's shadow-root scroll boundary, so the obvious fix doesn't work — a native $el[0].scrollIntoView() call does. Defend against this by asserting the rule id actually appears in the union of passes / incomplete / violations before trusting a zero-violation result. Any future axe-on-Ionic spec hits this.
  • The .transitioning read window. useTheme.ts applies a .transitioning class that animates background-color/color for 300ms on every theme apply, including initial boot. Any computed-style or contrast read taken inside that window is mid-animation and wrong — wait for the class to drop first. Not gradient-specific; applies to any test or tool reading resolved colors after a theme change.
  • Axe reports incomplete, not pass, on a CSS-gradient background, because it can't auto-resolve one. A green axe run on a gradient surface is not evidence the gradient itself clears AA — what actually proves that is the resolved-value equality assertion (this spec) plus the worst-stop guard (gradientPrimaryGuard.spec.ts). Reading an axe pass as gradient-contrast coverage is exactly the wrong conclusion, and it's the same species of mistake this whole milestone exists to prevent.

This runtime tier stayed primary-only — deliberately, per OBJ-2257. Extending it needs a text-bearing bg-gradient-accent surface to assert against, and DesignSystem.vue doesn't have one; authoring one first would be more work than the two-cell fix it would verify. The static worst-stop guard above is what covers the accent family's contrast; recorded as a conscious gap, not an oversight.

ionic_frontend/tests/unit/primaryInkGuard.spec.ts (with a back-ported, simpler twin at admin_dashboard/tests/unit/primaryInkGuard.spec.ts) is the primary-ink rule's static guard (v4.33). Unlike every guard above, it does no contrast math at all — it's a pure AST source scan asserting the adaptive token is used, and it inherits the token-layer contrast guarantee transitively from themeContrast.spec.ts/gradientPrimaryGuard.spec.ts rather than re-proving it. It parses each .vue file's template with vue/compiler-sfc and walks the element tree so a fill on a parent and hardcoded text-white ink on a descendant is one finding, propagating "has an ancestor fill" down through descendants — except that an intervening element with its own opaque background passes only its own fill status to its descendants, breaking the propagation from further up the tree, since an opaque ancestor genuinely severs the visual pairing between a further-up fill and ink underneath it. See "The primary-ink rule" above for the full mechanism and its two known blind spots: parent/child pairs were the reason v4.33 Phase 1's regex guard couldn't be ported to ionic_frontend verbatim, and non-literal :class bindings — computed properties, function calls, map lookups — are still invisible to this AST guard today, exactly as they were the reason v4.33 Phase 3's 8 design-ruling sites went undetected until a later, differently-shaped audit found them. Both apps' allowlists are down to permanent structural exclusions only (2 in ionic, 0 in admin) — see "Guards" above.

Adding a new theme? themeContrast.spec.ts, tokenDrift.spec.ts's Rule 4, theme-contrast-sweep.cy.ts, and gradient-runtime-verification.cy.ts all pick it up automatically as long as it follows the existing html[data-theme="…"] / html.dark[data-theme="…"] selector shape in supporter-themes.css — no test file needs editing. gradientPrimaryGuard.spec.ts is the one exception: bump its two hardcoded aggregate-count assertions (see above). Run themeContrast.spec.ts and gradientPrimaryGuard.spec.ts before you claim it passes AA; a green Storybook render or a screenshot proves nothing about contrast (jest-axe under jsdom can't resolve hsl(var(--token)) at all, so an axe unit test is silently blind to every token-driven color in this codebase). Adding a new --primary-family consuming site? Run primaryInkGuard.spec.ts in both apps — it's the one guard here that checks the consumer, not the token.

The ink-safe tint scale (v4.42, OBJ-1986)

A different contract from everything above: not one hue family's foreground pairing, but a named set of tint strengths that every same-hue-tint-plus-ink call site can reach for instead of hand-deriving its own alpha. See Accessibility Standards § App-Side AA-Violation Ledger, OBJ-1986 for the milestone writeup and DESIGN.md § Accessibility hard rules — v4.42 Governed Tint Scale for the full ruling; this section covers only the mechanics — how the scale is declared, how it resolves per hue, and how a call site actually uses it.

Declaration

The scale is 42 real CSS custom properties in design_system/css/tokens.css:288-343 (:root only — no dark/theme override, see "Resolution per hue" below), one block of three per SHIPPED_CEILING hue (the 14 hues with a measured AA tint ceiling, tracked in ionic_frontend/tests/unit/helpers/tintInkContrast.ts). Each is named --ink-safe-<hue>-<level> and holds a complete value, not a bare number:

css
--ink-safe-primary-wash: hsl(var(--primary) / 0.05);
--ink-safe-primary-fill: hsl(var(--primary) / 0.07);
--ink-safe-primary-max:  hsl(var(--primary) / 0.10);

Resolution per hue

Three levels, each a fixed proportion of that hue's own SHIPPED_CEILING, half-up to two decimal places:

LevelResolves atMeans
wash0.5 × ceilingA region tint — the surface still reads as the surface.
fill0.7 × ceilingA component body — chips, badges, pills, icon plates.
max1.0 × ceilingThe strongest tint this hue can carry with its own ink on top.

Because the resolution is a fraction of each hue's own ceiling, not a shared alpha ladder, two hues at the same level land at very different numbers — --ink-safe-primary-max is 0.10, --ink-safe-rarity-legendary-max is 0.39. Both resolve through var(--<hue>), so a level tracks whatever that hue resolves to in every theme and mode automatically. That's also why there's no html.dark override and no Supporter-theme override for any --ink-safe- token (tokens.css's html.dark block and supporter-themes.css are both asserted clean of the prefix) — overriding one would fight the hue variable it already resolves through.

tintInkContrastMatrix.spec.ts asserts this mechanically, live off the token file (never a hand-copied ceiling table): every one of the 42 alphas is re-derived as r2(fraction × SHIPPED_CEILING[hue]) and compared against what's actually parsed out of tokens.css, all three levels per hue are distinct, and every level clears AA text contrast in every theme × mode for both binding surfaces (--card, --background). A separate assertion walks upward from each hue's max in 0.01 steps and confirms some context fails AA before reaching alpha 1.0 — proof the max rung is a real ceiling, not slack that would let the "clears AA" assertion pass vacuously.

Using a level at a call site

A migrated call site reads the token directly, as a flat fill:

css
/* ionic_frontend/src/components/achievements/AchievementFilters.vue */
background: var(--ink-safe-primary-max);

or as one stop inside a gradient — the scale doesn't require a flat fill:

css
/* ionic_frontend/src/components/dashboard/CoachDockCard.vue */
background: linear-gradient(135deg, var(--ink-safe-coach-wash), hsl(var(--coach-light) / 0.05));

ionic_frontend/tests/unit/helpers/tintInkTemplate.ts's inkSafeAliasMatch() (flat) and gradientTintStops() (gradient stop) resolve the var(--ink-safe-<hue>-<level>) spelling back to { hue, alpha } through INK_SAFE_LEVELS — a table tintInkContrast.ts parses straight out of tokens.css, so the scanner and the matrix spec both read the same 42 values from one source, never a hand-copied second list. That's what lets the ink-safe guard and the raw-alpha guard treat a migrated site and an as-yet-unmigrated hsl(var(--hue) / 0.10) site as equivalent for contrast purposes — the alias is a spelling, not a different measurement.

When the scale doesn't offer the value you need

The scale is ink-safe only, and it's deliberately narrower than every same-hue tint in the app. Two censuses are in play here, and they must not be merged into one narrowing chain. Property-agnostic — every hsl(var(--<hue>) / α) occurrence, whatever property it sits in — the tree carries 401 occurrences across 12 hues: 168 at or below their hue's ceiling, 233 above it; that 233 is what a plain alpha-vs-ceiling grep turns up. Role-scoped, through the guard's own property gate, the domain is far narrower: only 146 of those 401 sit in a background role at all (the rest are border/box-shadow/filter: drop-shadow(...) — never a tint under ink, excluded by property), and of those 146, only 109 sit at or below their hue's ceiling. The 37 above-ceiling background-role occurrences — decorative fills, gradients, and scrims running to 0.85–0.9 — are not migrated, not swept, and not deprecated, under either census. None is a defect, and forcing them onto the scale would change rendered output for zero accessibility gain. There is no level above max; naming one would assert an ink-safety claim nothing has measured. A reader who greps the tree for above-ceiling raw CSS will find 233, not 37 — that property-agnostic count includes every border/box-shadow/filter site too; only 37 sit in a background role at all, which is what this scale actually governs.

Below max, a bespoke alpha that doesn't land on wash/fill/max is legal and stays legal — the guard does not require a background-role alpha to be on the scale, only that an exact match to a resolved level be spelled as the token rather than re-typed as a raw literal (ionic_frontend/tests/unit/helpers/tintInkTemplate.ts's scanTokenNotationViolations() — a pure notation rule, never fires on a value between rungs, needs no allowlist). Roughly 59 legal bespoke values exist between rungs or below wash by design; rarity tiers in particular encode rank rather than tint weight and read low on purpose.

If the hue you need isn't one of the 14 SHIPPED_CEILING hues at all, the scale has nothing to offer it — that hue either has a written exclusion in NO_CEILING_NEEDED (no -accessible ink token, so ink-safety doesn't apply) or needs its own ceiling measured before a scale entry could exist for it. That's a design-system change, not a call-site workaround.

Last updated: 2026-08-24 (v4.42 — Tint Alpha Governance, OBJ-1986 census-scope correction) · Version: v4.6.1

Loading…