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

15 KiB
Raw Permalink Blame History

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-footerbackground: 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.