docs: add Phase 18 theme/dark-mode remediation plan + product note
This commit is contained in:
@@ -342,6 +342,44 @@ the open-question set: `product-notes/phase-17-player-queue-view.md`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Phase 18 — Theme / Dark-Mode Remediation (DRY token pass)
|
||||||
|
|
||||||
|
A punch-list of six theming symptoms Daniel reported — five in dark mode, one in light —
|
||||||
|
that all trace to **three** root causes in how component/page CSS bypasses the theme-aware
|
||||||
|
token layer and binds *constant* source tokens instead. Resolved as one coherent token pass,
|
||||||
|
not six per-component patches. Full design, architecture map, root-cause analysis, token
|
||||||
|
table, and track breakdown: `product-notes/theme-dark-mode-remediation.md`.
|
||||||
|
|
||||||
|
**Root-cause collapse (six symptoms → three causes):**
|
||||||
|
- **Cause 1 — neutral surfaces don't invert.** Home hero-left + footer (#3) and About light
|
||||||
|
sections (#4) hardcode `background: var(--deepdrft-white)` / text on `--deepdrft-navy` —
|
||||||
|
brand *constants* that are identical in `:root` and `.deepdrft-theme-dark`, so they cannot
|
||||||
|
flip. Fix: bind a theme-aware `--deepdrft-page-surface` / `--deepdrft-page-text` alias. The
|
||||||
|
inversion must stay **neutral to the intentionally navy/green decorative sections**
|
||||||
|
(`.section-dark`, `.split-left`, `.cta-banner`, hero overlays) — a classify-then-recolor job.
|
||||||
|
- **Cause 2 — play chip binds a constant grey.** `PlayStateIcon.razor.css` `.icon-container`
|
||||||
|
hardcodes `--deepdrft-soft` (#e3e7ec). One shared component drives the release-hero chip, the
|
||||||
|
Cut track rows, *and* the player bar — so it reads "greyed-out" over dark heroes (#5) and "too
|
||||||
|
bright" on the navy player surface (#6). Fix: theme-aware `--deepdrft-play-chip` (moss-green +
|
||||||
|
navy glyph in dark) with a translucent `--deepdrft-play-chip-soft` override for the player bar.
|
||||||
|
- **Cause 3 — no theme-aware popover surface.** Light-mode default MudPopovers read "too dark"
|
||||||
|
(#1); there's no token for the wanted "desaturated navy." Fix: a `--deepdrft-popover-surface`
|
||||||
|
token; leave the bespoke `--deepdrft-panel-ground` panels alone.
|
||||||
|
|
||||||
|
**Sequenced as four tracks, `T1 → {T2, T3, T4}`.** T1 (additive token foundation in
|
||||||
|
`deepdrft-tokens.css`) is the cold-start prerequisite; T2 (neutral-surface inversion), T3
|
||||||
|
(play-chip theming), T4 (popover token) fan out behind it and are mutually independent. Pure
|
||||||
|
CSS-token pass — no source code, data layer, or streaming-seam changes. Prior art:
|
||||||
|
`product-notes/track-card-theming.md` solved this exact class of theme-aware recolor once
|
||||||
|
already; this generalizes the fix from one component to the pattern.
|
||||||
|
|
||||||
|
**Open questions for Daniel (spec §5):** (1) dark neutral surface = navy *ground* (continuous
|
||||||
|
field — recommended for footer/hero) vs. *elevated* navy-mid (distinct panels); (2) popover
|
||||||
|
target distance from white (recommend a light `color-mix(navy ~8%, white)` wash). Exact green
|
||||||
|
opacity + muted-text mixes are tune-on-screen details, not decision gates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Working with this file
|
## Working with this file
|
||||||
|
|
||||||
- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 1–5. Phase numbers are organisational, not sequencing.
|
- **Add items by extending an existing phase first**; only create a new phase when the addition genuinely doesn't fit any of 1–5. Phase numbers are organisational, not sequencing.
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
# Theme / Dark-Mode Remediation — DRY token pass
|
||||||
|
|
||||||
|
Status: proposed. Author: product-designer. Date: 2026-06-19. Implementer: TBD (separate delegation).
|
||||||
|
|
||||||
|
A design analysis of the DeepDrft theme system, focused on the dark theme, with a DRY
|
||||||
|
remediation plan that resolves a punch-list of six reported symptoms through **shared
|
||||||
|
theme tokens** rather than per-component patches. Daniel reported the symptoms; this note
|
||||||
|
maps the architecture, isolates the root causes, and sequences the fix.
|
||||||
|
|
||||||
|
Prior art this borrows from: `product-notes/track-card-theming.md` (landed 2026-06-05) —
|
||||||
|
the same class of problem (theme-aware recolor under `.deepdrft-theme-dark`, legible in
|
||||||
|
both palettes) solved once already with the same mechanism. This note generalizes that
|
||||||
|
fix from one component to the recurring pattern behind it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. How the theme system is wired today (the map)
|
||||||
|
|
||||||
|
There are **three** colour layers, and the bugs all live in how the third one bypasses the
|
||||||
|
first two.
|
||||||
|
|
||||||
|
### Layer A — MudBlazor palettes (C#)
|
||||||
|
`DeepDrftShared.Client/Common/DeepDrftPalettes.cs` defines `PaletteLight Light`,
|
||||||
|
`PaletteDark Dark` (+ `CmsLight`, `EmbedLight`, `EmbedDark`). `MainLayout.razor` mounts
|
||||||
|
`<MudThemeProvider Theme="DeepDrftPalettes.Default" IsDarkMode="_isDarkMode" />`. MudBlazor
|
||||||
|
injects these as `--mud-palette-*` CSS variables that **flip automatically** when
|
||||||
|
`IsDarkMode` toggles. This is the part that works: anything reading `--mud-palette-surface`,
|
||||||
|
`--mud-palette-background`, `--mud-palette-text-primary` inverts correctly for free.
|
||||||
|
|
||||||
|
### Layer B — DeepDrft design tokens (CSS)
|
||||||
|
`DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` defines two token families:
|
||||||
|
|
||||||
|
- **Source tokens** — raw brand colours, *constant across both themes*:
|
||||||
|
`--deepdrft-navy (#112338)`, `--deepdrft-white (#FAFAF8)`, `--deepdrft-green-accent
|
||||||
|
(#3D7A68)`, `--deepdrft-soft (#e3e7ec)`, etc. These never change between light and dark.
|
||||||
|
- **Theme-aware aliases** — `--theme-surface`, `--theme-surface-soft`, `--theme-primary…senary`,
|
||||||
|
`--gradient-base/accent/warm/light`, `--deepdrft-surface`, `--deepdrft-background`. These
|
||||||
|
**are** redefined inside the `.deepdrft-theme-dark` block (the wrapper class
|
||||||
|
`MainLayout.ThemeWrapperClass` puts on the root div), so they flip.
|
||||||
|
|
||||||
|
The token file's own header comment establishes the intended discipline: source tokens are
|
||||||
|
"source of truth"; theme-aware aliases are what page CSS is *supposed* to consume so it
|
||||||
|
"resolve[s] coherently across themes."
|
||||||
|
|
||||||
|
### Layer C — component / page CSS
|
||||||
|
Scoped `*.razor.css` files and the global `DeepDrftPublic/wwwroot/styles/deepdrft-styles.css`.
|
||||||
|
**This is where the discipline breaks.** Page sections that should track the theme surface
|
||||||
|
instead reach straight past Layer B and bind to the *constant source tokens* of Layer A
|
||||||
|
(`--deepdrft-white`, `--deepdrft-navy`, `--deepdrft-soft`). A constant cannot invert — so
|
||||||
|
these surfaces stay light-on-navy-site no matter the mode.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Root causes (six symptoms → three causes)
|
||||||
|
|
||||||
|
The six reported symptoms collapse to **three** root causes. That collapse is the whole
|
||||||
|
point of doing this as one coherent pass rather than six patches.
|
||||||
|
|
||||||
|
### Cause 1 — "neutral surface" sections bind to constant source tokens, so they never invert
|
||||||
|
*(Symptoms: Home hero-left + footer (#3); About light sections (#4))*
|
||||||
|
|
||||||
|
These rules are the smoking gun (all bind a constant, not a theme alias):
|
||||||
|
|
||||||
|
- `Home.razor.css` — `.hero-left`, `.section`, `.section-divider`, `.section-body p`,
|
||||||
|
`.medium-card`, `.split-right`, `.connect-*` → `background: var(--deepdrft-white)`,
|
||||||
|
text `color: var(--deepdrft-navy)`.
|
||||||
|
- `About.razor.css` — `.hero-left`, `.hero-image-pane`, `.bio`/process gradients →
|
||||||
|
`background: var(--deepdrft-white)`, text on `--deepdrft-navy`.
|
||||||
|
- `DeepDrftFooter.razor.css` — `.deepdrft-footer` → `background: var(--deepdrft-white)`,
|
||||||
|
logo/links text on `--deepdrft-navy` / `--deepdrft-muted`.
|
||||||
|
|
||||||
|
`--deepdrft-white` is `#FAFAF8` in **both** `:root` and `.deepdrft-theme-dark` — it is a
|
||||||
|
brand constant, never re-aliased. So in dark mode these read as bright off-white panels with
|
||||||
|
dark text floating in a navy site. The fix is **not** to hardcode a dark colour; it is to
|
||||||
|
**bind these surfaces to a theme-aware alias** that already inverts.
|
||||||
|
|
||||||
|
**Critical nuance Daniel flagged:** the fix must be *neutral to the existing navy and green
|
||||||
|
accent sections.* The page already has sections that are **intentionally** navy/green in
|
||||||
|
both modes — `.section-dark` (navy), `.split-left` (green), `.cta-banner` (navy), the
|
||||||
|
`ReleaseHeroOverlay` (dark image). Those are decorative-by-design and must **not** be touched
|
||||||
|
by the inversion. Only the "default page surface" sections (the ones currently white-because-
|
||||||
|
light) should flip. This is a *classification* problem first, a recolor second: separate
|
||||||
|
"neutral surface" from "decorative accent" and only re-token the former.
|
||||||
|
|
||||||
|
### Cause 2 — the play-icon chip background binds `--deepdrft-soft` (constant light grey)
|
||||||
|
*(Symptoms: greyed-out play icon on release heroes / track lists (#5); too-bright player-bar play button (#6))*
|
||||||
|
|
||||||
|
`PlayStateIcon.razor.css` `.icon-container` hardcodes `background-color: var(--deepdrft-soft)`
|
||||||
|
(`#e3e7ec` — a light grey, constant across both themes). `PlayStateIcon` is the **single**
|
||||||
|
glyph component used by the release heroes, the Cut track rows, *and* the player bar. So one
|
||||||
|
constant drives all of these:
|
||||||
|
|
||||||
|
- Over a **dark hero image / navy track list** → the light-grey chip reads dull and
|
||||||
|
"greyed-out" (#5). Daniel wants: **moss-green chip background, navy play glyph** in dark mode.
|
||||||
|
- On the **bright player-surface** → the same light-grey chip reads "very bright" against the
|
||||||
|
navy dock (#6). Daniel wants: **same green, much less opaque** (a translucent green wash,
|
||||||
|
not a solid bright fill).
|
||||||
|
|
||||||
|
Both are the same `--deepdrft-soft` constant failing to be theme-aware. One component, one
|
||||||
|
token — fix the token's dark-mode value and both surfaces resolve. Note the two contexts want
|
||||||
|
*different green treatments* (solid green chip on the hero; translucent green wash in the
|
||||||
|
player bar), so the chip background should be a **token the player-bar context can override**,
|
||||||
|
not a single flat value — see §3.
|
||||||
|
|
||||||
|
### Cause 3 — popover surface has no theme-aware token; light mode reads "too dark"
|
||||||
|
*(Symptom: light-theme popover background too dark, wants desaturated navy (#1))*
|
||||||
|
|
||||||
|
Two different popover families exist and they are styled inconsistently:
|
||||||
|
|
||||||
|
- **Bespoke panels** (visualizer controls, queue, privacy) deliberately use
|
||||||
|
`--deepdrft-panel-ground` (`#1a1c22`, a dark charcoal) for their dark-glass chrome. These
|
||||||
|
are *meant* to be dark in both modes — leave them.
|
||||||
|
- **MudBlazor default popovers** (selects, menus, tooltips, the share popover body) inherit
|
||||||
|
`--mud-palette-surface`. In light mode `Surface = #FAFAF8`, but elevation-overlay tinting +
|
||||||
|
the `--deepdrft-panel-ground` charcoal leaking through shared chrome is making them read
|
||||||
|
darker/muddier than intended. Daniel's ask — "a more desaturated navy" — says the *target*
|
||||||
|
isn't pure white; it's a **soft desaturated-navy surface**. There is no token for that today,
|
||||||
|
so each popover improvises.
|
||||||
|
|
||||||
|
The fix is a **dedicated theme-aware popover-surface token** (`--deepdrft-popover-surface`)
|
||||||
|
with a desaturated-navy value in light mode and the existing panel-ground in dark mode, bound
|
||||||
|
once at the MudPopover surface so every default popover picks it up.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. The DRY remediation — token structure
|
||||||
|
|
||||||
|
The unifying move: **page/component CSS must bind theme-aware aliases, and any surface that
|
||||||
|
must invert gets a named alias in `deepdrft-tokens.css` (defined twice — `:root` + `.deepdrft-theme-dark`).**
|
||||||
|
No surface colour is hardcoded at the component level. This is exactly the Layer-B discipline
|
||||||
|
the token file's header already declares; the work is making the consumers obey it.
|
||||||
|
|
||||||
|
### New / clarified tokens (in `deepdrft-tokens.css`)
|
||||||
|
|
||||||
|
| Token | Light (`:root`) | Dark (`.deepdrft-theme-dark`) | Replaces |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `--deepdrft-page-surface` | `var(--deepdrft-white)` | `var(--deepdrft-navy)` (ground) or `--deepdrft-navy-mid` (elevated) | the literal `--deepdrft-white` on neutral page sections |
|
||||||
|
| `--deepdrft-page-text` | `var(--deepdrft-navy)` | `var(--deepdrft-white)` | the literal `--deepdrft-navy` text on neutral sections |
|
||||||
|
| `--deepdrft-page-text-muted` | `var(--deepdrft-muted)` | `color-mix(... lighter)` | muted body/eyebrow text that must stay legible on dark |
|
||||||
|
| `--deepdrft-play-chip` | `var(--deepdrft-soft)` | `var(--deepdrft-green-accent)` | `.icon-container` background |
|
||||||
|
| `--deepdrft-play-glyph` | (current) | `var(--deepdrft-navy)` | play glyph colour in dark |
|
||||||
|
| `--deepdrft-play-chip-soft` | derived | `color-mix(green-accent ~30%, transparent)` | player-bar translucent variant (#6) |
|
||||||
|
| `--deepdrft-popover-surface` | desaturated navy (e.g. `color-mix(navy 8%, white)`) | `var(--deepdrft-panel-ground)` | MudPopover default surface (#1) |
|
||||||
|
|
||||||
|
Values above are *direction, not final*. Per project memory (decorative-palette contrast
|
||||||
|
targets the actual WCAG threshold for the element type — large text 3:1, pushing toward
|
||||||
|
vibrancy), the implementer should tune the exact mixes on screen; the **structure** is the
|
||||||
|
deliverable here, the hex is theirs to land.
|
||||||
|
|
||||||
|
### Why tokens, not per-component fixes
|
||||||
|
|
||||||
|
- **One source of truth per concept.** "Neutral page surface," "play chip," "popover surface"
|
||||||
|
each become *one* token. A future page that needs a neutral surface binds the token and
|
||||||
|
inverts for free — no new dark-mode rule to remember (the backfill-cliff smell the
|
||||||
|
*design-for-adaptability* memory warns against).
|
||||||
|
- **Neutrality to accents is structural, not vigilance-based.** Because only neutral-surface
|
||||||
|
sections get re-tokened and the decorative navy/green sections keep their explicit brand
|
||||||
|
colours, the inversion *cannot* accidentally flip a section that's meant to stay navy. The
|
||||||
|
classification is encoded in *which token a section binds*, not in a reviewer noticing.
|
||||||
|
- **Player-bar vs. hero divergence is expressible.** Cause 2 needs the same green in two
|
||||||
|
opacities. A `--deepdrft-play-chip` token + a `--deepdrft-play-chip-soft` override the
|
||||||
|
player-bar context sets means one green, two contexts, zero duplication.
|
||||||
|
|
||||||
|
### What stays untouched (the neutrality guardrail)
|
||||||
|
`.section-dark`, `.split-left`, `.cta-banner` (Home + About), `ReleaseHeroOverlay` dark-image
|
||||||
|
chrome, and the bespoke `--deepdrft-panel-ground` panels (visualizer/queue/privacy) keep their
|
||||||
|
explicit brand colours. They are decorative-by-design and already correct in both modes. The
|
||||||
|
remediation must **not** route them through the new neutral-surface tokens.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Track / wave breakdown (for clean dispatch)
|
||||||
|
|
||||||
|
Sequenced so the token layer lands first and the component re-pointing fans out behind it.
|
||||||
|
Tracks T2–T4 are parallel once T1 is in.
|
||||||
|
|
||||||
|
### T1 — Token foundation *(cold-start prerequisite)*
|
||||||
|
Add the theme-aware tokens from §3 to `deepdrft-tokens.css` — each defined in **both** `:root`
|
||||||
|
and `.deepdrft-theme-dark`. No component consumes them yet; this is a pure additive token
|
||||||
|
slice. Tune the dark-mode values on screen. **Load-bearing for everything below.**
|
||||||
|
- Scope: `DeepDrftShared.Client/wwwroot/styles/deepdrft-tokens.css` only.
|
||||||
|
- Acceptance: tokens resolve to the right value in each mode (verify via devtools); no visual
|
||||||
|
change yet (nothing binds them).
|
||||||
|
|
||||||
|
### T2 — Neutral-surface inversion *(Cause 1 → symptoms #3, #4)*
|
||||||
|
Re-point the neutral page-surface sections from constant source tokens to `--deepdrft-page-surface`
|
||||||
|
/ `--deepdrft-page-text` / `--deepdrft-page-text-muted`. **Classify first** — only the neutral
|
||||||
|
sections; leave `.section-dark` / `.split-left` / `.cta-banner` / hero-overlay alone.
|
||||||
|
- Scope: `Home.razor.css`, `About.razor.css`, `DeepDrftFooter.razor.css`.
|
||||||
|
- Acceptance: in dark mode the Home hero-left, the medium grid, the footer, and the About
|
||||||
|
light sections render dark-surface/light-text; the navy and green accent sections are
|
||||||
|
visually unchanged; light mode is pixel-identical to today.
|
||||||
|
- Risk: the appbar already has dark-mode handling (`deepdrft-styles.css §5`); confirm the
|
||||||
|
footer/hero changes don't double-invert anything the appbar rules already cover.
|
||||||
|
|
||||||
|
### T3 — Play-chip theming *(Cause 2 → symptoms #5, #6)*
|
||||||
|
Re-point `.icon-container` background from `--deepdrft-soft` to `--deepdrft-play-chip`; set the
|
||||||
|
dark play glyph to `--deepdrft-play-glyph` (navy); in the **player-bar context only**, override
|
||||||
|
the chip to the translucent `--deepdrft-play-chip-soft`.
|
||||||
|
- Scope: `PlayStateIcon.razor.css` (+ a player-bar-scoped override, likely in
|
||||||
|
`AudioPlayerBar.razor.css` or a context class on the bar's `.icon-container`).
|
||||||
|
- Acceptance (dark mode): release-hero + Cut-track-row play chips are **moss-green with a navy
|
||||||
|
glyph**; the player-bar play button is the **same green but markedly less opaque**; light
|
||||||
|
mode unchanged. Confirm hover states still read.
|
||||||
|
- Note: `PlayStateIcon` is shared — verify the chip change is acceptable on **every** mount
|
||||||
|
(heroes, track rows, player bar) and that the player-bar override is the only context-specific
|
||||||
|
divergence.
|
||||||
|
|
||||||
|
### T4 — Popover surface token *(Cause 3 → symptom #1)*
|
||||||
|
Introduce `--deepdrft-popover-surface` and bind MudBlazor's default popover surface to it so
|
||||||
|
light-mode popovers read as soft desaturated-navy rather than the current too-dark muddle.
|
||||||
|
**Do not** touch the bespoke `--deepdrft-panel-ground` panels.
|
||||||
|
- Scope: `deepdrft-styles.css` (a `.mud-popover` / popover-surface rule binding the new token);
|
||||||
|
token already added in T1.
|
||||||
|
- Acceptance: light-mode default popovers (selects/menus/share body) render desaturated-navy;
|
||||||
|
dark-mode popovers unchanged; the visualizer/queue/privacy panels are untouched.
|
||||||
|
- Open question (resolve during T4): confirm whether the "too dark" popover is a MudBlazor
|
||||||
|
elevation-overlay artifact or panel-ground leakage — the fix differs slightly (override the
|
||||||
|
overlay tint vs. set the surface). One devtools inspection settles it; flagged so the
|
||||||
|
implementer checks rather than guesses.
|
||||||
|
|
||||||
|
### Dependency shape
|
||||||
|
`T1 → {T2, T3, T4}`. T1 is the only cold-start item. T2/T3/T4 are independent of each other
|
||||||
|
and can land in any order or in parallel once T1 is in. None of them touch source code, the
|
||||||
|
data layer, or the streaming seam — this is a pure CSS-token pass.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Open questions for Daniel
|
||||||
|
|
||||||
|
1. **Dark neutral-surface = ground or elevated?** Should the inverted Home/About/footer
|
||||||
|
surfaces be the navy *ground* (`--deepdrft-navy`, matching the site background — sections
|
||||||
|
dissolve into one continuous dark field) or *elevated* navy-mid (`--deepdrft-navy-mid` —
|
||||||
|
sections read as distinct raised panels)? Recommend **ground** for the footer/hero (continuous
|
||||||
|
field, less busy) and let the medium-cards stay as bordered panels on that ground. This is a
|
||||||
|
taste call; flag for Daniel.
|
||||||
|
2. **Popover target colour (#1).** "Desaturated navy" — how far from white? Recommend a light
|
||||||
|
wash (`color-mix(navy ~8%, white)`) so it stays clearly a light-mode surface, not a dark one.
|
||||||
|
Confirm direction on screen.
|
||||||
|
3. Everything else (exact green opacity for the player-bar chip, exact muted-text mix) is a
|
||||||
|
tune-on-screen detail, not a decision gate.
|
||||||
|
|
||||||
|
These are the only items that change the shape of the work; the rest is mechanical.
|
||||||
Reference in New Issue
Block a user