244 lines
15 KiB
Markdown
244 lines
15 KiB
Markdown
# 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.
|