Skip to content

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:

Consumermain.cssRoleFloor
.page-header__eyebrow:332-33711px / 700 / uppercase — normal text under WCAG4.5:1
.page-header__icon (glyph color):338-344non-text graphic on its own 12% tile3:1
.page-header__icon (12% fill, 18% inset ring):341,:343decorativenone
.page-header__rule:348-352decorative hairlinenone
.page-hero::before:356-360decorative 14% washnone

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.
  • /spacing light (4.79 bare → 3.98 on the wash) and /shadows light (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 have background: var(--gradient-hero), whose bottom stop 203 78% 95% drops --primary to 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:

ts
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.

RouteheroInkLightresolvesL worst (≥4.5)heroInkDarkresolvesD worst (≥4.5)L tile (≥3)D tile (≥3)
/var(--primary-accessible)203 78% 35%5.32var(--primary-accessible)203 78% 61%7.14
/colorsvar(--primary-accessible)203 78% 35%4.73var(--primary-accessible)203 78% 61%6.194.125.01
/typographyvar(--coach-accessible)271 72% 46%5.92var(--coach-accessible)271 91% 80%7.695.216.68
/spacingvar(--success-accessible)160 84% 20%6.20var(--success-accessible)160 84% 39%5.985.364.90
/shadows222 20% 42%222 20% 42%4.79222 20% 62%222 20% 62%5.694.165.11
/gradientsvar(--accent-accessible)27 89% 35%4.57var(--accent-accessible)27 89% 55%5.974.164.89
/dark-mode238 58% 52%238 58% 52%5.63238 58% 72%238 58% 72%6.024.745.58
/animationsvar(--gold-accessible)38 84% 27%5.87var(--gold-accessible)43 96% 70%10.055.587.64
/icons175 65% 27%175 65% 27%4.74175 65% 38%175 65% 38%4.934.264.14
/componentsvar(--primary-accessible)203 78% 35%4.73var(--primary-accessible)203 78% 61%6.194.125.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

RouteAccentL bareL washL worstD bareD washD worstL tileD tile
/var(--primary)4.084.084.087.147.147.14
/colorsvar(--primary)4.343.633.637.696.196.193.165.01
/typographyvar(--coach)3.783.203.204.774.124.122.82 ❌3.58
/spacingvar(--success)4.793.983.987.285.985.983.444.90
/shadows222 20% 45%5.174.324.323.493.113.113.742.79 ❌
/gradientsvar(--accent)2.512.212.217.185.975.972.01 ❌4.89
/dark-mode238 58% 52%7.035.635.632.572.362.364.742.19 ❌
/animationsvar(--gold)1.621.501.5011.148.568.561.43 ❌6.51
/icons175 65% 38%3.072.652.655.874.934.932.38 ❌4.14
/componentsvar(--primary)4.343.633.637.696.196.193.165.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:

SlotValueProvenance
/shadows dark222 20% 62%reused — byte-identical to that route's existing navDark
/dark-mode light238 58% 52%reused — byte-identical to hero / navLight; already clears both floors in light, no reason to move it
/dark-mode dark238 58% 72%reused — byte-identical to that route's existing navDark
/icons dark175 65% 38%reused — byte-identical to that route's existing hero; already clears both floors in dark
/shadows light222 20% 42%new — 3 points off the decorative anchor, same hue and saturation
/icons light175 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

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:

  1. It must emit the ink properties too, or it silently keeps the 4.08:1 eyebrow.
  2. 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 inline style= string cannot carry a binding cleanly, a :style object 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.

StateTreatment
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 routeboth 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 / errorn/a — the header is static content rendered from props, with no async state
Focus / hover / active / disabledn/a — no focusable or interactive descendant
Motion-reducedunchanged. 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-hidden via 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

  • [ ] PageAccent gains heroInkLight / heroInkDark, each with a doc comment stating its floor and its surface (worse of --background and 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__eyebrow and .page-header__icon's color consume --page-accent-ink; the tile fill, inset ring, rule and wash still consume --page-accent and are byte-identical to master
  • [ ] .page-header and its html.dark sibling both declare an ink fallback that clears both floors (--primary-accessible), not --primary
  • [ ] PageHeader.vue emits all three custom properties from a single useRoute().path lookup; the existing :accent bindings on the nine pages still work untouched
  • [ ] BrandOverview.vue's hand-rolled header emits the ink properties and reads PAGE_ACCENTS['/'] instead of hard-coding var(--primary)
  • [ ] Matrix spec asserts, driven off PAGE_ACCENTS itself: for every key × both modes, eyebrow ink ≥4.5:1 against the worse of --background and hsl(<hero> / 0.14) over --background, and tile glyph ≥3:1 on hsl(<hero> / 0.12) over that wash. Asserting against bare --background alone lets /spacing and /shadows back through green
  • [ ] /'s case is asserted against the --gradient-hero stops, not the wash — a single shared assertion shape will get this route wrong
  • [ ] Completeness assertion: every PAGE_ACCENTS key has both ink values, so a route added without them fails loud
  • [ ] All ratios computed live via tintInkContrast.ts helpers — no hand-copied constants
  • [ ] pnpm -C design_system build passes; 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.

  1. The alias pair gets harder, not easier. After this fix, main.css's tint/ink pair is hsl(var(--page-accent) / 0.12) against hsl(var(--page-accent-ink)) — two different aliases, one of which resolves through a var(--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.
  2. The literal-valued routes are invisible to any static scan. /shadows, /dark-mode and /icons inject 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.
  3. SHIPPED_CEILING understates 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 for primary (0.10) and accent (0.14) and equals-or-exceeds coach / 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.

Loading…