UI-SPEC — Page-accent ink contract (.page-header eyebrow + icon tile)
Issue: OBJ-2966 · Task package: Orion, comment 1d834c7a on OBJ-2966 (Task 1) · Suggested branch: fix/obj-2966-page-accent-ink-contractMockup: .planning/phases/obj-2966-page-accent-ink/mockups/page-accent-ink.html · 6 captures under mockups/screenshots/Split off from: OBJ-2928 §10
Scope
--page-accent (design_system/src/main.css:331) is one value, both modes, four consumers. Two of those consumers carry a contrast floor and neither has ever had a stated contract:
| Consumer | main.css | Role | Floor |
|---|---|---|---|
.page-header__eyebrow | :332-337 | 11px / 700 / uppercase — normal text under WCAG | 4.5:1 |
.page-header__icon (glyph color) | :338-344 | non-text graphic on its own 12% tile | 3:1 |
.page-header__icon (12% fill, 18% inset ring) | :341,:343 | decorative | none |
.page-header__rule | :348-352 | decorative hairline | none |
.page-hero::before | :356-360 | decorative 14% wash | none |
In scope: the colour contract for those two ink-bearing consumers, per route, per mode, across all ten design_system routes — including /, whose header is hand-rolled in BrandOverview.vue:76-79 and never passes through PageHeader.vue.
Out of scope: layout, type scale, the eyebrow strings, the rule and wash geometry, and the guard fix (Orion's task 3). No copy changes — every string on these headers stays exactly as it is.
Ruling 1 — the decorative accent keeps its name; a new ink sibling carries the floor
Adopted, per Orion's A1. --page-accent stays the page's identity colour and drives the wash, the tile fill, the inset ring and the rule, unchanged on every route. A new --page-accent-ink carries the text and glyph floors.
The alternative — recolouring --page-accent wholesale to whatever clears 4.5:1 — was rejected on measurement, not taste. /animations' gold at 43 96% 56% has to travel to 38 84% 27% to clear AA as text in light mode. Applying that to the wash and the tile would replace ten pages of deliberate colour with ten muddied ones to fix one 11px line. The wash and tile have no floor precisely because nothing reads them.
This is also the pattern already in the file. .nav-item (main.css:364-370) resolves --nav-accent from --nav-accent-light / --nav-accent-dark per theme, for exactly this reason: "resolved per theme so an accent that fails contrast in one mode can swap without touching the page hero." The page hero now does the same thing the nav rail already does.
Ruling 2 — the contract is per mode, not per route
Adopted, per Orion's A2. Three accents are raw literals that do not re-resolve in dark (222 20% 45%, 238 58% 52%, 175 65% 38%), which is exactly why /shadows passes light and fails dark while /dark-mode does the reverse. A single ink value cannot serve both modes for a literal accent. navLight / navDark solved this same problem in this same file; this mirrors their shape and their doc-comment convention.
Ruling 3 — one ink token serves both floors
The eyebrow and the tile glyph take the same --page-accent-ink. Two tokens were considered and rejected: the glyph sits one composited layer above the eyebrow's surface, so an ink that clears 4.5:1 against the wash necessarily clears 3:1 on the 12% tile over that wash — measured, worst case 4.12:1 at /colors light, 1.12 above the glyph floor. A second token would buy nothing and add a second thing that can drift.
Ruling 4 — the floor is measured against the wash, not against bare --background
This is the one place this spec departs from the issue's framing, and it changes the failure count.
.page-hero::before paints hsl(var(--page-accent) / 0.14) as a radial gradient centred at 12% 0% — the top-left corner, which is exactly where the eyebrow sits. The eyebrow's real backdrop is therefore the accent at α=0.14 over --background, not bare --background. The wash is same-hue with the ink, so it always moves contrast the wrong way.
Measured against the surface that is actually there:
- The issue reports 6 of 10 routes failing. Counting the wash, it is 10 of 10 — every route fails in at least one mode, 12 of 20 route×mode slots.
/spacinglight (4.79 bare → 3.98 on the wash) and/shadowslight (5.17 → 4.32) are new failures the bare-background measurement passes./is a new failure too, for a different reason: its hand-rolled hero has no wash but does havebackground: var(--gradient-hero), whose bottom stop203 78% 95%drops--primaryto 4.08:1. The issue's 4.35 measured it against a background that instance never renders on.
Every ratio in this spec is the worse of bare --background and the 14% wash (or, for /, the worse of the two --gradient-hero stops). Task 2's matrix spec must assert the same worst-of pair — asserting against bare --background alone would let /spacing and /shadows back through green.
Why axe-core never caught this either
A fourth false-negative mechanism, in a different guard from the three in Orion's package. Running axe-core's color-contrast rule over the rendered header returns zero violations and 20 incomplete nodes, every one with the reason:
Element's background color could not be determined due to a pseudo element
That pseudo-element is .page-hero::before. The same wash that causes the defect is what makes axe unable to see it. Automated runtime a11y checking will not cover this surface at any point, before or after the fix — which is what makes task 2's matrix spec the only real guard on these values.
Ruling 5 — the rule and the wash do not change
Confirmed, answering Orion's acceptance criterion 6: no. .page-header__rule, .page-hero::before, the 12% tile fill and the 18% inset ring all keep reading --page-accent and are byte-identical before and after. The captured mockup shows this directly — in every Now/Proposed pair the wash, tile and rule are indistinguishable and only the two ink-bearing elements move.
For the record, since a reviewer will ask: the 18% inset ring measures 1.07–1.28:1 against the tile it borders. That is fine. It is a decorative boundary on a decorative fill, not the visible boundary of a control, so SC 1.4.11 does not reach it. Do not "fix" it.
The contract
Type change — design_system/src/lib/pageAccents.ts
The missing doc contract on hero is the documented root cause, so the new fields must carry theirs. Mirror navLight / navDark:
export interface PageAccent {
/**
* Decorative page identity colour. Drives the .page-hero wash, the icon-tile
* fill, its inset ring and the header rule — none of which carries a contrast
* floor. NOT an ink: never put text or a meaningful glyph in this colour.
* Use heroInkLight / heroInkDark for that (OBJ-2966).
*/
hero: string
/**
* Header ink in light theme — the .page-header__eyebrow text AND the
* .page-header__icon glyph. Clears 4.5:1 against the WORSE of --background
* and the .page-hero 14% accent wash (the wash is what the eyebrow actually
* sits on); the glyph clears 3:1 on its 12% tile over that same wash.
*/
heroInkLight: string
/** Header ink in dark theme. Same two floors, same two surfaces. */
heroInkDark: string
/** Nav-rail accent in light theme. Clears 3:1 on --sidebar-accent and on the sidebar glass. */
navLight: string
/** Nav-rail accent in dark theme. Same floor. */
navDark: string
}Values — all ten routes, both modes
Ink expression, what it resolves to in the default theme, and the measured worst-surface ratio. L worst / D worst is the worse of bare --background and the 14% wash; tile is the glyph on its 12% tile over that wash.
| Route | heroInkLight | resolves | L worst (≥4.5) | heroInkDark | resolves | D worst (≥4.5) | L tile (≥3) | D tile (≥3) |
|---|---|---|---|---|---|---|---|---|
/ | var(--primary-accessible) | 203 78% 35% | 5.32 | var(--primary-accessible) | 203 78% 61% | 7.14 | — | — |
/colors | var(--primary-accessible) | 203 78% 35% | 4.73 | var(--primary-accessible) | 203 78% 61% | 6.19 | 4.12 | 5.01 |
/typography | var(--coach-accessible) | 271 72% 46% | 5.92 | var(--coach-accessible) | 271 91% 80% | 7.69 | 5.21 | 6.68 |
/spacing | var(--success-accessible) | 160 84% 20% | 6.20 | var(--success-accessible) | 160 84% 39% | 5.98 | 5.36 | 4.90 |
/shadows | 222 20% 42% | 222 20% 42% | 4.79 | 222 20% 62% | 222 20% 62% | 5.69 | 4.16 | 5.11 |
/gradients | var(--accent-accessible) | 27 89% 35% | 4.57 | var(--accent-accessible) | 27 89% 55% | 5.97 | 4.16 | 4.89 |
/dark-mode | 238 58% 52% | 238 58% 52% | 5.63 | 238 58% 72% | 238 58% 72% | 6.02 | 4.74 | 5.58 |
/animations | var(--gold-accessible) | 38 84% 27% | 5.87 | var(--gold-accessible) | 43 96% 70% | 10.05 | 5.58 | 7.64 |
/icons | 175 65% 27% | 175 65% 27% | 4.74 | 175 65% 38% | 175 65% 38% | 4.93 | 4.26 | 4.14 |
/components | var(--primary-accessible) | 203 78% 35% | 4.73 | var(--primary-accessible) | 203 78% 61% | 6.19 | 4.12 | 5.01 |
Worst text ratio in the set: 4.57:1 (/gradients light). Worst glyph ratio: 4.12:1 (/colors light, /components light). Both clear their floor; nothing in the table is borderline against the wrong floor.
/ has no icon tile — BrandOverview.vue's hero renders an eyebrow and a title only.
And what it replaces
| Route | Accent | L bare | L wash | L worst | D bare | D wash | D worst | L tile | D tile |
|---|---|---|---|---|---|---|---|---|---|
/ | var(--primary) | 4.08 | 4.08 | 4.08 ❌ | 7.14 | 7.14 | 7.14 | — | — |
/colors | var(--primary) | 4.34 | 3.63 | 3.63 ❌ | 7.69 | 6.19 | 6.19 | 3.16 | 5.01 |
/typography | var(--coach) | 3.78 | 3.20 | 3.20 ❌ | 4.77 | 4.12 | 4.12 ❌ | 2.82 ❌ | 3.58 |
/spacing | var(--success) | 4.79 | 3.98 | 3.98 ❌ | 7.28 | 5.98 | 5.98 | 3.44 | 4.90 |
/shadows | 222 20% 45% | 5.17 | 4.32 | 4.32 ❌ | 3.49 | 3.11 | 3.11 ❌ | 3.74 | 2.79 ❌ |
/gradients | var(--accent) | 2.51 | 2.21 | 2.21 ❌ | 7.18 | 5.97 | 5.97 | 2.01 ❌ | 4.89 |
/dark-mode | 238 58% 52% | 7.03 | 5.63 | 5.63 | 2.57 | 2.36 | 2.36 ❌ | 4.74 | 2.19 ❌ |
/animations | var(--gold) | 1.62 | 1.50 | 1.50 ❌ | 11.14 | 8.56 | 8.56 | 1.43 ❌ | 6.51 |
/icons | 175 65% 38% | 3.07 | 2.65 | 2.65 ❌ | 5.87 | 4.93 | 4.93 | 2.38 ❌ | 4.14 |
/components | var(--primary) | 4.34 | 3.63 | 3.63 ❌ | 7.69 | 6.19 | 6.19 | 3.16 | 5.01 |
12 of 20 text slots and 6 of 18 glyph slots fail today. The glyph failures were not in the issue's table at all — it flagged /gradients and /animations as suspected; the measured set is /typography light (2.82), /gradients light (2.01), /animations light (1.43), /icons light (2.38), /shadows dark (2.79) and /dark-mode dark (2.19). Orion's scope pushback was right, and larger than estimated.
Where the values come from
Seven routes take an existing -accessible sibling in both modes. No new colour is minted, and the ink tracks its family automatically if the family ever moves — which is the whole point of those tokens.
Three routes are raw literals with no sibling to reach for (Orion's A3). Six per-mode slots; four of them reuse a value that already exists in pageAccents.ts for the same route, so only two genuinely new values are introduced in the whole spec:
| Slot | Value | Provenance |
|---|---|---|
/shadows dark | 222 20% 62% | reused — byte-identical to that route's existing navDark |
/dark-mode light | 238 58% 52% | reused — byte-identical to hero / navLight; already clears both floors in light, no reason to move it |
/dark-mode dark | 238 58% 72% | reused — byte-identical to that route's existing navDark |
/icons dark | 175 65% 38% | reused — byte-identical to that route's existing hero; already clears both floors in dark |
/shadows light | 222 20% 42% | new — 3 points off the decorative anchor, same hue and saturation |
/icons light | 175 65% 27% | new — 11 points off, same hue and saturation |
Both new values follow one rule, applied identically: the shallowest whole-percent step along the same hue and saturation whose worst-surface ratio reaches 4.7:1. That target is not arbitrary — it is the headroom the token-backed routes land on (--primary-accessible measures 4.73 on the wash), so a hand-authored literal and a token-backed route hold the same margin. Both bottom out just under it at the next step up (/shadows at 43% → 4.64, /icons at 28% → 4.49), so neither is over-darkened.
/icons' existing navLight (175 65% 31%) was tried first and rejected: it measures 3.83 on the wash. It was tuned for a 3:1 sidebar floor and does not carry a 4.5:1 text floor. /shadows' hero (222 20% 45%) fails the same way at 4.32. Reusing a 3:1-floor value for a 4.5:1 role is the exact class of mistake this spec exists to close.
/gradients light is the thin one. --accent-accessible measures 4.57 on the wash — 0.07 over the floor. Kept anyway, deliberately: the wash is α=0.14, which is the accent family's registered SHIPPED_CEILING (tintInkContrast.ts:66), so this site is already inside the alpha envelope the existing matrix spec guards, and it moves with the token if the token ever moves. A hand-authored literal here would gain ~0.4 of headroom and cost the family link. Recorded so nobody "discovers" it later and quietly changes it.
Implementation shape
design_system/src/main.css
/* Consumes a per-page --page-accent (HSL triple) for decoration, and a paired
--page-accent-ink for the two consumers that carry a contrast floor. The ink
resolves per theme — mirroring .nav-item's --nav-accent below — because three
of the ten accents are raw literals that do not re-resolve in dark, so no
single ink value clears AA in both modes. (OBJ-2966) */
.page-header {
--page-accent: var(--primary);
--page-accent-ink: var(--page-accent-ink-light, var(--primary-accessible));
position: relative;
padding: 2.25rem 0 1.75rem;
}
html.dark .page-header {
--page-accent-ink: var(--page-accent-ink-dark, var(--primary-accessible));
}
.page-header__eyebrow { /* … */ color: hsl(var(--page-accent-ink)); }
.page-header__icon {
/* … */
background: hsl(var(--page-accent) / 0.12); /* unchanged — decorative */
color: hsl(var(--page-accent-ink)); /* was --page-accent */
box-shadow: inset 0 0 0 1px hsl(var(--page-accent) / 0.18); /* unchanged */
}The fallback pair matters: an unregistered route emits no custom properties at all, so both branches must land on a value that clears both floors. --primary-accessible does, in both modes (4.73 / 6.19). Do not fall back to --primary — that is the defect.
design_system/src/components/layout/PageHeader.vue
Per Orion's A4, look the entry up from useRoute().path and emit all three properties from that one lookup, rather than adding an accentInk prop that ten pages must thread through. Keep the existing accent prop as an override so the nine :accent="PAGE_ACCENTS['/x'].hero" bindings stay valid and untouched.
:style="{
'--page-accent': accent ?? entry.hero,
'--page-accent-ink-light': entry.heroInkLight,
'--page-accent-ink-dark': entry.heroInkDark,
}"<script setup lang="ts"> stays. This is the same shape SideNav.vue:94-95 already uses to emit --nav-accent-light / --nav-accent-dark.
Note on the accent override. If a caller passes accent but the route has no PAGE_ACCENTS entry, the ink falls back to --primary-accessible while the decoration takes the caller's colour. That is the correct failure mode — legible ink over a possibly-mismatched hue beats an illegible match — but say so in a comment so the next reader does not "fix" it into taking the caller's accent as ink.
design_system/src/pages/BrandOverview.vue:76-79 — the one that needs a hand
/ never touches PageHeader.vue. It hard-codes --page-accent: var(--primary) in a literal style= attribute and applies .page-header__eyebrow directly. Two things to fix, not one:
- It must emit the ink properties too, or it silently keeps the 4.08:1 eyebrow.
- It currently ignores
PAGE_ACCENTS['/']entirely — the map entry for/is dead for this header. Point the inline style at the map so/cannot drift from the registry the matrix spec asserts over. If the inlinestyle=string cannot carry a binding cleanly, a:styleobject is fine; this is the only markup change the spec asks for.
Its floor is different from the other nine and must be measured differently: no .page-hero wash, but background: var(--gradient-hero), so the surface is the worse of the two gradient stops — in light, the bottom stop 203 78% 95%, giving 5.32:1 for the proposed ink. Note the adjacency to OBJ-2928, which owns --gradient-hero ink coverage generally; this spec covers only this eyebrow on it.
State catalog — .page-header
The header has no interactive state of its own: the eyebrow is a <p>, the icon tile is a decorative <span>, neither is focusable or hoverable. The catalog is therefore short by nature, not by omission.
| State | Treatment |
|---|---|
| Default (light) | eyebrow hsl(var(--page-accent-ink)) at --page-accent-ink-light; tile fill hsl(var(--page-accent) / 0.12); ring / 0.18; glyph hsl(var(--page-accent-ink)); rule gradient --page-accent → transparent; wash / 0.14 |
| Default (dark) | identical markup; --page-accent-ink re-resolves to --page-accent-ink-dark via the html.dark rule. Decoration unchanged — the wash, tile, ring and rule read the same --page-accent in both modes, as they do today |
| Unregistered route | both properties absent; ink falls back to --primary-accessible, decoration to --primary. Legible, not correct-looking — task 2's completeness assertion is what makes this state unreachable in practice |
| Loading / empty / error | n/a — the header is static content rendered from props, with no async state |
| Focus / hover / active / disabled | n/a — no focusable or interactive descendant |
| Motion-reduced | unchanged. The .fade-up entrance on the header is pre-existing and untouched by this spec |
| Mobile (375px) | unchanged. Colour-only change; no layout, no reflow, no new element. Verified at 390px in the captures |
Microcopy
No copy changes. Every eyebrow string (FOUNDATION, BRAND, COMPONENTS) and every title stays exactly as it ships. This spec changes colour values and nothing else — flagged explicitly so a reviewer does not go looking for a copy table that should not exist.
Accessibility
- Contrast: 4.5:1 for the eyebrow (normal text: 11px, well under the 18.66px large-text threshold, so the 3:1 large-text allowance does not apply even at weight 700), 3:1 for the tile glyph. Both measured against the composited surface actually rendered, in both modes. All 20 text slots and all 18 glyph slots clear their floor after the change.
- Colour is not the only signal. The accent distinguishes pages decoratively; the eyebrow and title carry the same information as text. Nothing here depends on hue discrimination.
- Focus order, ARIA, keyboard: unchanged — no focusable element in the block. The tile glyph is decorative and already correctly
aria-hiddenvia lucide's default; the title carries the page name. - Motion: untouched.
prefers-contrast/ forced-colors: not addressed here and not regressed. Out of scope for this issue.
Storybook story brief
design_system has no Storybook (ionic_frontend owns it), so there is no story to scaffold. The equivalent visual ground truth for this change is the matrix spec in task 2 plus the rendered mockup in this spec's phase directory. Called out rather than left blank so nobody goes looking for a story that has nowhere to live.
Flow sequence
n/a — single surface, no sequence. See the throughline note in the handoff.
Acceptance criteria for Codi
- [ ]
PageAccentgainsheroInkLight/heroInkDark, each with a doc comment stating its floor and its surface (worse of--backgroundand the 14% wash) — the missing contract is the root cause, so a value without a stated contract does not close this - [ ] All ten routes carry both values, byte-identical to the table above
- [ ]
.page-header__eyebrowand.page-header__icon'scolorconsume--page-accent-ink; the tile fill, inset ring, rule and wash still consume--page-accentand are byte-identical to master - [ ]
.page-headerand itshtml.darksibling both declare an ink fallback that clears both floors (--primary-accessible), not--primary - [ ]
PageHeader.vueemits all three custom properties from a singleuseRoute().pathlookup; the existing:accentbindings on the nine pages still work untouched - [ ]
BrandOverview.vue's hand-rolled header emits the ink properties and readsPAGE_ACCENTS['/']instead of hard-codingvar(--primary) - [ ] Matrix spec asserts, driven off
PAGE_ACCENTSitself: for every key × both modes, eyebrow ink ≥4.5:1 against the worse of--backgroundandhsl(<hero> / 0.14)over--background, and tile glyph ≥3:1 onhsl(<hero> / 0.12)over that wash. Asserting against bare--backgroundalone lets/spacingand/shadowsback through green - [ ]
/'s case is asserted against the--gradient-herostops, not the wash — a single shared assertion shape will get this route wrong - [ ] Completeness assertion: every
PAGE_ACCENTSkey has both ink values, so a route added without them fails loud - [ ] All ratios computed live via
tintInkContrast.tshelpers — no hand-copied constants - [ ]
pnpm -C design_system buildpasses; no visual change to rule, wash, tile fill or inset ring
Notes for task 3 (the guard) — three things this fix changes underfoot
Not scope creep; these are facts about the surface the guard is being taught to see, discovered while measuring it.
- The alias pair gets harder, not easier. After this fix,
main.css's tint/ink pair ishsl(var(--page-accent) / 0.12)againsthsl(var(--page-accent-ink))— two different aliases, one of which resolves through avar(--x, var(--y))fallback chain. The resolver needs to handle the fallback form, not just--alias: var(--other). Task 3's acceptance criterion 1 (guard goes red on the original defect) should be proven against the pre-fix single-alias form; the post-fix form is the "and now it is green" half. - The literal-valued routes are invisible to any static scan.
/shadows,/dark-modeand/iconsinject raw HSL triples at runtime that belong to no hue family. The static guard cannot classify them at all — reinforcing Orion's A6. The matrix spec is the only thing that covers those three, so do not let it be dropped as redundant with the guard. SHIPPED_CEILINGunderstates this surface's real alpha. The tile is 12% over the 14% wash, so the accent's effective composited alpha under the glyph is 0.2432, not 0.12. That exceeds the registered ceiling forprimary(0.10) andaccent(0.14) and equals-or-exceedscoach/gold/success(0.20). Nothing fails — every value in the table above is measured at the real composited alpha and clears — but the map claims a lower shipped alpha than the tree actually renders. Worth a ceiling update in the same PR, or a recorded note if that widens blast radius past task 3's escape hatch.
Open questions
None blocking. Every value is measured and every route is named.