Skip to content

Accessibility Standards

Objectuve targets WCAG 2.1 AA compliance across all user-facing views — the ionic_frontend/ app and the public marketing_landing/ site. Accessibility is a core quality bar, not an afterthought — as a PBC mission-aligned empowerment tool, the app must be usable by everyone.

Target Standard

CriterionRequirement
Color contrast (text)4.5:1 minimum (WCAG AA)
Color contrast (large text)3:1 minimum
Tap targets44px minimum (both dimensions)
Focus indicatorsVisible on all interactive elements
Screen readersCore flows completable via VoiceOver and TalkBack
Keyboard navigationAll interactive elements reachable via Tab
Reduced motionAll animations respect prefers-reduced-motion

Composables

All accessibility utilities live in ionic_frontend/src/composables/useAccessibility.ts.

useFocusTrap(containerRef)

Traps keyboard focus within a container (modals, dialogs). Call activate() when the modal opens and deactivate() when it closes. Focus returns to the trigger element on deactivate.

typescript
import { ref } from 'vue'
import { useFocusTrap } from '@/composables/useAccessibility'

const modalRef = ref<HTMLElement | null>(null)
const { activate, deactivate } = useFocusTrap(modalRef)

// On modal open:
activate()

// On modal close:
deactivate()

useAnnounce()

Announces messages to screen readers via an aria-live region. Use for dynamic content updates (XP earned, badge unlocked, toast messages).

typescript
import { useAnnounce } from '@/composables/useAccessibility'

const { announce } = useAnnounce()
announce('Badge unlocked: First Steps')
announce('Error: could not save goal', 'assertive')

useReducedMotion()

Returns a reactive boolean reflecting the user's prefers-reduced-motion setting. Use to conditionally disable or simplify animations.

typescript
import { useReducedMotion } from '@/composables/useAccessibility'

const { reducedMotion } = useReducedMotion()
// In template: v-if="!reducedMotion" on animated elements

Programmatically focuses the main content area. Used by the global skip-to-main link in App.vue.

Below 768px, BottomTabBar (role="navigation" aria-label="Main navigation") is the only primary-nav landmark. SideMenu's rail is desktop chrome: v-if="showMenu" on showMenu = computed(() => !route.meta.public && !isMobile.value) (SideMenu.vue:327) unmounts it entirely below 768px — not aria-hidden, not inert — so no rail control is in the DOM, the a11y tree, or the Tab order on a phone (OBJ-2502). AppHeader no longer exists at any width; the role="banner" landmark is gone below 768px by design — WCAG has no banner-landmark success criterion, and BottomTabBar's navigation landmark is unaffected.

