Skip to content

v4.31 — Gradient Primary Dark-Mode Redesign

The brand gradient — --gradient-primary, --primary-light, --primary-glow — now has a real dark-mode value in every theme, instead of silently wearing each theme's light-mode gradient on a dark page.

Summary

--gradient-primary, --primary-light, and --primary-glow had no dark-mode declaration anywhere: not in design_system/css/tokens.css's html.dark block, not in any of the 9 html.dark[data-theme="…"] blocks in ionic_frontend/src/theme/supporter-themes.css, and not in deriveCustomTheme's dark return block. This is the same silent-fallthrough class of defect as the undefined --primary-interactive that v4.26 and v4.29 fixed — one token family over, and the one family neither of those milestones was scoped to touch.

The milestone's most valuable finding came from a design-engineering cross-check, not a new measurement tool. The triggering issue (OBJ-2075) and this milestone's own kickoff ROADMAP both assumed every theme's dark-mode gradient fell through to :root's un-themed Default blue. Desi disproved that in Phase 1, in a real browser with getComputedStyle(): a Supporter theme's html[data-theme="x"] block is not scoped away from dark mode, so 9 of 10 themes actually served their own light-mode gradient on a dark page — stops tuned for a white background, rendered on 222 47% 8%. Only the Default theme, which has no [data-theme] block, ever reached :root. The real baseline was 20 of 80 failing WCAG AA cells, not the originally-cited 26 — and the correction surfaced a second, non-contrast defect no contrast checker would ever catch: the ad-hoc from-primary to-primary-light gradient pair collapsed to a flat fill with no gradient at all in dark mode, in all 10 themes, because every theme's dark --primary was byte-identical to its light --primary-light.

No ink swap could fix either failure — both inks this codebase has (white, and same-hue near-black) were tried against every failing cell's worst stop, and neither cleared 4.5:1 on both stops anywhere; the closest miss was 4.36, 0.14 short. This made the milestone a token redesign, not a remediation: --gradient-primary now anchors one stop exactly on that theme's --primary and places the other stop 10 lightness points away, in whichever direction increases contrast against that theme's own --primary-foreground — direction chosen per-theme, not globally, since 6 themes need to travel lighter and 4 need to travel deeper. The structurally-unfixable from-primary to-primary-light pair was retired on text-bearing surfaces only; it remains sanctioned on purely decorative fills (progress bars, tints), where it holds no text and needs no ink guarantee.

Goal

--gradient-primary, --primary-light, and --primary-glow have no dark-mode declaration anywhere... The measured contrast consequence is 20 AA-failing cells out of 80 (4 gradient shapes actually used in the codebase × 10 themes × 2 modes)... No ink swap fixes any of it... That is why this is a redesign and not a remediation.

.planning/milestones/v4.31-gradient-primary-dark-mode-redesign-ROADMAP.md

