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-glowhave 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-glowacross all 20html-level blocks (:root'shtml.darkplus all 9 supporterhtml.dark[data-theme]blocks), plus both ofderiveCustomTheme()'slight/darkreturn blocks for Theme-Creator custom themes. - A new "anchor and travel" mechanism: each theme's gradient anchors one stop on its own
--primaryand travels 10 lightness points toward whichever direction increases contrast against that theme's--primary-foreground— riding the already-guarded--primary/--primary-foregroundinvariant instead of inventing a new one. - A reversed test assertion:
tokenDrift.spec.tsused 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 fromthemeContrast.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 20html[data-theme]×html.darkpermutations 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, barebg-/text-/border-/ring-/shadow-/via-primary-light|glow) — 12 defectiveadmin_dashboardfill sites fixed, 7 text-bearingfrom-primary to-primary-light/glowsites migrated tobg-gradient-primary, a staledesign_systemcatalog swatch corrected, and a latent Tailwind cascade-order bug (a staticbg-gradient-to-brclass 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:rootfallthrough 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
| Phase | Name | Status | Plans | Highlights |
|---|---|---|---|---|
| 1 | Re-baseline & gradient token contract | Shipped | 2 | Re-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. |
| 2 | Dark-mode token declarations + static guard | Shipped | 2 | Re-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. |
| 3 | Consumer reconciliation | Shipped | 1 | Verification-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. |
| 4 | Runtime verification | Shipped | 1 | One 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. |
| 5 | Prevention & docs | Shipped | 1 | Rewrote 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 flatbg-primaryon 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-primarybottomed 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-deepprecedent would have broken the 6 near-black themes. --gradient-accentand--gradient-brand-deepdeliberately 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/hslToRgb8from@/lib/customThemerather 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.tsis 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-accentcarries the identical dark-mode declaration gap--gradient-primaryhad; 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-whiteonbg-primaryfails AA in dark mode (11admin_dashboardsites plus a broader resting-state case, and a separate icon-on-gradient pair inionic_frontend'sTemplateCard.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
masterpromotion, where the blast radius is not near-zero.
Related Artifacts
- Roadmap: v4.31-gradient-primary-dark-mode-redesign-ROADMAP.md
- Milestone Audit: v4.31-gradient-primary-dark-mode-redesign-MILESTONE-AUDIT.md
- Design tokens contract: docs/development/design-tokens.md § The gradient family
- Triggering issue: OBJ-2075 (deferred out of v4.29 Phase 2 by that milestone's own pre-committed gradient carve-out)
- Merge PR: #2182
- Follow-ups: OBJ-2257 (
--gradient-accent), OBJ-2266 (hardcodedtext-whiteonbg-primary)
Related Commits
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 tomaster(PR #2182)7f347eae0— Flip v4.31 ledger row to shipped (PR #2185)
Last updated: 2026-08-08 · Version: v4.4.5