At 768px and up, SideMenu renders with a static role="banner" on its .nav-header (SideMenu.vue:13) and carries two destinations with no phone-width equivalent: Help & Guide and the admin-only Admin Dashboard link. Both are reachable at every width via Settings instead (SettingsHub.vue's help-guide and admin-dashboard rows, OBJ-2502 Task 1), so a phone user loses the rail, not the destinations.

useMenuState's isExpanded (persisted to localStorage as sideMenuExpanded) only sizes the desktop rail (280px expanded vs. 80px collapsed, the menuWidth computed). It has no phone-width effect — the rail doesn't render below 768px at all, so there's no icon-only-drawer state to account for there.

Component Patterns

Buttons

All buttons must have accessible text — either visible text content or aria-label for icon-only buttons.

html
<!-- Good: visible text -->
<UiButton>Save Goal</UiButton>

<!-- Good: icon-only with aria-label -->
<button aria-label="Edit goal">
  <AppIcon name="pencil" />
</button>

<!-- Bad: no accessible text -->
<button><AppIcon name="pencil" /></button>

Header icon buttons — focus-visible recipe

Page-header icon buttons (back button, theme toggle, refresh, share, etc.) must render a visible focus ring on keyboard focus. Two patterns are in use:

Pattern 1: Scoped CSS for ion-button elements

In your component's scoped style block, add a :focus-visible rule:

html
<template>
  <ion-button fill="clear" aria-label="Go back">
    <AppIcon name="arrow-left" slot="start" />
  </ion-button>
</template>

<style scoped>
ion-button:focus-visible {
  outline: 2px solid hsl(var(--ring));
  outline-offset: 2px;
}
</style>

Pattern 2: Tailwind utility classes for plain <button> elements

For <button> elements outside Ionic's component system, apply the Tailwind classes directly:

html
<button 
  aria-label="Refresh feed"
  class="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
  <AppIcon name="refresh-cw" />
</button>

Design tokens: Both patterns use --ring (a CSS custom property aliasing the design system's focus-ring color), ensuring light/dark mode consistency.

Gotchas:

  • ion-menu-button (Ionic native): Has native focus management; do NOT apply these classes.
  • div[role="button"]: If you use a div with role="button" and tabindex="0" (e.g., SideMenu's .logo-section, the branding/home affordance at every width since AppHeader was removed below 768px per OBJ-2502), add the same :focus-visible CSS rule to make it keyboard-accessible.

Form Fields

Associate labels with inputs. Use aria-invalid and aria-describedby for error states.

html
<label :for="fieldId">Goal name</label>
<UiInput
  :id="fieldId"
  :aria-invalid="!!error"
  :aria-describedby="error ? errorId : undefined"
  aria-required="true"
/>
<span v-if="error" :id="errorId" role="alert">{{ error }}</span>

Modals

All modals must:

  • Have role="dialog" and aria-modal="true" (IonModal provides this)
  • Have aria-labelledby pointing to the modal title
  • Trap focus with useFocusTrap
  • Close on Escape key
  • Return focus to the trigger element on close

Images

  • Meaningful images: Provide descriptive alt text
  • Decorative images: Use alt=""
  • User-uploaded images: Use context-based alt (e.g., "Check-in photo for Learn Rust")

Dynamic Content

Use aria-live regions for content that updates without user action:

  • aria-live="polite" — new feed items, loading completion
  • aria-live="assertive" — errors, urgent alerts
  • aria-busy="true" — while content is loading

Game Canvas

The Critical Path puzzle canvas uses role="application" to signal custom keyboard semantics (not a standard grid or list). Tab navigation cycles through valid next nodes only; already-tapped nodes are removed from the Tab cycle (tabindex="-1"). Blocked nodes (prerequisites not yet met) remain Tab-reachable but are marked aria-disabled="true" and announce an error message on Enter/Space rather than firing a tap.

Keyboard navigation:

  • Tab — cycles through all reachable nodes (tabindex="0")
  • Enter/Space on a valid node — fires the tap handler
  • Enter/Space on a blocked node — announces "Not quite — check the connections."
  • After a valid keyboard tap — focus moves to the next reachable node via nextTick

Screen reader announcements:

  • On valid tap: "Node [label]. [N] of [total]." at polite priority
  • On game won: "Path mapped. [N] nodes. [mm:ss]." at polite priority
  • On invalid tap: existing assertive live region toast announces the error

Implementation: See PipesBoard.vue for the 7-node state catalog (default, blocked, tapped-past, tapped-active, invalid-flash, complete). The useFocusTrap and useAnnounce composables from ionic_frontend/src/composables/useAccessibility.ts handle focus management and announcements. Both HowToPlayOverlay.vue and CompletionModal.vue trap focus during open; Escape dismisses both. See .planning/phases/obj-293-a11y-critical-path/UI-SPEC.md for the full design contract.

Roving-tabindex listboxes

A role="listbox" with role="option" children must expose exactly one tab stop, with arrow keys moving focus between options — the WAI-ARIA APG listbox pattern. Giving every option its own tabindex (or letting native <button> tabbability leak through unmanaged) is the most common way this gets built wrong: it produces N tab stops and zero arrow-key navigation, which is what shipped originally in GoalForm.vue's app-link preset picker before OBJ-2426 fixed it.

Reference implementation: GoalForm.vue's app-link preset picker (:421-463 template, :772-830 script).

The contract:

  • One activeIndex ref tracks which option is tabbable: :tabindex="index === activeIndex ? 0 : -1".
  • ArrowRight/ArrowDown moves to the next option, ArrowLeft/ArrowUp to the previous, both wrapping at the ends. Home/End jump to the first/last option. All six keys call preventDefault().
  • Moving focus calls .focus() on the target option and scrollIntoView({ block: 'nearest', inline: 'nearest' }) if the listbox scrolls — block: 'nearest' matters inside Ionic; other values can yank the page's own scroll position.
  • role="listbox" / role="option" / aria-selected bindings are unaffected by this pattern — it only changes how focus and tabindex are managed.

The activeIndex-not-derived-from-selection trap. The naive implementation derives tabindex straight from selection state — :tabindex="isSelected(option) ? 0 : -1". That's wrong whenever selection can be empty: with nothing selected, every option gets tabindex="-1" and the listbox becomes completely keyboard-unreachable, which is worse than the ten-tab-stops bug it was meant to fix. activeIndex must be stored state, initialized to the selected option's index when one exists and to 0 otherwise, and kept in sync with selection via a watch(..., { immediate: true }) (not a one-shot onMounted) so it's also correct when a component loads with a pre-existing selection, not just on fresh creation.

Arrow keys move focus only — they do not always select. CoachFrequencyCardGrid.vue:31-38 and MoodSelector.vue:59-78 both select on arrow (emit('update:modelValue', ...)/emit('select', ...) fires directly from the keydown handler), and that's the right default when the options are self-contained. GoalForm.vue's picker diverges deliberately: selecting a preset overwrites form.appLink, a shared, user-editable text input. Selection-follows-focus there would mean a user who pasted their own link loses it the moment they arrow across the tiles to look at the presets. So arrow/Home/End move activeIndex and call .focus() only; Enter/Space commits the selection, identical to a click. Copy the select-on-arrow precedent by default; diverge to commit-on-Enter/Space only when moving focus can itself cause a destructive side effect (overwriting user input, firing a mutation, etc.) — and say so in a comment near the handler, the way GoalForm.vue:767-771 does, so the next contributor doesn't assume the wrong precedent applies.

Disclosure panels (collapsible nav groups)

A collapsible section's header must be a real <button> carrying aria-expanded (reflecting open/closed) and aria-controls (pointing at the panel's id); the panel itself must not merely be visually hidden — a 0-height/overflow: hidden panel still leaves its links in the Tab order unless it also carries the inert attribute when collapsed. Verified by tabbing through a closed panel before and after adding inert: without it, Tab walks through every link inside the closed panel before reaching the next real control; with it, Tab skips straight past.

html
<button
  type="button"
  :aria-expanded="!collapsed"
  aria-controls="nav-group-rail-system"
  @click="toggle('system')"
>
  System
</button>
<div id="nav-group-rail-system" :inert="collapsed || undefined">
  <!-- panel items -->
</div>

Namespace panel ids per rendering. If a component renders more than one instance of the same logical panel in one DOM at once (e.g. a desktop rail and a mobile drawer both present, toggled by CSS breakpoint rather than v-if), a bare id="panel-system" collides and every aria-controls reference resolves to whichever instance is first in the DOM — not necessarily the one the user is looking at. Suffix the id with the rendering it belongs to (e.g. nav-group-<rail|drawer>-<groupId>).

Icon-only collapsed states still need an accessible name. A rail item that drops its visible text label when a sidebar collapses to icon-only still needs title and aria-label on the link (or an sr-only span) — the icon alone isn't one. When the item also carries a pending-count badge, fold the count into that same accessible name (e.g. "Moderation Queue, 12 pending") rather than only into the visual badge, since a screen-reader user collapsing past the group header would otherwise never hear it.

Reference implementation: admin_dashboard/src/components/nav/NavGroup.vue and admin_dashboard/src/layouts/AdminLayout.vue (OBJ-3393) — the collapsible admin sidebar's group headers, inert collapsed panels, namespaced nav-group-<rail|drawer>-<groupId> ids, and rail-collapsed icon accessible names.

Testing

ESLint

The eslint-plugin-vuejs-accessibility plugin catches common a11y issues at lint time:

bash
cd ionic_frontend && npm run lint

Vitest (Component-Level)

Use the expectNoA11yViolations helper for component tests:

typescript
import { mount } from '@vue/test-utils'
import { expectNoA11yViolations } from '@tests/unit/helpers/a11y'
import MyComponent from '@/components/MyComponent.vue'

it('has no accessibility violations', async () => {
  const wrapper = mount(MyComponent)
  await expectNoA11yViolations(wrapper)
})

Cypress (E2E)

The cypress-axe integration runs axe-core against full pages:

bash
cd ionic_frontend && npx cypress run --spec 'tests/e2e/specs/accessibility/**'

Storybook

The @storybook/addon-a11y panel shows real-time violations for each story. Check it when developing new components.

Manual Testing

  • VoiceOver (macOS/iOS): Cmd+F5 to toggle. Navigate with Tab and arrow keys.
  • TalkBack (Android): Settings > Accessibility > TalkBack. Swipe to navigate.
  • Keyboard only: Tab through all interactive elements. Verify focus indicators are visible.

App-Side AA-Violation Ledger (ionic_frontend/)

Real color-contrast failures found and fixed in the app itself — the ionic_frontend/ counterpart to the marketing-site ledger below. There is no equivalent automated scan for the app; the regression guard is ionic_frontend/tests/unit/tokenDrift.spec.ts, which asserts exact contrast ratios against real composited surfaces using the repo's own contrastRatio/hslToRgb8 math. This matters because expectNoA11yViolations (jest-axe in jsdom) cannot resolve hsl(var(--token))tokens.css is never loaded in that environment — so an axe unit test silently skips the color-contrast rule entirely for token-driven colors. Don't let a green axe unit test stand in as the guard for a token-contrast fix.

OBJ-1951 (resolved, PR #1931): light-mode text on amber fills failed AA across the streak/gamification surface — two violations found by Desi's axe run on OBJ-1948, plus two more found only by re-deriving contrast from the tokens directly, because axe's own run was itself wrong on this surface:

  • 14 × active heatmap cells (StreakDetailsModal.vue cellClasses), bg-warning text-white2.13:1 light, 1.84:1 dark. Axe reported 0 dark violations; dark was actually the worse of the two the whole time, because --warning is lighter in dark mode (38 92% 60%) than in light (38 92% 50%).
  • Rest-day heatmap cells (same file, graceCellClasses), text-warning on bg-warning/10 with a border-warning/60 boundary (1.59:1, non-text, against a 3:1 floor) — never measured before this fix, because no Storybook story passed graceDays and the e2e a11y smoke (tests/e2e/specs/accessibility/a11y-smoke.cy.ts) never opens the modal.
  • Next-rank name (PathToNextBar.vue:70), --streak-accent on the modal's real composited card surface (bg-card/70 over ion-modal's opaque background, not pure white) — 2.59:1.
  • GoalActionBar.vue's Undo button carried the identical bg-warning text-white defect, found while tracing the fix's blast radius rather than by either audit.

Fix: active heatmap cells and the Undo button move to bg-warning text-warning-foreground8.85:1 light / 10.23:1 dark, the design system's own designated pairing, already shipping in CommunityListItem.vue. Rest-day cells move to border-warning-accessible/80 bg-warning/10 text-warning-accessible dark:border-warning/60 dark:text-warning4.65:1 text / 3.51:1 border light, 8.09:1 / 4.19:1 dark. A new --streak-accent-accessible token (27 89% 37% light / 27 89% 55% dark, design_system/css/tokens.css) carries the next-rank name (4.97:1) and the XP chip's light-mode fill (XpChip.vue, solid fill + white ink, 4.97:1 — the chip's light-mode celebration card composites to grey, and no tint at a brand-plausible lightness clears AA there, so this needed a fill change, not just an ink swap). Scope addition: PathToNextBar's label/connector alphas (--muted-foreground/0.7 → full in light, dark connector /0.45/0.55) — raising the rank name to 4.97:1 while the adjacent word "to" sat at 2.60:1 wasn't a real fix. Guarded by 2 new assertions in tokenDrift.spec.ts (§Testing) checking the new token and the rest-cell pair against their real composited surfaces, not pure white, so the pair can't silently drift back.

See DESIGN.md § Palette intent § Amber fills for the lightness rule this locked in, and the [Unreleased] OBJ-1951 entry in CHANGELOG.md for the full fix writeup.

Lesson: this sat in production because two coverage gaps compounded, not one — the e2e a11y smoke only visits routes and never opens modals, so no modal in the app is covered by the browser-based AA gate, and no story passed graceDays, so the rest-cell state was never even rendered for review. A green axe run (unit or e2e) proves nothing for a state it never saw. The new WithRestDays story (shipped by OBJ-1948) and the tokenDrift.spec.ts assertions are the durable guards; treat any future amber/token-driven contrast claim as unverified until it's backed by one of those, not an axe pass.

OBJ-1962 (resolved, PR #1989, v4.26 Phase 4 — Light-Mode Contrast Floor): the streak celebration card (CelebrationBalanced.vue/CelebrationCinematic.vue) is a translucent glass card over StreakCelebration.vue's bg-black/70/dark:bg-black/80 scrim — a card whose rendered color depends on whatever the user happens to be looking at behind the scrim. Measured against the real composite (page → scrim → card → ink), the secondary label failed AA at every backdrop tried: 3.24:1–3.47:1 (a white sheet, --background, a mid-grey photo, and a dark hero image all measured within that range), against a 4.5:1 floor. Two more strings failed independently: the dismiss hint (2.62:1 dark) and, most severely, the XP chip — a Phase 5 --accent-foreground repoint (27 89% 10% on bg-accent, correct for bg-accent fills) had silently landed on the XP chip's different fill (--streak-accent-accessible, which ships with no declared ink of its own), dropping its dark-mode ink to 1.07:1 — effectively invisible text, and the same failure OBJ-1951 fixed, re-opened from a different direction six days later.

Fix: the card surface goes opaque — hsl(var(--card)) in both modes, no backdrop-filter, no rgba() gradient — following the precedent FeatureTour.vue already set for this exact pattern (§ Tour overlay, ionic_frontend/STYLE_GUIDE.md) rather than re-discovering it. Every secondary string moves off token / <alpha> onto a flat --muted-foreground (6.10:1 light / 6.97:1 dark). A new token, --streak-accent-accessible-foreground (white, fixed, not themeable, design_system/css/tokens.css), pairs explicitly with --streak-accent-accessible so the XP chip's fill and ink can't drift apart again (5.43:1 light flat pair; the dark chip's fill is a translucent --streak-accent gradient over the dark card, not the solid accessible token, so its worst-case 0.10-alpha stop measures 15.6:1 — never actually the risk in dark). The hero streak number flattens to solid --streak-accent-accessible in light (5.43:1 against the 3:1 large-text floor); dark keeps its unchanged --streak-accent → --gold gradient. StreakCelebration.vue itself — including its one documented outline-none allowlist exception at :115 — is untouched; only the two celebration-card components and CelebrationToast.vue (ink-only, no scrim) changed.

Guarded going forward: 6 new tokenDrift.spec.ts assertions (ionic_frontend/tests/unit/tokenDrift.spec.ts:821) pin the opaque surface (no rgba()/backdropFilter survives), the secondary/primary ink floors, the hero-number large-text floor, and the XP-chip pairing including its dark 0.10-alpha worst-case stop, against the real composited surface. A green axe run does not satisfy this guard — the same OBJ-1951 lesson, with fresh evidence: Desi's own axe-core run over the shipped (pre-fix) card reported 1 violation in light and 0 in dark, against 5 light and 2 dark failures measured from source on that identical rendered card. axe can't resolve hsl(var(--token)) in jsdom, and the e2e a11y smoke never opens a modal — so for a token-driven modal surface, tokenDrift.spec.ts is the only real gate, not axe in either form.

See DESIGN.md § Palette intent § Accessibility hard rules — v4.26 Light-Mode Contrast Floor (rule 6) for the surface-vs-ink-alpha rule this locked in, and the [Unreleased] OBJ-1955 entry in CHANGELOG.md for the full milestone writeup.

OBJ-2047 (verified-already-fixed, no PR — closed against OBJ-1984, not a new change): filed as an adjacent finding while Desi speced OBJ-2044's hero-accent contrast fix — SupporterHero.vue:71's eyebrow pill (text-accent on bg-accent/10 at the time the issue was written) measured 1.86–3.80:1 against a 4.5:1 floor, and OBJ-2044's then-incoming --accent-accessible token was assumed to only lift it to 3.78–4.19:1, still failing in every theme. Desi speced a fix (a class swap to bg-accent-accessible text-accent-foreground dark:text-card) and Josh signed off on it. Before implementing, Codi re-measured against fresh origin/master (post OBJ-2044 merge, PR #2012) per the mandatory re-check step, and found the gap already closed: SupporterHero.vue:71, PlanTierCard.vue:39, and SupporterUpgradeModal.vue:58 all already ship bg-accent/10 text-accent-accessible, landed earlier by OBJ-1984 (v4.26, 86460a4f1) — a wider, unrelated fix whose --accent-accessible derivation already covered this exact composited tint (see rule 5 below).

Why the two measurements disagreed: the issue's numbers were computed against a flat --card/--background check, the same assumption the corrected DESIGN.md § Amber fills rule warns against. --accent-accessible's real derivation (OBJ-1984) is against the full binding-surface set, including the SupporterHero stacked accent/10-over-accent/5 composite (folded to one effective alpha: 0.145 light / 0.19 dark) — the surface the eyebrow pill and its two siblings actually render on. Measured against that real composite: all 20 cells (10 themes × light/dark) clear 4.5:1, worst light 4.51 (Sunset), worst dark 4.55 (Rose) — reproduced independently by Codi using the same contrastRatio/hslToRgb8/relativeLuminance machinery customTheme.ts uses, and pinned by the pre-existing accent-accessible token layer (v4.26 Phase 6, OBJ-1984) describe block in tokenDrift.spec.ts:593 (372/372 passing on the same run — no new guard added, since none was needed).

No code changed, no implementation branch pushed, no PR opened. Desi's UI-SPEC (.planning/phases/v4.28-obj-2047-eyebrow-pill-contrast/UI-SPEC.md, committed on the design/obj-2047-eyebrow-pill-contrast branch, commit 0a57435db — never merged to master, so the path doesn't exist on this branch; retrievable via git show 0a57435db:.planning/phases/v4.28-obj-2047-eyebrow-pill-contrast/UI-SPEC.md) and its proposed class swap are superseded — applying it on top of an already-passing surface would have been an unrequested visual change to a shipped hero.

See DESIGN.md § Palette intent § Amber fills (corrected rule) and § Accessibility hard rules — v4.26 Light-Mode Contrast Floor (rule 5) for the accent-on-accent-tint rule this confirms, and the [Unreleased] OBJ-2047 entry in CHANGELOG.md for the verification writeup.

OBJ-2056 (resolved, PR #2274): GoalForm.vue:756's four-preset app-link tile picker (tileTintClasses — Duolingo/Chess.com/Strava/Peloton monogram tiles) carries the same same-hue-ink-on-same-hue-tint shape OBJ-2047 investigated, in three hue families inside one array: bg-primary/10 text-primary, bg-accent/10 text-accent (already text-accent-accessible from the OBJ-1984 sweep, unaffected by this ticket), and bg-success/10 text-success. Measured against the tile's real composited backdrop — GoalForm only mounts inside <ion-modal class="app-modal">, which never sets --background, so Ionic's opaque default (#ffffff light / #0B0E17 dark) applies; the component's bg-card/70 backdrop-blur-xl wrapper composites to byte-identical --card in light and a few channels darker in dark, not a blur of arbitrary page content as originally assumed — 18 of 80 cells (10 themes × 2 modes × 4 presets) failed: primary in 8 of 10 light themes, success in all 10 light themes, dark passing everywhere.

Fix: ink-only swap, text-primarytext-primary-accessible and text-successtext-success-accessible, on the existing tint — zero new tokens (both already shipped in design_system/css/tokens.css), zero visual change to any tile fill. Worst cell after the fix: 4.52:1 (Rose, light, primary). The opaque bg-<hue>-accessible fill pattern OBJ-2047 speced for the Supporter hero was measured against this tile too and rejected — it fails 15 of 80 cells here, bottoming out at 2.98:1 — so this is a narrower, different fix for a component with a different backdrop, not that pattern shipping late.

Corrects the original OBJ-2056 task package's assumption about OBJ-2047. The package instructed recording GoalForm.vue:756 as fixed on the premise that OBJ-2047 had shipped the opaque-fill pattern described above. It hadn't — OBJ-2047 closed verification-only (see above), no code changed. GoalForm.vue:756 is fixed here, by this ticket's ink-swap, independently of OBJ-2047.

Known gap surfaced, not closed here: --primary-accessible's "AA-checked against the bg-primary/10 chip" claim (DESIGN.md rule 4) holds for the ten hand-authored preset values this array consumes, not for deriveCustomTheme()'s Theme-Creator output — see the rule 4 correction below. Two more findings routed out rather than fixed here: the tile picker's role="listbox" should be one tab stop with roving tabindex/arrow-key navigation, not ten independent tab stops (OBJ-2426); and the ~30-site systemic same-hue-ink-on-tint sweep flagged on OBJ-1951, OBJ-2047, and this ticket is now filed as its own remediation epic (OBJ-2427) instead of being rediscovered a fourth time. The --primary-accessible derivation gap itself is OBJ-2425.

See DESIGN.md § Palette intent § Accessibility hard rules — v4.26 Light-Mode Contrast Floor (rule 4, correction) for the --primary-accessible derivation-gap writeup, and the [Unreleased] OBJ-2056 entry in CHANGELOG.md for the fix writeup.

OBJ-2055 (resolved via reconciliation with v4.26/OBJ-1958, PR #2035 the promoting merge): the app-wide --primary/--primary-foreground and --accent/--accent-foreground pair — 7 of 10 themes failed AA on the primary CTA in light mode (Ocean worst, 2.69:1), 9 of 10 failed on accent in both modes. Root cause: every light theme block hardcoded flat white foreground while every dark block correctly derived same-hue near-black ink, and no static or runtime guard existed to catch it. Three themes (Rose, Ember, Default) sat in a genuine contrast dead zone — no foreground at any lightness reached 4.5:1 against their authored primary — so a foreground-only swap couldn't fix all ten. A second, independent milestone (v4.26, OBJ-1958) fixed the same defect on the same files from a branch that forked two hours earlier and reached master first; v4.29 reconciled onto v4.26's values rather than shipping its own. Full token contract, the dead-zone finding (with Rose/Ember/Default as the worked example, both escape routes documented), the dark-mode accent cascade, the --primary-interactive/deriveCustomTheme() fallthrough trap, and the known gradient-family gap (OBJ-2075) are documented in docs/development/design-tokens.md — not duplicated here.

Fix, mechanically: --primary/--accent and their foregrounds now clear 4.5:1 in all 10 themes × 2 modes (40/40 pairs). Three new token families joined the contract in the same reconciliation: --primary-ink/--primary-ink-foreground (solid ink for glyphs on the primary gradient), --primary-accessible and --accent-accessible (text/ink roles checked against --card/--background rather than against --primary/--accent themselves — --primary-accessible's check was flat at the time, widened to the full composited binding-surface set by OBJ-2425, see below), and --primary-interactive (hover fill, now declared in all 9 Supporter light blocks plus all 9 dark blocks). Three consumer fixes were specified and shipped in PR #2026 (commit 2c3146d58): SuggestedGoalCard.vue's disabled "Adopting…" button moved off bg-primary/60 (a dimmed fill against full-strength ink — a mismatch, not strictly a contrast defect, since WCAG 1.4.3 exempts disabled controls) onto flat bg-primary + disabled:opacity-50, the repo-wide convention; Dashboard.vue's decorative streak-refresh icon moved from text-primary-foreground (accidental token reuse) to text-background; GoalCreate.vue:111-112's camera hover chip moved to an opaque bg-foreground ring-2 ring-background + text-background, photo-independent per Desi's UI-SPEC-IMAGE-INK.md. All three are confirmed live on master today (re-verified directly against source, not against the PR).

Regression found and fixed same day — Goal.vue:79's "Add cover photo" badge briefly lost its scrim. UI-SPEC-IMAGE-INK.md also specified a black/70 scrim (bg-black/70 backdrop-blur-sm text-white, worst-case 11.58:1/12.43:1 across 5 placeholder bands × 10 themes × 2 modes) for this badge, and PR #2026 shipped it. But the Phase 4 reconciliation merge (de08508ec) silently reverted this one element back to its pre-fix classes — bg-card/15 backdrop-blur-sm text-primary-foreground/70, border-primary-foreground/20, hover:bg-card/25 hover:text-primary-foreground, aria-label="Add cover photo to goal" — while correctly keeping the surrounding focus-ring cleanup master's v4.26 already shipped everywhere else in the same file. Two consequences while it was reverted: the badge failed WCAG AA again (back to ~1.06–1.47:1, the audit's original measurement), and the visible label ("Add photo", which did survive the revert) no longer matched its aria-label ("Add cover photo to goal" does not contain "Add photo" as a contiguous substring) — a fresh SC 2.5.3 (Label in Name) violation the original fix didn't have. This is the same "conflict-free but semantically wrong" merge-regression shape the ROADMAP's Phase 4 delivery log already documented twice (landing-2026.css/colors.ts, customTheme.test.ts's lavender exception) — a third instance, undetected at merge time because neither themeContrast.spec.ts nor tokenDrift.spec.ts inspects Goal.vue. Re-applied the same day (OBJ-2086, PR #2041, commit e6a4671ef) — UI-SPEC-IMAGE-INK.md Site 1 verbatim, confirmed live on master: bg-black/70 ... text-white ... border-white/30, aria-label="Add photo to goal".

Guarded going forward: a new themeContrast.spec.ts (ionic_frontend/tests/unit/) is the static regression guard for the 40-pair contract — demonstrated failing on the pre-fix tree (31 declared / 17 failing) before being trusted, per the same "must fail pre-fix" bar tokenDrift.spec.ts's existing assertions follow. tokenDrift.spec.ts gained the --primary-accessible/--accent-accessible cases (their distinct --card/--background predicate) and, during the reconciliation merge, caught two conflict-free-but-semantically-wrong regressions a plain git merge missed entirely: a stale superseded --primary left behind in marketing_landing/css/landing-2026.css and design_system/src/tokens/colors.ts, and an invalid ink exception in customTheme.test.ts. Neither guard covers arbitrary consumer markup like Goal.vue's badge, which is exactly why the third regression above went uncaught — worth naming as a real, current limit of the guard suite, not just a historical note. A new theme-contrast-sweep.cy.ts (ionic_frontend/tests/e2e/specs/accessibility/) drives all 20 real-browser html[data-theme] × html.dark permutations with axe's color-contrast rule confirmed actually running, plus a dedicated assertion that a real UiButton's resolved hover fill reaches the theme's own --primary-interactive value — the class-reaches-the-token check no static parser can perform.

Follow-up (OBJ-2087/OBJ-2241): the "neither guard covers arbitrary consumer markup" gap above is closed by ionic_frontend/tests/unit/consumerContrastGuard.spec.ts — one source-text assertion per v4.29 consumer-layer fix (SuggestedGoalCard.vue, Goal.vue:79, GoalCreate.vue, Dashboard.vue), each anchored to its element and asserting the fix class present / pre-fix class absent. See testing.md § A remediation without an assertion is not remediated for the generalized rule this incident produced.

Known, accepted gap — the gradient family (--gradient-primary/--primary-light/--primary-glow). Measured but deliberately not fixed in this milestone: 26 of 80 measured cells fail, including bg-gradient-primary in 10 of 10 themes in dark mode (no theme declares a dark-mode value, so every theme falls through to :root's undifferentiated light-mode gradient). No ink swap resolves it — the lightness spread between the gradient's two stops is too wide for any single ink color, so the only real fixes are structural (narrower stops, real per-theme dark values, or a dedicated gradient-ink token), which is a token/gradient redesign out of scope here. Tracked as follow-up OBJ-2075. See docs/development/design-tokens.md § "Known gap" for the full measured table.

See docs/development/design-tokens.md for the full token contract and guard reference, and the [Unreleased] OBJ-2055 entry in CHANGELOG.md for the fix writeup.

OBJ-2427 — v4.36 Tint-Ink Contrast Floor (resolved across 5 phases, PRs #2267/#2269/#2275/#2276/#2282/#2290): the same defect — same-hue ink on a same-hue tint (bg-<hue>/N text-<hue>) — had been fixed one site at a time three separate times (OBJ-1951, OBJ-2047 — closed as a no-op, the "locked pattern" it inherited had never actually shipped anywhere — and OBJ-2056), and none of the three measured the whole surface. A full sweep of master @ 76a9f626c found 109 true defects across 66 files in the tint/alpha census alone, plus a structurally separate class of 135 shipped hits (87 ionic_frontend + 10 admin_dashboard files) where --destructive — a fill token whose name reads like an ink token — was used as ink in dark mode, measuring as low as 1.77:1, the most severe defect this milestone's original sweep found.

  • Phase 1 (OBJ-2430, PR #2267): --warning-accessible was itself broken — 4.45:1 as ink on bg-warning/10 over --background, below AA before any alpha increase, so no call-site edit could have fixed it. Darkened 28 92% 36%28 92% 34% in design_system/css/tokens.css.
  • Phase 2 (OBJ-2431, PR #2269): all 135 text-destructive ink sites swapped to text-destructive-accessible (6.0:1 dark / 5.6:1 light — one token, no dark: variant). bg-destructive fills were untouched.
  • Phase 3a (OBJ-2432, PR #2275): --primary-accessible darkened in 7 Supporter theme blocks — the /10 ink swap was measured safe against --card at kickoff but re-measured over --background it failed AA in 5 themes and sat exactly on the floor in a 6th; this closed that gap (worst case post-fix: 4.64:1, rose light).
  • Phase 3b (OBJ-2432, PR #2276): 68 mechanical text-<hue>text-<hue>-accessible swaps across 47 files, generated by a work-order script rather than hand-counted, after the flat "α ≤ 0.10" bucket boundary was found to be wrong (the ceiling is per hue — see DESIGN.md § Palette intent § Accessibility hard rules — v4.36 Tint-Ink Contrast Floor, rule 3) and had left two success sites owned by nobody.
  • Phase 4 (OBJ-2433, PR #2282): 18 sites where no -accessible ink clears AA at any alpha got a deliberate, Desi-specified pattern instead of an alpha drop — HabitCalendar's selected-state affordance moved to an opaque border (5.07:1) + accessible Check glyph (4.32:1) rather than a fill change, after the acceptance criterion's assumed fix shape (a ≥3:1 fill pair) turned out to be unsatisfiable — the current fill pair measures 1.00–1.11:1 at every candidate lightness. See gotchas.md § Acceptance criteria that assume a fix's shape can be unsatisfiable.
  • Phase 5 (OBJ-2434, PR #2290): the durable artifact — ionic_frontend/tests/unit/tintInkContrastMatrix.spec.ts asserts the full (hue, alpha, mode, theme, surface) matrix from live token files, and ionic_frontend/tests/unit/tintInkGuard.spec.ts fails CI on same-hue co-occurrence or an above-ceiling alpha, evaluating every bg-<hue>/N on a line independently (not first-match) and discovering hues from design_system/css/tokens.css rather than a hand list — the hand list is exactly what hid coach/gold from the kickoff census (31 real hits, zero coverage). coach/gold sites are allowlisted with an explicit OBJ-2441 expiry; the 10 C-hover-only sites are flagged as a recorded deferral (hover states are AA-exempt when the rest state passes). ionic_frontend/.mockups/primitives.html's token block is now generated by build-primitives.mjs from themedCssForMockup() instead of hand-copied, closing a gap where it was silently missing --*-accessible, --primary-ink*, --coach*, and every Supporter theme.

Retired — two of the three gaps this note tracked are closed; one is not (v4.42 Phase 6, OBJ-2849). This note originally read "Known gap, not yet shipped (OBJ-2456)" and recorded three scanner blind spots: cross-element tint/ink splitting, a second tint layer stacked on a first, and an integer-only alpha regex (/N, not arbitrary-value /[0.NN]). OBJ-2456 itself closed with none of the three actually shipped, and the 5 defects it allowlisted (worst 1.57:1, PublicProfile.vue:669+:213) sat live behind that allowlist for a full milestone after the issue that promised to fix them was marked done — an open gap note against a closed issue, the exact debt shape v4.42 was chartered to end. v4.42 closed two of the three: cross-element nesting (Phase 2, PR #2563; Phase 3, PR #2573) and the integer-only alpha regex (Phase 5, PR #2576) — between them resolving all 5 defects above; CROSS_ELEMENT_ALLOWLIST is []. The third — a second tint layer stacked on a first — is still open; no phase of this milestone composited that shape, and the guard still does not see it. See DESIGN.md § Palette intent § Accessibility hard rules — v4.36 Tint-Ink Contrast Floor (the identical OBJ-2456 correction) for the full ruling — the two pages must not disagree — and the OBJ-1986 entry below for the milestone that closed the first two.

See DESIGN.md § Palette intent § Accessibility hard rules — v4.36 Tint-Ink Contrast Floor for the five hard rules this locked in (the per-hue ceiling table and the --destructive fill-vs-ink trap in particular), and the [Unreleased] OBJ-2427 entry in CHANGELOG.md for the full milestone writeup.

OBJ-2425 (resolved, PR #2270): --primary-accessible's predicate (above, from OBJ-2055/v4.29) checked flat --card/--background only — never the real composited tint every text-primary-accessible consumer actually renders on. Surfaced by Desi while specing OBJ-2056's GoalForm.vue tile-preset fix: a sweep of the Theme Creator's full reachable input space (36,461 hue × saturation combinations) found the flat check let 65% of light-mode custom themes (worst 3.97:1) and 41% of dark-mode custom themes (worst 4.00:1) ship a --primary-accessible that failed 4.5:1 against the bg-primary/20-over---card tile surface used across 82 anchors. A second, independent hole affected dark mode only: flat --muted is lighter than --card/--background in dark, making it the hardest flat dark surface, and the old predicate never evaluated it at all. 7 of 10 light presets and 5 of 10 dark presets also failed on the same widened set, meaning the hand-authored presets weren't fully safe either, not just Theme-Creator output.

Fix: --accent-accessible's composited-surface mechanism (rule 5 above) generalized into one shared helper covering both tokens, taking an ordered layer-stack shape so a fixed-token composite (--card@.70 → --secondary@.30 → primary@.10) and a non-card/background flat backdrop (--muted) can both be expressed — neither was representable in the old {backdrop, alpha} pair. PRIMARY_ACCESSIBLE_LIGHT_SURFACES (17 entries) / _DARK_SURFACES (19 entries) now gate deriveCustomTheme()'s clamp and 12 preset ink values (7 light + 5 dark), hue/saturation untouched, lightness-only movement, 0 failures across the full 36,461-input space in both modes (median shift 3 light / 6 dark, max 8 light / 13 dark). Two markup re-anchors landed alongside the constant, not as preset-value changes: GoalPartnerChip.vue's shared-state avatar disc off a nested bg-primary/20-in-/8 composite onto bg-card, and AmbientSidebar.vue's Level-chip plinth gradient stop lowered from /0.25 to /0.15 — both ink-preserving and fill-only.

Guarded going forward: tokenDrift.spec.ts gained a step-5 grid deriver sweep (1,533 inputs, both modes) against the full surface set, demonstrated failing on the pre-fix tree before being trusted, and its preset guard widened from the single /10-over---card case it checked before to the full 17/19-surface set. --focus-ring's existing ≥3:1 guard (tokenDrift.spec.ts:485-500) was re-run, not rewritten — the fix only moves ink further from every surface in both modes, so that floor cannot regress.

Known, related, not yet fixed: --accent-accessible's dark surface set has the identical flat---muted hole this ticket closed for primary — routed out as a separate finding during this ticket's Task 1, not fixed here.

See docs/development/design-tokens.md § The composited-tint predicate gap for the full guard mechanics, docs/ui-specs/obj-2425-primary-accessible-binding-surfaces.md for the canonical binding-surface enumeration, DESIGN.md § Palette intent (rule 4) for the corrected design rule, and the [Unreleased] OBJ-2425 entry in CHANGELOG.md for the fix writeup.

OBJ-2669 (resolved, PR #2432): two solid-fill token pairs failed AA against their own paired -foreground ink — not a tint, the raw full-strength fill: bg-destructive/text-destructive-foreground measured 3.61:1 worst case (10 of 20 theme×mode contexts below AA — the Delete-button pattern) and bg-success/text-success-foreground measured 2.59:1 worst case (20 of 20 contexts failing). The other four solid-fill families (primary 4.54, accent 4.54, gold 5.02, warning 8.85) were already clean. Flagged by Desi while fixing an unrelated chip/badge tint-ink defect on OBJ-2632 and recorded for routing rather than fixed there — this is a token-definition defect, not fixable inside a mockup.

Corrects a claim made in OBJ-2629's own commit message, which stated a solid fill paired with its own -foreground token "was never the defective pattern." For destructive and success specifically, it was — and this reaches shipped product surfaces (the Delete button, the Success badge), not just ionic_frontend/.mockups/primitives.html.

A structurally different shape from v4.36's Tint-Ink Contrast Floor (OBJ-2427, above). That milestone's guard scans for same-hue ink tinted against a same-hue tint (bg-<hue>/N text-<hue>); this is a full-strength solid fill (bg-<hue>, no alpha) paired with its own dedicated -foreground token — a shape tintInkGuard.spec.ts never scanned for and doesn't cover today.

Fix: --destructive's anchor adopts the AA-safe values --destructive-fill had already independently derived — light 0 84% 60%0 72% 44%, dark 0 63% 31%0 72% 46% (5.35:1 worst case). --success gains its first-ever html.dark override, the same bright-fill/dark-ink shape --warning/--gold already use: the light anchor darkens from the value both modes previously shared (160 84% 39%) to 160 84% 27%; dark keeps that same 160 84% 39% value (byte-identical to today), but its foreground flips from an inherited white to a deep same-hue 160 90% 10% ink (5.01:1 worst case). Landed across the full production surface — design_system/css/tokens.css, design_system/src/tokens/colors.ts, the Ionic variable-layer duplicate (ionic_frontend/src/theme/variables.css, 6 hex + 2 -rgb triplets), and marketing_landing/css/landing-2026.css's synced --success (caught by the existing tokenDrift.spec.ts drift guard; --destructive isn't duplicated there).

CI regression, fixed same day: darkening --destructive's light anchor for this fix also darkened the /25 shipped-tint ceiling --destructive-accessible derives against (v4.36's per-hue alpha ceiling, above) — re-derived --destructive-accessible to hold both, caught by tintInkContrastMatrix.spec.ts's regression guard before merge, not after.

5 shipped text-white-on-solid-fill sites repointed onto the two now-accessible pairs: TodayActions.vue:140, HabitCalendar.vue:109, CollectiveGoalDetail.vue:50text-success-foreground; NotificationsPanel.vue:29text-destructive-foreground. Roy's review caught a 5th site the implementation pass missed — TeamLeaderboard.vue:128text-success-foreground — plus 2 bundled MINOR sites, AllyConfirmModal.vue:43 and BlockConfirmSheet.vue:50text-destructive-foreground; both review rounds independently re-grepped the full ionic_frontend/src tree for bg-destructive/bg-success (non-tint) + text-white afterward and found zero remaining matches. GoalHero.vue:29 (bg-destructive/80 text-white/85 — an alpha tint, not a solid fill) is out of scope and untouched, confirmed left alone by design.

Guarded going forward: a new describe block in ionic_frontend/tests/unit/themeContrast.spec.ts asserts both solid-fill/-foreground pairs against their real anchors, in both modes.

Known follow-ups, not fixed here (deliberately out of scope): --coach/--coach-foreground carries the identical defect (Desi flagged it, not yet ticketed as its own issue — worth a sibling ticket). --destructive-fill is now byte-identical to --destructive in both modes, making the token redundant (4 call sites, a small follow-up cleanup). design_system/src/main.css:278-283's .chip-destructive comment illustrates the old --destructive light→dark transition with numbers that are now stale — the reasoning it documents still holds, but the cited numbers don't; not fixed since no test asserts the comment. ionic_frontend/src/stories/design-system/Colors.stories.ts passes hardcoded hex literals (#10b77f, #ef4444) to its Storybook swatches rather than reading the live tokens, now stale for Success/Destructive — no test catches it (unlike colors.ts/landing-2026.css, which are guarded).

See DESIGN.md § Palette intent § Accessibility hard rules — Solid-Fill Own-Foreground Floor (new hard-rule section) for the rule this locked in, and the [Unreleased] OBJ-2669 entry in CHANGELOG.md for the fix writeup.

OBJ-2679 — v4.40 Plain-Surface Ink Sweep (resolved across 6 phases, PRs #2434/#2456/#2467/#2487/#2503/#2513): the same defect § v4.36's Tint-Ink Contrast Floor (OBJ-2427, above) fixed for tinted ink had a plain-surface sibling nobody had measured — a raw text-<hue> fails AA against --card/--background even with no tint anywhere nearby. OBJ-2668 (Desi) measured all 7 declared hues untinted across 10 themes × 2 modes and found every hue fails AA untinted, in 7 to 20 of its 20 contexts (worst 1.62:1, gold). This milestone built the guard for it (ionic_frontend/tests/unit/plainSurfaceInkGuard.spec.ts) and swept the debt it exposed — 362 violations to 0, tracked through a full-census allowlist that shrank phase by phase rather than a silent exclusion.

  • Phase 1 (PR #2434): built plainSurfaceInkGuard.spec.ts — a tag-aware sibling of tintInkGuard.spec.ts, not an extension of it (that guard's same-line matcher can't follow an <AppIcon> tag across a line break, and 18 of 131 hue-bearing tags do). Scoped to the 14 of 39 discovered hues with a declared -accessible sibling. Deleted the now-expired COACH_GOLD_ALLOWLIST from tintInkGuard.spec.ts (OBJ-2441 had shipped both tokens). Kickoff census: 362 violations / 140 exempt / 10 review, corrected from a pre-guard hand count of 425.
  • Phase 2 (PR #2456, OBJ-2682): swept 59 sites across components/dashboard, components/goal, components/achievements — the densest surface in the app, sequenced first so Desi could measure the milestone's one real design judgment (does a wall of -accessible ink read visibly muted?) before the mechanical batches ran. Finding: muting is light-mode only (all 21 light cells darken, zero dark cells do). 53 of 59 sites took the mechanical swap; 5 took Desi's "less ink" remedy per § v4.37 rule 2; LegendaryCelebration.vue:23 — which renders on a permanent dark scrim where the swap regresses 9.02:1 → 2.31:1 — got Orion's text-white ruling instead of an -accessible swap or an unverifiable exemption.
  • Phase 3 (PR #2467): swept 69 sites / 38 files across the social surface (allies, communities, community, feed, teams, views/teams), following Phase 2's protocol with no further design task.
  • Phase 4 (PR #2487): swept 68 sites across shared UI & chrome (components/ui and app-wide primitives) — the highest-risk batch, since a regression in a shared primitive isn't local. Two scoped-CSS "landmine" sites (a component redefining the Tailwind utility class inside its own <style scoped> block) had the class and the CSS rule moved together, not just the class.
  • Phase 5 (PR #2503): swept 81 sites across views/*, play/*, and views/settings — the milestone's largest single batch. MeetCoach.vue:87's PERSONA_CLASSES[...] ?? 'text-coach' fallback (a :class binding, invisible to the guard's static-class scan) was swept by hand; the PERSONA_CLASSES map values themselves are untouched — persona-* has no declared -accessible sibling and sits outside § v4.37's reach until one exists. Surfaced, not fixed here: a pre-existing bg-coach text-coach-foreground defect at GoalCreate.vue:208,586 (3.68:1) — carried to this ledger below.
  • Phase 6 (commit 330dac826, PR #2513): swept the tail — 71 sites across 22 *.stories.ts files and 14 sites across 7 admin_dashboard/src files — and flipped the milestone's completion gate: ALLOWLIST is now asserted [] directly, with a separate live-tree assertion that the real tree has zero unlisted violations. Extended the story-file scanner's Tier B with a proven h(AppIcon, { … }) recognizer so a decorative icon rendered via Vue's h() (not a template) is exempt the same way a template <AppIcon> is — found 5 such sites, one more than the 4-site pre-scan (GoalCreate.stories.ts:145's sparkles icon sits inside a multi-line h() call the by-eye pre-scan missed). admin_dashboard/src has no <AppIcon>, so nothing there is exempt — all 14 sites swept, including FilterBar.vue:84, a tint site in disguise (bg-[hsl(var(--primary)/0.12)] text-primary) measured against its real composited 12% tint over --background, not a bare --card/--background check. Two of the guard's own tests — a sanity guard asserting real violations exist, and a per-phase census-printable test — would otherwise have gone vacuous against the now-empty allowlist; both were re-expressed to assert against the exempt/review buckets and the real remaining census output instead of being deleted.

Final measured census: 0 violations, 145 exempt, 10 review. The 145 is the original 140 decorative aria-hidden <AppIcon> template sites plus the 5 h(AppIcon, …) story sites Phase 6's recognizer added. All 362 originally-censused violations are swept; none remain.

Carried onto this ledger, surfaced by the sweep but not fixed by it:

  • The solid-fill/-foreground defect class — GoalCreate.vue:208,586 (bg-coach text-coach-foreground, 3.68:1, axe-flagged in every theme × mode) — joins the OBJ-2669 entry above (CoachContextCard.vue:50, TodayActions.vue:15, CommunitiesHubCard.vue), a structurally different shape (a solid fill against its own -foreground token, not a tint) that this sweep's guard doesn't scan for.
  • Hover ink running lighter than its -accessible base — admin_dashboard/src/views/EmployeeDetailView.vue:815, UnauthorizedView.vue:25 (text-primary-accessible hover:text-primary-light) — the base ink was swapped, the hover state deliberately was not, per the guards' standing hover-ink deferral bucket every phase of this milestone (and v4.36 before it) has followed.
  • Storybook wiring gaps blocking visual verification: Views/PublicProfile (useAuth called without the Clerk Vue plugin mounted) and Views/PublicGoal (<story/> mount never resolves) — pre-existing, unrelated to this sweep.
  • The persona-*/cat-*/category-* ink Phase 5 flagged, above (~22 sites) — no -accessible sibling exists for any of the three prefixes, so they sit outside § v4.37's reach until one is declared; a design-system ticket, not a sweep.

See DESIGN.md § Palette intent § Accessibility hard rules — v4.37 Plain-Surface Ink Floor for the rule this enforces, and the [Unreleased] OBJ-2679 entry in CHANGELOG.md for the full milestone writeup.

OBJ-2359 (resolved, PR #2273): every enabled row on ios <ion-action-sheet> rendered in --ion-color-primary (a fixed hex, #177bbb) — measured against Ionic's own ios sheet surface at 4.34:1 light / 3.80:1 dark, an AA fail with no WCAG 1.4.3 exemption since the control is enabled, not disabled. App-wide: every action sheet using the default row treatment inherits it on an ios-mode device, not scoped to one component. Desi's measurement pass found the actual sheet has four distinct ink roles, not one, because Ionic paints the cancel row's background through a separate .action-sheet-cancel::after layer that most contrast probes (including axe-core, which is why CI stayed green) never resolve:

Ink roleSits onLight before → afterDark before → after
Enabled rowgroup surface (#f9f9f9/#1a1a1a)4.35 ✗ → 5.62 ✓3.80 ✗ → 7.09 ✓
Cancel rowcancel ::after surface (#ffffff/#262626)4.58 ✓ → 5.91 ✓3.31 ✗ → 6.17 ✓
Destructive rowgroup surface3.57 ✗ → 6.15 ✓4.62 ✓ → 7.00 ✓
Header + sub-headergroup surface2.71 ✗ → 5.79 ✓3.03 ✗ → 6.79 ✓

(Default theme, both modes. All 10 shipped presets measured; worst margin after the fix is 4.81:1 forest-light on enabled rows and 4.31:1 midnight-dark on cancel rows — the latter needed a preset value move, see below.)

Fix: three ios-only declarations in ion-action-sheet.ios (ionic_frontend/src/theme/tailwind.css:563-584) — --button-color: hsl(var(--primary-accessible)) and --color: hsl(var(--muted-foreground)) for enabled rows and the header pair, plus the destructive row's red repointed to --destructive-text shipped in Ionic's own three-selector shape (rest / .ion-activated / .ion-focused) plus an any-hover rule — a single-selector override loses to Ionic's higher-specificity state rules and the row flashes back to --ion-color-danger on press, verified in-browser before shipping the wider shape. .md is untouched: md's enabled-row ink is already --ion-color-step-850 (~15:1) and md has no .action-sheet-destructive rule at all, so extending the same declaration to .md would both throw away contrast and make destructive rows indistinguishable from their neighbors — confirmed bit-identical before/after on every md capture.

deriveCustomTheme()'s --primary-accessible clamp gained the two ios sheet surfaces the group and cancel rows sit on (ionic_frontend/src/lib/customTheme.ts — L18/D20/D21, folded into OBJ-2425's generalized BindingSurface model rather than a bespoke predicate, since OBJ-2425 landed the same clamp mid-flight). 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% (the larger of the two candidate values once OBJ-2425's independent recompute and this ticket's own were reconciled — 74% already clears both new ios surfaces with margin, 6.24:1 group / 5.42:1 cancel).

Guarded going forward: tokenDrift.spec.ts gained a guard case covering the two ios sheet 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 regression to the single-selector destructive shape fails loudly instead of silently losing the row's red on press.

Why CI never caught this: axe-core doesn't composite ::after backgrounds, so it measured the cancel row against the wrong surface entirely, and axe only runs against rendered routes — it never opens an action-sheet overlay. tokenDrift.spec.ts's guard is the durable fix here, the same lesson OBJ-1951 and OBJ-1962 already recorded above for modal/overlay surfaces a route-level scan can't see.

See docs/development/design-tokens.md § OBJ-2359 for the token-clamp mechanics, and the [Unreleased] OBJ-2359 entry in ionic_frontend/CHANGELOG.md and CHANGELOG.md for the fix writeup.

OBJ-2071 (resolved, branch fix/obj-2071-enneagram-pill-contrast, PR pending): EnneagramResultCard.vue's dominant-type pill (and the identical markup duplicated in the EnneagramTypeDetail view, not a component) hardcodes text-white over a fixed per-Enneagram-type hex applied via inline style — not bg-accent, not a CSS custom property. There is no theme axis and no light/dark axis on this surface at all; a pill measures identically in every supporter theme and both modes. The original filing got two things wrong: it claimed the pill inherited --accent and drifted per supporter theme (the real axis is the nine Enneagram types), and it cited a 3:1 floor (the real floor is 4.5:1text-xs = 12px = normal text, this doc's own Target Standard table). Corrected measurement found a third surface nobody had scoped before this ticket — the type-number chip:

TAccentPill text-white (before)Sub-label opacity-70 (before)Number chip bg-white/25 (before)accentInk (after, all 3 surfaces)
1 Reformer#7e8a9c3.50 ❌2.54 ❌2.43 ❌#16191d5.04
2 Helper#d98a822.66 ❌2.04 ❌2.04 ❌#270f0c6.82
3 Achiever#fcc4191.61 ❌1.38 ❌1.44 ❌#3226019.23
4 Individualist#9276bd3.78 ❌2.69 ❌2.57 ❌#1811224.86
5 Investigator#2e6a6e6.17 ✅3.94 ❌3.58 ❌#ffffff (unchanged)6.17
6 Loyalist#3d76565.35 ✅3.53 ❌3.25 ❌#ffffff (unchanged)5.35
7 Enthusiast#f285292.57 ❌1.95 ❌2.03 ❌#3017036.53
8 Challenger#c93b415.02 ✅3.20 ❌3.35 ❌#ffffff (unchanged)5.02
9 Peacemaker#b9c2a41.86 ❌1.57 ❌1.57 ❌#1c1f149.01

24 of 27 surfaces (9 types × 3) failed 4.5:1 before this fix — only Types 5, 6 and 8 cleared the pill label, and every sub-label and every number chip failed, including on the three "passing" types. Independently re-derived against enneagramTypes.ts's shipped accent/accentInk pairs using the WCAG relative-luminance formula, not restated from the UI-SPEC.

Fix: ink-only — accent is byte-identical in all nine entries; a new additive accentInk field on EnneagramType carries bestForeground(h, s, l, 10) (ionic_frontend/src/lib/customTheme.ts:100, the design system's existing ink-picking function, not a new rule) applied to each accent's own hue/saturation. Set in the same inline style binding as backgroundColor so fill and ink cannot separate the way --streak-accent-accessible and its ink did in OBJ-1962. The opacity-70 sub-label dimming is removed (it inherits accentInk and lands at the pill's own ratio) and the number chip's bg-white/25 fill is removed (kept translucent, it helps the six dark-ink types but still fails the three white-ink ones — no single treatment worked for all nine with the fill kept). 27/27 surfaces now clear 4.5:1, worst cell 4.86:1 (Type 4). getEnneagramType's unknown-type fallback also carries an accentInk (#322601 on #fcc419, 9.23:1). The closest transferable precedent is OBJ-2055/OBJ-2056 — a hardcoded flat foreground against a fill whose lightness varies, fixed with an ink-only swap — not OBJ-2047, whose opaque-fill pattern doesn't apply here (see that entry above: no PR, unmerged spec, do not cite as precedent).

axe found 15 of the 24 failures — it missed every number chip. Running axe-core over a faithful reproduction of the shipping markup reports the 6 failing pill labels and the 9 failing sub-labels, but zero of the 9 failing number chips — it does not resolve the bg-white/25 composite. Fresh first-party evidence, on this exact component, for the standing rule two paragraphs above (OBJ-1951/OBJ-1962): a green axe run is not the gate for a token- or fill-driven contrast defect.

Guarded going forward: ionic_frontend/tests/unit/enneagramPillContrastGuard.spec.ts — 27 assertions: per-type accent-unchanged + accentInk-clears-4.5:1 pins (9 types × 2, using the repo's own relativeLuminance math from customTheme.ts), the fallback check, and markup assertions per file (pre-fix classes absent, ink set via the same inline style binding) for both EnneagramResultCard.vue and EnneagramTypeDetail.vue. Demonstrated failing on the pre-fix tree (18/27) via git stash before being trusted, per the same bar tokenDrift.spec.ts/themeContrast.spec.ts already follow.

Why the class-based token guards can't see this shape. themeContrast.spec.ts and tokenDrift.spec.ts walk :root/theme CSS blocks; consumerContrastGuard.spec.ts anchors specific classnames. None of them can see a fixed hex applied via inline style — there is no token or class to walk. The upcoming v4.36 Tint-Ink Contrast Floor milestone's sweep predicate is also class-based (bg-<hue>/N text-<hue>, .planning/milestones/v4.36-tint-ink-contrast-floor-ROADMAP.md:26), so this inline-fixed-hex shape — and any future one like it — is structurally invisible to that sweep too. Widening its guard scope to cover inline-hex sites is a separate, not-yet-actioned recommendation, not part of this fix.

See .planning/phases/obj-2071-enneagram-pill-contrast/UI-SPEC.md for the full design contract and Josh's sign-off record, and the [Unreleased] OBJ-2071 entry in CHANGELOG.md for the fix writeup.

OBJ-2290 (resolved, PR #2550): TeamLeaderboard.vue's podium rank numeral (TeamLeaderboard.vue:96) set ink with a literal text-white, shared across all three podium blocks even though podiumTone() (264-269) gives each rank a different gradient fill. Filed initially against only the rank-3 tone (bg-gradient-accent vs text-white, measured 9 of 10 themes failing), the real scope was wider: ranks 1 and 2 (from-gold to-warning, from-slate-300 to-slate-400) are theme-invariant fills that failed on 10 of 10 themes, worst stops 1.69:1 and 1.48:1 against a 4.5:1 label floor — worse than the rank-3 defect the issue was filed against, and missed by the original measurement because it only checked the tone that happened to vary by theme.

The class of bug this is, not just the one site. A hardcoded ink literal overriding a themed token is invisible to every token-layer contrast predicate on this page and in design-tokens.md — those predicates all measure the token's declared contrast, not what class a consumer actually applied, so this component could sit undetected next to fully-passing themeContrast.spec.ts/tokenDrift.spec.ts/gradientPrimaryGuard.spec.ts runs indefinitely. See design-tokens.md § The consumer-override blind spot for the full mechanism and why the obvious one-word fix (text-whitetext-accent-foreground on the shared class) would have silently reintroduced the defect on Midnight, whose --accent-foreground is itself literally white.

Fix: ink moved into podiumTone() so each rank pairs its own token — text-warning-foreground for ranks 1 and 2, text-accent-foreground for rank 3 — rather than one class shared across three unrelated fills. TeamLeaderboard.vue:88 (the podium avatar initials, also text-white, but over an API-supplied entry.color that no token swap can fix) was a separate defect, resolved by OBJ-2803 (below).

Guarded going forward: ionic_frontend/tests/unit/consumerContrastGuard.spec.ts gained a "Site 5" block — per-branch assertions that each podiumTone() rank pairs the correct ink token and that text-white is absent from the static class and all three branches, following the same element-anchored pattern (not a file-wide toContain) the guard's header documents after the OBJ-2087/de08508ec regression.

See docs/development/design-tokens.md § The consumer-override blind spot for the generalized defect-class writeup.

OBJ-2803 (resolved, PR #2951): the podium avatar initials at TeamLeaderboard.vue:88 (plus the same hardcoded-text-white-over-runtime-color shape at TeamLeaderboardRow.vue:22, MondayRecapCard.vue:85, TeamRoomHeader.vue:5, EditSubCommunityModal.vue:39) sat over an API-supplied runtime color, not a themed gradient — OBJ-2290's per-tone token swap couldn't fix it, since there's no fixed background to pair a token against.

Fix, two different shapes for two different sources of color:

  • Runtime-tinted avatars (TeamLeaderboard.vue, TeamLeaderboardRow.vue, MondayRecapCard.vue) — entry.color/mover.color retired entirely from LeaderboardEntry/RecapMover. A new avatarTint(seed) helper (ionic_frontend/src/helpers/avatarTint.ts) hashes a stable member id into one of 4 fixed fill+ink class pairs (--avatar-tint-1..3 + -foreground, plus a 4th slot reusing --primary-ink) — a constrained palette, not a computed-luminance pick, so the pairing is guaranteed safe rather than derived per-color. Tokens declared in both :root and .dark in design_system/css/tokens.css, each clearing 4.5:1.
  • Enum-toned room icon chips (TeamRoomHeader.vue, EditSubCommunityModal.vue) — these were never runtime colors, just left out of the v4.33 --gradient-tone-* migration; roomIcon.ts's toneClass now returns bg-gradient-tone-* + text-primary-foreground like every other migrated site.

Guarded going forward: avatarTintGuard.spec.ts (new) asserts all 4 tint pairs clear 4.5:1 in both modes via contrastRatio() from customTheme.ts. consumerContrastGuard.spec.ts extended with Sites 6–9, one per touched element.

See docs/development/design-tokens.md § The consumer-override blind spot for why this is the same defect class as OBJ-2290, just with no token to swap to.

OBJ-2807 (resolved, PR #3054): a different defect class from the two entries above — not a contrast failure, but a missing accessible name (WCAG SC 4.1.2 Name, Role, Value). TeamLeaderboard.vue's podium rank numeral (the pedestal <div> rendering 1/2/3, now at TeamLeaderboard.vue:105) carried the rank visually with no accessible name — a screen reader announced the podium block as unlabeled values ("Maya, 340, 2"), with no indication "2" meant rank. Found by Desi while writing the UI-SPEC for OBJ-2290's contrast fix (her "Also confirmed live" note on that issue); filed separately since it's a labeling defect, not a color one.

Fix: an sr-only <span>Rank {{ podiumRank(i) }}</span> (TeamLeaderboard.vue:91) placed first in the podium cell — ahead of the avatar, name, and points — so rank announces first, matching TeamLeaderboardRow's existing announcement order (the two variants of the same board now read the same way). The pedestal <div> itself (TeamLeaderboard.vue:105) gets aria-hidden="true", since its content is now redundant with the label. Announcement changes from "Maya, 340, 2" to "Rank 2, Maya, 340". No visual change — sr-only is absolutely positioned and doesn't affect the flex column's layout or gaps.

Not yet guarded going forward. Roy's review flagged a gap: no assertion in TeamLeaderboard.spec.ts's podium describe block pins the new .sr-only "Rank N" text or the pedestal's aria-hidden. CI's jest-axe/WCAG gate catches a missing-accessible-name class of regression generically, but nothing pins this specific announcement, so a future podium refactor could silently drop the label with zero test signal. Non-blocking per Roy's review — a known gap, not a filed follow-up.

OBJ-1986 — v4.42 Tint Alpha Governance, real defects fixed (Phases 2–4, PRs #2563/#2573/#2575): filed by Desi during v4.26 Phase 6 (OBJ-1984) against nine ungoverned same-hue tint alphas. The kickoff census found the real problem was two guard gaps, not nine values: 104 tintInkGuard.spec.ts allowlist entries sat against issues already done (78 on a placeholder 'OBJ-2472 phase 2 triage pending' reason, orphaned since OBJ-2472 itself closed), and SHIPPED_CEILING covered only 6 of the 14 hues with a declared -accessible ink token — 8 hues (accent, streak-accent, brand-chesscom, brand-strava, all 4 rarity-*) had no enforced AA ceiling at all despite having a fix target.

  • Phase 2 — 52 primary cross-element entries. Measured every entry individually rather than eyeballing the bucket. 51 of 52 were genuinely decorative (unlabelled <AppIcon> glyphs, raw aria-hidden SVGs) and 1 was a real defect: GoalCreate.vue's template-chip "Change" button (hover:text-primary, now hover:text-primary-accessible at :149), a real interactive label the scanner's line-window match had missed — fixed with a one-token -accessible ink swap (2.73:1 → 4.92:1 worst-case light).
  • Phase 3 — the 26 remaining cross-element entries + 19 hover-deferred entries. Un-gating the decorative-descendant check from primary-only to every hue dissolved all 26 remaining cross-element entries structurally — the same unlabelled-glyph shape, verified individually rather than assumed. All 19 hover-deferred entries turned out to carry no rest-state tint at all, so § v4.36 rule 5's hover exemption is permanently true for them and was made structural rather than listed. Zero new defects found; the orphaned allowlist collapsed 104 → 10 entries, the last 10 all correct, permanent exemptions.
  • Phase 4 — Phase 1's 8 new ceilings flag 3 sites, not the ~9 the kickoff census estimated. The census had over-read PublicProfile.vue/FeedItemCard.vue's rarity tiles as ceiling-exceeding; measured directly, none of them actually composited same-hue ink over a background (borders and Tailwind arbitrary-bracket alphas the guard's integer-only matcher couldn't see, not real exceedances). The genuine find was 3 accent sites one point over the new /14 ceiling — PointsExplainerSheet.vue:26/:35 and StimXpBadge.vue:5 — fixed /15 → /14, no ink or layout change.

Further real defects, disposed by later phases and documented at their own rule rather than duplicated here: Phase 5b fixed 8 celebration-tier rarity-*/gold sites (raw hue ink swapped to -accessible) among 52 raw-CSS findings its own widened guard surfaced; Phase 5c fixed the 6 -foreground-over-tint sites the scanner's -foreground-as-ink blind spot had hidden (worst 1.32:1, BadgeModal.vue:406, default theme dark), plus one unrelated raw-ink-over-ceiling site. See DESIGN.md § Accessibility hard rules — v4.42 Governed Tint Scale, rule 4, for the -foreground contract ruling those fixes are governed by.

Known and owned, not fixed by this milestone — three solid-fill -foreground violations, found during Task 1's measurement pass, tracked as OBJ-2851. A different rule from everything above — a full-strength opaque fill against its own -foreground token, the same shape the OBJ-2669 entry above governs, just not yet measured under it — and deliberately not folded into this milestone's close. Resolved, and widened to eight — see the OBJ-2851 entry below. Re-measuring the full contract for the fix (not just these three known call sites) found five more, latent, failures.

Guarded going forward: tintInkGuard.spec.ts's ALLOWLIST (the concatenation of all nine buckets — HOUSE_PATTERN_ALLOWLIST, PERSONA_BGFADED_ALLOWLIST, HOVER_ONLY_ALLOWLIST, CEILING_ALLOWLIST, BRACKET_ALPHA_ALLOWLIST, RAW_CSS_CO_OCCURRENCE_ALLOWLIST, FOREGROUND_INK_ALLOWLIST, CROSS_ELEMENT_ALLOWLIST, FOREGROUND_INK_CROSS_ELEMENT_ALLOWLIST) is asserted expect(ALLOWLIST).toEqual([]), plus a live-tree scan asserting zero unlisted violations — the same completion-gate model v4.40 Phase 6 used. docs/development/design-tokens.md § The ink-safe tint scale documents the 42 --ink-safe-<hue>-<level> tokens this milestone declared so future call sites don't re-derive a per-hue alpha by hand.

See DESIGN.md § Palette intent § Accessibility hard rules — v4.42 Governed Tint Scale for the full ruling (the scale, the ceiling-asymmetry rule, the retired/still-open OBJ-2456 split, and the -foreground contract), docs/development/design-tokens.md for the scale mechanics, and the [Unreleased] OBJ-1986 entry in CHANGELOG.md for the milestone writeup.

OBJ-2851 (resolved, branch fix/obj-2851-solid-fill-foreground-floor): three live violations of § "Solid-Fill Own-Foreground Floor (OBJ-2669)" found during OBJ-1986's Task 1 measurement pass and deliberately left unfixed at that milestone's close (above) — bg-coach text-coach-foreground 3.69:1 in all 20 theme × mode contexts (ui/UiButton.vue:17's coach variant, so every coach button), bg-rarity-epic text-rarity-epic-foreground 3.01:1 dark (PublicGoal.vue:447), hover:bg-destructive-interactive text-destructive-foreground 4.32:1 light (ui/UiButton.vue:12's destructive variant, so every destructive button — 5 call sites total once re-counted for the fix, not the 3 first filed). Re-measuring the full 24-pair contract for the fix — not just these three known call sites — found five more, latent, failures: bg-strava 2.76:1 (20/20), bg-category-teal 1.99:1 (20/20), bg-category-rose 3.16:1 (20/20), bg-category-indigo 3.72:1 dark, bg-streak-accent-accessible 2.63:1 dark — declared token pairs with zero live call sites pairing the fill with its own -foreground ink today, invisible to a call-site scan but still a contract failure the moment either token is reached for.

All eight fixed in design_system/css/tokens.css. Seven keep their -foreground token and gain a compliant value (dark-mode html.dark overrides for rarity-epic, category-indigo, streak-accent-accessible — no single same-hue ink cleared both modes; a lightened light-anchor for destructive-interactive, strava, category-teal, category-rose). coach is the exception: rather than darken --coach-foreground (the fix this issue first proposed, rejected by Desi's UI-SPEC after it was measured to break two already-passing surfaces — the coach hover state and TeamRoleBadge.vue's resting badge, both 5.21:1 → 3.20:1 under that option), --coach (#a855f7, the reserved brand purple) stops carrying a label at all. Every text-bearing coach surface — 18 call sites across 15 files, 6 of them a literal text-white invisible to any -foreground census — repoints to a new --coach-interactive/--coach-dark fill pair with its own --coach-interactive-foreground ink (5.21:1 resting, 6.90:1 hover). Full findings table, pre-/post-fix ratios, and the bg-destructive-accessible non-defect observation (an -accessible ink token used as a fill at ManageRoomsSheet.vue:97/TrialStatusBanner.vue:64, saved from being a live defect only by an existing dark: override — not sanctioned by this rule, recorded rather than changed): DESIGN.md § Accessibility hard rules — Solid-Fill Own-Foreground Floor (OBJ-2669).

A second design conflict, split into its own ruling (OBJ-2927): the streak-accent-accessible fix first proposed (html.dark { --streak-accent-accessible-foreground: 27 89% 18%; }) broke a currently-passing, unrelated consumer — XpChip.vue's translucent props.dark=true variant composites a 10–25%-alpha --streak-accent gradient over dark --card and needs light ink, which the same token also carries. One token, one dark-mode value, two surfaces needing opposite ink polarity — the same shape as the coach conflict above. Desi's ruling: no new token: 27 89% 10% instead of 18% (reuses --accent-foreground's shipped dark value, since --accent in dark is byte-identical to --streak-accent-accessible; clears 6.40:1 vs 18%'s 4.60:1), and XpChip.vue:28's dark branch repoints to --card-foreground instead of the shared token.

Guarded going forward: ionic_frontend/tests/unit/solidFillForegroundGuard.spec.ts (OBJ-2921) — the durable half of this fix, closing the gap DESIGN.md's own v4.42 section flagged when it deferred these findings. Two layers: a token-level matrix asserting all 24 declared <x>/<x>-foreground pairs clear AA in all 20 (theme × mode) contexts, and a call-site scanner (AST-walked template classes plus a per-line scan for the <script>-side cva()/computed()/:class-binding shapes the AST walk can't see) that resolves classes through design_system/tailwind-tokens.cjs rather than string concatenation, honors dark: variant overrides (so the bg-destructive-accessible non-defect above doesn't false-positive), and excludes any /N alpha modifier (a tint, governed by the v4.36/v4.37 rules, not this one). Verified failing with exactly the eight findings above against the pre-fix tree; zero allowlist entries after.

See DESIGN.md § Palette intent § Accessibility hard rules — Solid-Fill Own-Foreground Floor (OBJ-2669) for the full findings table and ruling, and the [Unreleased] OBJ-2851 entry in CHANGELOG.md for the fix writeup.

OBJ-2868/OBJ-2869 — v4.44 Gradient Stop Ink Contract, real defect fixed and a new guard surface opened (5 phases): no guard in the tree had ever looked at a gradient token's stops — tintInkGuard.spec.ts matched same-hue tint/ink pairs and gradients were invisible to it. Filed against one measured defect (--gradient-gold stop B, 3.97:1 against --gold-foreground) and widened by the kickoff census: tokens.css carries 40 --gradient-* declarations across 22 token names (36 raw-literal stops + 4 var() aliases), and the guard saw none of them.

  • The shipped fix: --gradient-gold stop B moved from hsl(38 92% 50%) to hsl(43 96% 66%)3.97:1 → 5.63:1, exact in all 20 theme×mode contexts (Phase 2). Two more real ink defects the census found: --gradient-coach paired with literal text-white at all 17 ink-bearing call sites, never --coach-foreground (3.96:1, fixed by porting --gradient-coach-ink from the marketing surface, 7.01/7.24:1); --gradient-primary paired with text-white at 4 sites, failing at 1.66:1 in dark mode (fixed by swapping to --primary-foreground, no token change) (Phase 3b, OBJ-2875).
  • The new guard surface: Phase 4 (OBJ-2873) taught tintInkGuard.spec.ts to scan design_system/css (SCAN_ROOTS, :136) and resolve gradient stops through var() indirection rather than only matching literal hsl() values — scanGradientDeclarations() / resolveGradientTokenStops(). Widening the scan surface produced zero new violations in any pre-existing bucket, asserted as its own test.
  • Deliberately not covered by the new guard, both named rather than implied: (a) the runtime tokens deriveGradientPrimary() (ionic_frontend/src/lib/customTheme.ts:295) mints for user-authored Theme Creator themes — no static census can see generated output; (b) the two translucent --gradient-coach-tint* stops — composite resolution is v4.42 Tint Alpha Governance's contract. A third exclusion, --gradient-hero, was named here at close — see the OBJ-2928 entry below; it's since been found and fixed, not merely scoped out.

OBJ-2928 (resolved, branch fix/obj-2928-gradient-hero-ink-coverage): --gradient-hero's v4.44 close-time exclusion — "zero consumers in ionic_frontend/src" (tintInkGuard.spec.ts:2108, pre-fix) — was factually wrong, not merely scope-limited. ionic_frontend/src/stories/design-system/Gradients.stories.ts rendered the token via a string-concatenated 'var(' + g.token + ')' that defeated every literal var() scan (including GUARD-1 above), painting it with a hardcoded, mode-blind ink literal (g.dark ? '#1e293b' : 'white') that measured 1.20:1 in dark mode — a real, unmeasured defect inside the census scope, not outside it. The same swatch's other five gradients had the identical hardcoded-literal shape: five of six failed AA in at least one mode (worst 1.17:1, Card swatch dark), all invisible for the same reason.

  • The two BrandOverview.vue sites the original follow-up named were never the defect — both measure clean. :118 h2, --foreground: 16.07 light / 16.74 dark. :85 body, --muted-foreground: 5.46 / 6.82. :82 h1, text-gradient-primary: 4.08 light / 7.14 dark — clears only as large text (≥18.66px bold or ≥24px regular); the shipped text-6xl md:text-8xl font-extrabold site qualifies with room to spare, but this ink is not permitted at normal size on this gradient.
  • Fix: Gradients.stories.ts now renders every swatch's ink from a live CSS custom property (hsl(var(--<token>))) instead of the dark: boolean literal switch. One deliberate exception: the Coach swatch's ink stays the fixed keyword white — no design token encodes constant white across both modes, and white is exactly how real Coach call sites express it (CoachRoadmapIntro.vue:38 et al., already ledgered above) — guarded structurally by GUARD-8 below rather than banned outright. gradient-hero moved out of GRADIENT_NO_INK_TOKENS into GRADIENT_CALLSITE_PAIRINGS with two bucket-(a) rows (--foreground, --muted-foreground).
  • Two new guard checks close the mechanisms that hid it, not just the one instance. GUARD-7 scans design_system/src for inline style="..." gradient references no existing tier read — a shape GRADIENT_CALLSITE_PAIRINGS could record but never discover — measured against default × {light, dark} only (design_system loads no supporter themes, mirroring the existing admin_dashboard narrowing). GUARD-8 cross-checks any .stories.ts file's exported (token, ink) data against the ledger, so it validates the data the render expression reads rather than the expression's text — a future rewrite of the concatenation shape (template literal, helper function, anything) stays covered without the check changing. Both are seeded-control tested.

See DESIGN.md § Palette intent § Accessibility hard rules — v4.44 Gradient Stop Ink Contract for the full ruling, and the [Unreleased] OBJ-2928 entry in CHANGELOG.md for the fix writeup.

Deferred, not fixed — six supporter-theme --gradient-cover-* / --gradient-tone-* pairs stay below the 3:1 glyph floor, tracked as OBJ-2876. Read directly from the guard's own allowlist (ionic_frontend/tests/unit/tintInkGuard.spec.ts:2180-2219), not from milestone prose — this milestone was bitten twice already by counts copied from wording (Phase 2's hardcoded 3.97, Phase 3a's grep -c prediction). Three honest, distinct numbers, not interchangeable: 6 distinct tokens (gradient-cover-career, gradient-cover-financial, gradient-cover-productivity, gradient-cover-relationships, gradient-cover-wellness, gradient-tone-book), 9 token×stop allowlist entries (the guard is stop-keyed, not token-keyed — asserted toHaveLength(9) and new Set(...).size === 6 in the same test, specifically so a new failing stop on one of the six tokens can't hide inside an already-permitted entry), and 37 failing (token × stop × theme × mode) ledger rows across every supporter-theme context. Worst measured: 1.64:1 (gradient-cover-career stop A, forest theme, light mode).

Mechanism, not a stop retune: these gradients were tuned against --primary-foreground in the default theme (v4.33 Phase 3's solve, tuned for white ink). Supporter light themes redeclare --primary-foreground near-black while the cover/tone gradients stay at their default-theme values — near-black ink on a gradient tuned for white ink. No stop retune fixes it; the fix re-opens v4.33 Phase 3's token architecture. Expiry condition (Desi's, verbatim): scope OBJ-2876 before any new --gradient-cover-* / --gradient-tone-* token is added, or the next one inherits the gap. Allowlisted in the Phase 4 guard under GRADIENT_INK_ALLOWLIST, gated behind v4.44 close.

See DESIGN.md § Palette intent § Accessibility hard rules — v4.44 Gradient Stop Ink Contract for the full ruling, and the [Unreleased] entry in CHANGELOG.md for the milestone writeup.

OBJ-2966 (fix implemented and reviewed, branch fix/obj-2966-page-accent-ink-contract, PR #2666 pending merge): .page-header__eyebrow (design_system/src/main.css:341-346) and .page-header__icon's glyph both painted their same-hue decorative --page-accent directly as ink, with no stated contrast contract on the field consuming it as text — PageAccent's navLight/navDark (design_system/src/lib/pageAccents.ts) already documented a 3:1 floor two fields over; hero, the field the eyebrow reads, did not. Found by Orion while investigating OBJ-2928 (above) and deliberately split off — different token family, different defect class. Measured against bare --background: 6 of 10 design_system routes failed in at least one mode, worst 1.61:1 (/animations, light).

Desi's UI-SPEC measurement widened the defect before Codi touched any code. .page-hero::before paints the same-hue wash the eyebrow visually sits inside — hsl(var(--page-accent) / 0.14), a radial gradient centred at 12% 0%, the exact corner the eyebrow occupies — so bare --background was never the surface actually rendered. Measured against the real composite: 10 of 10 routes fail, not 6 (12 of 20 route×mode text slots — /spacing light 4.79→3.98 and /shadows light 5.17→4.32 are new failures a bare-background check passes clean), and the icon tile's glyph fails the 3:1 floor at 6 of 18 slots, not the 2 first suspected (/typography L 2.82, /gradients L 2.01, /animations L 1.43, /icons L 2.38, /shadows D 2.79, /dark-mode D 2.19).

Fix: --page-accent stays decorative-only (wash, tile fill, inset ring, rule — byte-identical to before); PageAccent gains a paired, per-mode heroInkLight/heroInkDark ink contract, each carrying a doc comment stating both its floor and its surface (the missing surface statement, not just the missing floor, is the root cause this closes). Seven of ten routes reuse an existing -accessible token family automatically; the three raw-literal routes (/shadows, /dark-mode, /icons) get hand-authored per-mode values — two genuinely new (/shadows light 222 20% 42%, /icons light 175 65% 27%, both tuned to the same headroom rule: the shallowest whole-percent step reaching 4.7:1 on the wash) and four reused from a value the same route already carries elsewhere in the file (/shadows dark and /dark-mode dark take their own navDark; /dark-mode light and /icons dark take their own hero). /icons' existing navLight (175 65% 31%) was tried first and rejected at 3.83:1 on the wash — tuned for the 3:1 sidebar floor, reused for the 4.5:1 text role, the exact mistake this contract exists to close. PageHeader.vue emits --page-accent, --page-accent-ink-light, and --page-accent-ink-dark from one useRoute().path lookup; a route with no PAGE_ACCENTS entry falls back to --primary-accessible, never a raw hue. BrandOverview.vue's hand-rolled / header — which bypasses PageHeader.vue entirely via an inline style= attribute — now reads PAGE_ACCENTS['/'] instead of hard-coding var(--primary), closing a dead-map-entry risk, and is measured against its own real surface (--gradient-hero's two stops, not the wash — light bottom stop 203 78% 95% → 5.32:1).

One implementation-shape deviation from the UI-SPEC's literal example, verified correct, not drift. The spec derived ink through an intermediate --page-accent-ink custom property scoped to .page-header/html.dark .page-header. BrandOverview.vue's hero carries no .page-header class, so that ancestor-scoped shape would have left /'s eyebrow unresolved. The shipped fallback chain (var(--page-accent-ink-light, var(--primary-accessible))) resolves on the two actual ink consumers (.page-header__eyebrow/.page-header__icon + html.dark siblings) directly instead — same values, same floors, same fallback target, just anchored at the element rather than at an ancestor class one of the eleven instances doesn't carry.

A fourth false-negative mechanism, in axe-core rather than this repo's static scanner. Running axe-core's color-contrast rule over the rendered header returns zero violations and 20 incomplete nodes, every one reading "Element's background color could not be determined due to a pseudo element" — the same .page-hero::before wash that causes the defect also blinds the runtime checker to it, before and after the fix. A green axe run was never evidence this surface was safe; pageAccentInkMatrix.spec.ts (below) is the only real guard on these values.

Guarded going forward — the third static-scanner false-negative mechanism in this token family, and the first two named alongside it so the pattern reads as one lineage rather than three scattered fixes. discoverHues() (ionic_frontend/tests/unit/helpers/tintInkContrast.ts) only ever registers a hue from a raw-HSL declaration in design_system/css/tokens.css. --page-accent is declared only in main.css:340 (--page-accent: var(--primary);) — a local alias, never a tokens.css hue of its own — so the same-hue tint/ink pair at main.css:351-352 was never matched, despite design_system/src having been in SCAN_ROOTS since v4.42 Phase 5 (OBJ-2818). buildLocalAliasMap()/resolveAliasTerminals() (ionic_frontend/tests/unit/helpers/tintInkTemplate.ts) resolve a file-local --alias: var(--other) declaration — including a per-mode fallback chain (var(--x, var(--y))), and even when the alias declaration and its consumer sit in different rule blocks — back to a real hue before matching; terminates on a self- or mutually-referential cycle and doesn't add a chain branch that bottoms out in a non-var() literal (a --gradient-* stop) as a hue candidate. Proven red (pre-fix single-alias form: co-occurrence and ceiling checks both fire) then green (post-fix fallback-chain form: neither fires), both directions, against the real shipped shapes.

  1. String-concatenated var() construction, closed by OBJ-2928 (above; PR #2663, in review as of this writing) — a background built as 'var(' + token + ')' in TS/JS (Gradients.stories.ts's Storybook swatches) has no literal var(--name) substring for a regex scan to find.
  2. Inline style="..." attributes, also closed by OBJ-2928 — no scan tier read HTML style= attributes at all, so BrandOverview.vue's (pre-existing, unrelated) style="background: var(--gradient-hero);" sat entirely outside every guard's reach regardless of what it resolved to.
  3. Local CSS alias indirection, closed here — a file-local --alias: var(--hue) declaration is invisible to every hue-name matcher until it's resolved back to the hue it aliases, even when both halves of a tint/ink pair use the identical alias name.

11-alias census, full tree. Every local --alias: var(--other) declaration across all five SCAN_ROOTS was enumerated and individually verdicted — 2 genuinely new co-occurrence matches surfaced (admin_dashboard/src/style.css's --danger: var(--destructive) alias, feeding .btn-danger/.chip-danger's raw --danger ink over the same-hue tint — fixed, color: hsl(var(--destructive-accessible)), mirroring the file's own .chip-success/.chip-coach convention); the remaining 9 correctly produced no match for structurally distinct reasons (the property gate excludes non-background/color uses like filter: drop-shadow() and outline:; some resolve to a token with no discovered hue at all; --gradient-* chains bottom out in a non-hue by design, verified via a dedicated fixture rather than merely asserted). BrandOverview.vue's own --page-accent alias is structurally outside this guard's reach regardless — an inline style= attribute, not a {} block — and is covered instead by pageAccentInkMatrix.spec.ts (Orion's A6).

One measured residual, allowlisted rather than fixed. .page-header__icon's tile fill sits at 0.12 alpha over the .page-hero wash's own 0.14 — an effective composited alpha of 0.2432 under the glyph, above SHIPPED_CEILING[primary] (0.10), which is calibrated for the 4.5:1 text floor, not the 3:1 non-text floor this tile is actually held to. Worst measured 4.12:1 — real margin above the 3:1 floor it's held to. CEILING_ALLOWLIST records the reason rather than raising the ceiling itself.

Guarded going forward, durably. ionic_frontend/tests/unit/pageAccentInkMatrix.spec.ts (new) asserts every PAGE_ACCENTS route × both modes × both floors, parsed live from pageAccents.ts's own source text (never a hand-copied route table) — measured against the worse of --background and the wash, / measured separately against the --gradient-hero stops, plus a completeness assertion so a route added without ink values fails loud. tintInkGuard.spec.ts gained the alias-resolution fixtures above (red/green proof, cycle-safety, non-hue-chain termination) and the CEILING_ALLOWLIST entry above.

See DESIGN.md § Palette intent § Accessibility hard rules — Page-Accent Ink Contract (OBJ-2966) for the full ruling, docs/ui-specs/obj-2966-page-accent-ink.md for the per-route value table and rendered captures, and the [Unreleased] OBJ-2966 entry in CHANGELOG.md for the fix writeup.

OBJ-3557 (resolved, PR #3028) — the ledger's first SC 1.4.11 non-text-fill entry, not a text-ink one. Every entry above is a 4.5:1 text-on-fill defect; this one is two non-text bg-primary/bg-gradient-primary/bg-warning progress fills failing the 3:1 non-text floor in supporter themes — no ink sits on either fill. Surfaced during the OBJ-2318 primary-ink sweep and deliberately excluded there as a different defect class (a fill-against-fill contrast question needs its own token call, not --primary-ink).

  • The ticket's original target, OnboardingTour.vue:37's step dots, is dead code — no production caller, only its own Storybook story and unit spec import it (docs/milestones/v4.16-guided-product-tour.md:23 records it as "an orphaned tour component sat unused" once v4.16 generalized it into FeatureTour.vue). FeatureTour.vue:87 carries the identical defect in a worse form: its current-step segment's bg-gradient-primary measured 1.85:1 (forest light), its completed-step segments' bg-primary measured 2.88:1. Fixing the orphan would have shipped nothing to users.
  • While measuring, CommunityGoals.vue:192's bg-primary (2.88:1, reproducing the ticket's own figure) was found to have an adjacent, worse, theme-invariant violation one line down: :193's bg-warning measured 1.95:1, failing for every user on the default theme, not just supporters. Both are in scope; :191/:194 (bg-success/bg-muted-foreground) were already clean.
  • Judgment call, not automatic: CommunityGoals.vue:192 sits directly under its own percentage as text, which could argue the fill is redundant and exempt. Ruled not exempt — getProgressColor() keys the fill's hue to a progress band, a categorical read the adjacent percentage text doesn't carry, and a denser card variant that drops the label would silently make the fill the sole carrier. CommunityChallengeCard.vue:68 (2.55:1) is confirmed correctly aria-hidden and stays untouched.
  • Fix: all four sites resolve to the pre-existing --primary-accessible/--warning-accessible tokens (design_system/css/tokens.css:29/:174) — worst case 5.19:1/5.01:1 respectively. No new token; --primary-accessible reused as a fill here, not as ink under text (a different floor, needing its own measurement per DESIGN.md:731 — don't extend this ruling to that case). One rejected option worth recording: distinguishing the tour's current segment from completed ones via bg-primary-accessible opacity-60 composites to 2.49:1, reintroducing the exact failure (opacity-70 still fails at 2.98:1) — no stacked opacity on a measured fill.
  • Cost, accepted: the tour's current-step segment loses its gradient flourish; current-vs-completed is no longer colour-distinguished. Accepted because state is already carried three other ways on that surface — the visible "Step N of M" text, the aria-label="Step N of M", and the 40ms stagger — none of which this fix touches.
  • Guarded going forward. 4 new tokenDrift.spec.ts assertion families (24 concrete assertions across 10 themes × 2 modes) pin both changed fills and the two unchanged bands, so a future --success/--muted retune can't silently reopen the half this ticket doesn't touch. Same standing rule as every entry above: a green axe run is not evidence here — jest-axe in jsdom can't resolve hsl(var(--token)), so it silently skips color-contrast for token-driven colors entirely.
  • Resolved separately, by deletion, not a legend: whether CommunityGoals.vue's four-band progress colour needed a legend was routed as an IA question, OBJ-3561 — now resolved: the band function is deleted outright (getProgressColor() no longer exists on CommunityGoals.vue, which also supersedes the fix described above; see the OBJ-3561 entry in CHANGELOG.md and docs/features/communities.md § Goals Tab), not legended. The same four-band shape recurs at roughly 20 other sites repo-wide — MappingProposalRow.vue:27, PublicProfile.vue:182, MilestoneCard.vue:88 (GoalProgress.vue:89-91, a fourth example originally cited here, was deleted in the same OBJ-3561 change) — tracked as a systemic sweep, OBJ-3562 (backlog).

See .planning/phases/v4.35-obj-3557-non-text-fill-contrast/UI-SPEC.md for the full design contract and measurement table, and the [Unreleased] OBJ-3557 entry in CHANGELOG.md for the fix writeup.

OBJ-4019 — v4.66 Bound-Class Guard Reach (not a new violation class — a blind spot in the guards that enforce § v4.36/§ v4.37 above): both tintInkGuard.spec.ts and plainSurfaceInkGuard.spec.ts reached the element tree through one function each (tintInkTemplate.ts/plainSurfaceInkTemplate.ts's staticClassOf()), and both read only a static class="..." attribute — a :class/v-bind:class binding parses as a directive and was never looked at. TeamMemberRow.vue:35 (:class="member.isInactive ? 'text-warning' : ''") shipped past both guards' green builds as the filing evidence. Milestone closed complete on the literal tier: Phase 1 censused 776 live bindings and ruled the branch semantics (RULE-1 — mutually exclusive branches never pair across each other, fully co-occurring within one); Phase 2 shipped the shared resolver both guards now read through; Phases 4–5 swept the 17 raw-ink and 69 tint-bearing candidates the widening surfaced; Phase 6 re-asserted the completion gate at full strength and added a sanity assertion that the resolver still sees real bindings. The identifier tier (bare identifier/member-access resolution) was deliberately deferred to OBJ-4030 — CENSUS-3 found the 114 bare-identifier/member-access bindings carried zero of the known raw-ink or tint defects, so the milestone closed complete without it.

  • What the guards now see: a :class/v-bind:class binding whose expression is a string literal, ternary (including nested), object literal, array literal, a template literal with no interpolation, or a logical &&/??/|| — via the shared resolveElementClasses() resolver (ionic_frontend/tests/unit/helpers/boundClassResolver.ts), which both guard helpers now consume in place of their old static-only read. Each expression resolves to string[] branch alternatives; every existing hue predicate (restTintHues, rawInkHues, opaqueFillHueIn, …) runs once per alternative, never on the alternatives joined together — joining them would manufacture same-hue pairs that can never actually render.
  • The contributor-facing change, in one line: a hue written into a :class binding is governed exactly as one written into a static class string — reach for the -accessible sibling there too, not just in markup you type as a literal class="..." attribute.
  • What they still do not see, named rather than left implicit: a bare identifier, a member/index access, a call expression, or string concatenation inside a :class binding lands in a fourth unresolved bucket (alongside violations/exempt/review) instead of silently passing — tracked as OBJ-4030 for the same-file identifier-tier follow-on (CoachQuote.vue's containerClass/glyphClass computed strings are the acceptance case). One confirmed site stays unresolved even after OBJ-4030 ships, since that follow-on is scoped to same-file resolution only: MeetCoach.vue:127's PERSONA_CLASSES[selectedPersona as PersonaId]?.bg, whose root PERSONA_CLASSES is imported from coachPersonas.ts, not declared in the same file.
  • A separate, still-open residue in a different rule's guard: scanSolidFillTemplateAst() (ionic_frontend/tests/unit/helpers/tintInkTemplate.ts:547, governing the Solid-Fill Own-Foreground Floor, § OBJ-2669 above) still reads only a static class attribute — the identical defect shape this milestone closed, one function above it in the same file, for the v4.36/v4.37 rules. Every live :class-bound solid-fill site today happens to keep its full class list on one physical line, so the guard's per-line Tier B scanner catches them by coincidence, not by design. Tracked as OBJ-4054; not attempted here — out of scope for this milestone's rule.
  • Two items Phase 5 taught the tint/ink guard, beyond the binding reach itself: opaqueFillHueIn() now also recognizes a token-backed bg-gradient-<hue> utility as an opaque fill of <hue>, derived from the token's own resolved value across every theme × mode (never from the class name, so --gradient-coach-tint can never qualify merely because its name starts with gradient-coach); and the ceiling check (a bg-<hue>/N measured against SHIPPED_CEILING[hue]) is now element-scoped for a bound class, not just line-scoped — scanResolvedClassSites() joins a multi-line binding's tint and ink into one string per branch alternative before the ceiling check runs, reaching a pairing a per-physical-line scan can't.

See DESIGN.md § Palette intent § Accessibility hard rules — v4.36 Tint-Ink Contrast Floor and § v4.37 Plain-Surface Ink Floor for the binding-parity rule amendment this ships with, and the [Unreleased] OBJ-4019 entry in CHANGELOG.md for the full milestone writeup.

Marketing Site (marketing_landing/)

marketing_landing/ is a plain static site (no build step, no Vue, no package.json of its own) that ships two parallel stylesheets:

  • css/landing-2026.css — the modern sheet, HSL design tokens (index.html, meet-coach.html)
  • css/styles.css — a legacy sheet with its own :root token block, styling the .footer and page chrome on the other 9 public pages (about, contact, 404, and all 6 policies/*)

Both sheets must independently meet the same AA bar (4.5:1 normal text, 3:1 large text) as the app — text color contrast is not exempt just because a page is static HTML.

Why two stylesheets exist and aren't yet consolidated: the split predates the AA guard below and lets the same class of bug (a token or hardcoded hex that fails contrast) recur independently in either sheet — which is exactly what happened in OBJ-1422. Retiring styles.css in favor of landing-2026.css was deliberately descoped from that fix (a 9-page template migration is a different scope of work than a color-only contrast fix) and is tracked separately as OBJ-1423 (backlog, low priority — no active violations once the ramp above shipped, so it's a maintainability question, not a live bug). Read styles.css and landing-2026.css as two systems that happen to share a design language, not one system with an accidental duplicate, until OBJ-1423 resolves that.

The contrast guard

scripts/check-marketing-contrast.mjs is an axe-core color-contrast guard, wired to CI via .github/workflows/marketing-contrast-guard.yml (path-filtered on marketing_landing/** and the script itself).

It exists because a naive guard — one that only reads axe's violations bucket — misses real failures. axe puts any text it can't score itself (anything painted over a gradient or background-clip: text pseudo-ink) into a separate incomplete bucket instead. This guard resolves both:

  • Gradient-as-background — walks ancestors, alpha-composites every background layer (solid or gradient) until an opaque layer is hit, and scores against the worst gradient stop.
  • Gradient-as-ink (background-clip: text / -webkit-text-fill-color: transparent) — reads -webkit-text-fill-color before falling back to color, since some of the site's gradient headlines set only the former.
  • Scroll-reveal animations on both mechanisms the site uses (landing-2026.css's .reveal.in, styles.css's [data-animate].is-visible) are force-revealed before scanning — otherwise axe silently skips anything that starts at opacity: 0 and never scrolls into view in a headless run.

Run it locally:

bash
node --test scripts/check-marketing-contrast.test.mjs   # unit tests for the color math + allowlist matching
node scripts/check-marketing-contrast.mjs                # live scan against all 11 pages, needs playwright-core + axe-core (resolved from ionic_frontend/node_modules)

Exit code 0 means zero non-allowlisted AA color-contrast failures across all 11 public pages. logo-evolution/index.html is intentionally excluded — it loads neither stylesheet.

Allowlisted exceptions (named, tracked, not silently suppressed):

IssueWhatStatus
OBJ-1427--color-text-light on --gradient-hero (404/about/contact hero tint) — the token sits right at the AA floor on plain white (4.55:1) and dips under it on the hero gradient's lightest stop (4.41:1)Backlog. Root cause identified as a CSS cascade-specificity collision (.error-content > p beats the intended override) rather than a color choice — same bug class as two cascade fixes already made in styles.css during OBJ-1422, just not caught there. One-line fix once picked up.

A guard failure on any other node is a real regression — do not widen an allowlist entry to make CI green.

OBJ-1426 (resolved, PR #1563): white text on --gradient-primary's bright-cyan endpoint failed AA on seven live surfaces (CTA buttons, nav badge, gradient headlines), worst case 1.79:1. No longer allowlisted — the GRADIENT_PRIMARY_ISSUE branch was removed from this guard entirely, so these nodes are held to real AA and pass on their own. Fix: solid --color-primary-ink (#136699) for button/badge fills, a new --gradient-primary-ink token (ending #0E7490) for gradient-headline ink. See DESIGN.md § Palette intent § Brand blue for the "cyan is decoration-only" rule this locked in, and the [Unreleased] OBJ-1426 entry in CHANGELOG.md for the full fix writeup.

Design-token footprint: verify before you trust a spec's consumer count

During OBJ-1422's ramp, the UI-SPEC's rationale for darkening --color-text-muted said "its only live consumer renders at 0.9rem." It actually has 8 consumers in css/styles.css (.trust-text, .trust-subtext, .not-list-label, .pricing-note, .faq-item summary::after, .cta-subtext, .error-hint, .policy-content .effective-date) — not a defect in the shipped fix (darkening the token fixes all 8 as a side effect, which is strictly correct), but a reminder that a spec's stated blast radius for a shared token can undercount. Grep the token before assuming a doc's consumer list is exhaustive.

New Component Checklist

When creating a new component, verify:

  • [ ] All interactive elements have accessible text or aria-label
  • [ ] Color contrast meets WCAG AA (4.5:1 text, 3:1 large text)
  • [ ] Tap targets are >= 44px
  • [ ] Focus indicators are visible (focus-visible:ring-*)
  • [ ] Form fields have associated labels and error descriptions
  • [ ] Images have appropriate alt text
  • [ ] Animations respect prefers-reduced-motion
  • [ ] Component passes expectNoA11yViolations in tests
  • [ ] Storybook story shows no a11y addon violations

Last updated: 2026-09-10 (OBJ-2807 — added the podium rank numeral accessible-name entry to the App-Side AA-Violation Ledger) · Version: v4.8.3

Loading…