Scope — What Shipped

  • New dark-mode token declarations for --gradient-primary, --primary-light, --primary-glow across all 20 html-level blocks (:root's html.dark plus all 9 supporter html.dark[data-theme] blocks), plus both of deriveCustomTheme()'s light/dark return blocks for Theme-Creator custom themes.
  • A new "anchor and travel" mechanism: each theme's gradient anchors one stop on its own --primary and travels 10 lightness points toward whichever direction increases contrast against that theme's --primary-foreground — riding the already-guarded --primary/--primary-foreground invariant instead of inventing a new one.
  • A reversed test assertion: tokenDrift.spec.ts used to assert the dark-mode gap "must remain" (OBJ-1960 A4); that assertion now asserts the opposite, with the reversal's rationale recorded in the test and in the docs.
  • A new static guard, ionic_frontend/tests/unit/gradientPrimaryGuard.spec.ts, asserting declaration presence per block and a worst-stop ≥4.5:1 predicate per gradient — a different shape from themeContrast.spec.ts's 40-pair contract, since a gradient's failure mode is its weakest stop, not a single foreground/background pair.
  • A new real-browser guard, ionic_frontend/tests/e2e/specs/accessibility/gradient-runtime-verification.cy.ts, driving all 20 html[data-theme] × html.dark permutations and asserting the resolved dark-mode gradient equals that theme's own dark-block value — never an inequality against :root, which the Phase-1 correction showed would pass vacuously for 9 of 10 themes.
  • Consumer reconciliation across all five usage shapes (Tailwind class, raw var() in template/<style>, .stories.ts, bare bg-/text-/border-/ring-/shadow-/via-primary-light|glow) — 12 defective admin_dashboard fill sites fixed, 7 text-bearing from-primary to-primary-light/glow sites migrated to bg-gradient-primary, a stale design_system catalog swatch corrected, and a latent Tailwind cascade-order bug (a static bg-gradient-to-br class silently fighting a dynamic gradient binding) caught and fixed along the way.
  • Rewritten documentation: docs/development/design-tokens.md's "The gradient family" section, previously three-ways-false ("not yet fixed", the superseded :root fallthrough reading, the 26-cell count), now documents the shipped contract with the corrected general rule and a labelled note on what the page used to say and why it was wrong.

Phases

PhaseNameStatusPlansHighlights
1Re-baseline & gradient token contractShipped2Re-measured the matrix against master, disproved the milestone's own founding premise (20/80 cells, not 26/80), chose the anchor-and-travel mechanism, and implemented the full token contract — closing the actual defect a phase early.
2Dark-mode token declarations + static guardShipped2Re-scoped in place after Phase 1's absorption: fixed the stale catalog swatch, closed a real admin_dashboard consumer gap the original census had wrongly counted as zero, and shipped the worst-stop guard — demonstrated failing on the pre-fix tree by two independent agents before being trusted.
3Consumer reconciliationShipped1Verification-only for admin (Phase 2 had already closed it); migrated the remaining text-bearing gradient sites, caught a latent Tailwind cascade bug a naive class-swap would have shipped silently wrong, and flagged (for Phase 5 to fix) a three-ways-false docs section.
4Runtime verificationShipped1One new file, zero production-code changes. Proved the resolved dark-mode cascade equals each theme's own dark-block value across all 20 permutations in a real browser — the tier a static guard structurally cannot cover. Found and solved a reusable axe-on-Ionic viewport-scroll trap along the way.
5Prevention & docsShipped1Rewrote the gradient section of docs/development/design-tokens.md from a three-ways-false state to the shipped contract, with a labelled "what this used to say and why it was wrong" note. Recorded both deferred follow-ups by issue ID.

A sixth, non-phase stage (OBJ-2271) promoted the integration branch to master via PR #2182 — see Related Artifacts.

Key Decisions

  • Anchor-and-travel over a dedicated ink token or gradient retirement — Phase 1 evaluated three mechanisms (narrow-and-anchor the existing stops, a dedicated gradient-ink token mirroring --primary-accessible, or retiring gradient fills for flat bg-primary on text-bearing surfaces) and chose a bounded slice of the first plus the third: anchor-and-travel for the token itself, and gradient retirement only on the 7 text-bearing sites the pair structurally couldn't clear. --gradient-brand-deep (tokens.css:143, OBJ-1994) was the in-repo existence proof that narrowing and deepening a gradient's spread clears AA — its stops measure 4.54 worst-case against white, where the original --gradient-primary bottomed out at 3.12.
  • Direction is per-theme, not global — 6 themes ship a near-black same-hue ink and needed to travel lighter; the 4 white-ink themes (default, midnight, ember, slate) needed to travel deeper. A naive "deepen everything" reading of the --gradient-brand-deep precedent would have broken the 6 near-black themes.
  • --gradient-accent and --gradient-brand-deep deliberately excluded — both carry a smaller version of the same defect shape, but folding them in would have doubled Desi's derivation matrix before the primary mechanism was proven. --gradient-accent's 2-of-20-cell Midnight failure, measured in Phase 1, is tracked as follow-up OBJ-2257.
  • No new contrast math — every phase imported contrastRatio/relativeLuminance/hslToRgb8 from @/lib/customTheme rather than re-implementing gradient contrast logic, per the ROADMAP's non-negotiable pattern.
  • A gradient's contrast predicate is worst-stop, not endpoint-pair — deliberately kept out of themeContrast.spec.ts's 40-pair assertion. gradientPrimaryGuard.spec.ts is a structurally different check: the minimum contrast across every stop in a multi-stop gradient against one foreground, not a pair comparison.

Requirements Coverage

This milestone has no standalone REQUIREMENTS.md — it is a defect-fix milestone hand-authored per this repo's planning canon, with coverage tracked against the ROADMAP's phase-by-phase acceptance criteria and the promotion issue's own acceptance list. 12 / 12 criteria satisfied — see the Milestone Audit for the full table.

Outcomes

bg-gradient-primary and the migrated from-primary to-primary-light/glow sites now render a dark-mode-tuned gradient across all 9 Supporter themes plus Default, instead of silently wearing each theme's light-mode gradient on a dark page. Every progress bar and text-bearing surface that previously relied on the ad-hoc pair renders a real gradient in dark mode — not the flat fill the byte-identical --primary/--primary-light collision was silently producing before. A future author adding a token to this family (or the next one) has a general rule to work from — docs/development/design-tokens.md states the corrected fallthrough mechanism explicitly, not just for this token family — and two guards (gradientPrimaryGuard.spec.ts, gradient-runtime-verification.cy.ts) that fail loudly if the gap reopens.

Tech Debt

  • (Follow-up, backlog) OBJ-2257 — --gradient-accent carries the identical dark-mode declaration gap --gradient-primary had; measured in Phase 1 (2 of 20 cells fail, both Midnight, both modes), deliberately not fixed in this milestone.
  • (Follow-up, backlog) OBJ-2266 — hardcoded text-white on bg-primary fails AA in dark mode (11 admin_dashboard sites plus a broader resting-state case, and a separate icon-on-gradient pair in ionic_frontend's TemplateCard.vue/TemplatePickerModal.vue), found while measuring Phase 2's admin fix and correctly refused as out of this milestone's scope.
  • (Process) Phases 3 and 4's Multica issues have no written release-gate record (CI state, rollback plan) — both phases' merges are confirmed via the git record rather than a posted SHIP IT comment. Non-blocking for an integration-branch-only merge; flagged in case it recurs on a future milestone's master promotion, where the blast radius is not near-zero.
  • 69a727151 — Phase 1: re-baseline + gradient token contract (PR #2160)
  • 40430dac7 — Phase 2: dark-mode token declarations + static guard (PR #2167)
  • af62cca08 — Phase 3: consumer reconciliation (PR #2169)
  • 805aa2223 — Phase 4: runtime verification (PR #2174)
  • aec6c4ee7 — Phase 5: prevention & docs (PR #2177)
  • 59f2898b5 — Promote v4.31 integration branch to master (PR #2182)
  • 7f347eae0 — Flip v4.31 ledger row to shipped (PR #2185)

Last updated: 2026-08-08 · Version: v4.4.5

Loading…