Files
deepdrft/product-notes/theme-dark-mode-remediation.md

244 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 T2T4 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.