Skip to content

Design: Dark Mode Toggle

Technical design for the Notes dark-mode feature. Produced by prd-to-design. Authoritative for structure; prd.md stays authoritative for requirements.

  • Designs: prd.md — “Dark Mode Toggle” for the Notes web app.
  • Scope of this design: the component boundaries, the storage contract, and the pre-paint flow that satisfy FR-1–FR-6. The exact CSS color values are left to implementation; only the token names and the contrast target (NFR-2) are fixed here.
  • Status: Approved

Four small new pieces plus one change to the existing token stylesheet. Everything runs client-side; nothing crosses a network boundary.

┌────────────────────────┐
index.html ───────▶ │ theme-script (inline) │ sets <html data-theme> pre-paint
(<head>) └───────────┬────────────┘
│ reads
┌─────────▼─────────┐
│ theme-storage │ localStorage get/set (+ fallback)
└─────────▲─────────┘
│ get/set
React tree: ThemeProvider ──────┘
│ provides { theme, setTheme }
▼
ThemeToggle ──▶ writes data-theme + storage on change
tokens.css (existing, modified): :root and [data-theme="dark"] palettes
  • theme-script (new) — inline <head> script; resolves and applies the theme before the stylesheet paints.
  • theme-storage (new module) — the only place that touches localStorage.
  • ThemeProvider (new) — React context holding the resolved theme for the UI.
  • ThemeToggle (new component) — the user control.
  • tokens.css (existing, modified) — gains a dark palette keyed on data-theme.
  • theme-script
    • Responsibility: apply the correct data-theme to <html> before first paint.
    • Owns: the pre-hydration value of the data-theme attribute.
    • Depends on: localStorage and the prefers-color-scheme media query.
    • Requirements: FR-4, FR-5, NFR-1.
  • theme-storage
    • Responsibility: read and write the persisted preference, safely.
    • Owns: the notes.theme storage key and the unavailable-storage fallback.
    • Depends on: localStorage.
    • Requirements: FR-3, NFR-3.
  • ThemeProvider
    • Responsibility: expose the current theme and a setter to the React tree.
    • Owns: the in-app theme state.
    • Depends on: theme-storage.
    • Requirements: FR-2.
  • ThemeToggle
    • Responsibility: let the user change the theme and reflect the current state.
    • Owns: nothing persistent; delegates to the provider.
    • Depends on: ThemeProvider.
    • Requirements: FR-1, FR-6.

Internal module interfaces (no network endpoints in this feature):

type Theme = 'light' | 'dark';
type Preference = Theme | 'system';
// theme-storage
getPreference(): Preference // 'system' when unset or storage unavailable
setPreference(p: Preference): void // no-op (swallow) if storage unavailable
// resolution (shared by theme-script and ThemeProvider)
resolveTheme(pref: Preference, prefersDark: boolean): Theme
Invariant: pref==='system' → prefersDark ? 'dark' : 'light'; otherwise pref
  • Invariants: resolveTheme is pure and total; setPreference never throws.
  • Versioning: all-new contracts; no existing interface changes.
  • Entity: a single persisted string under localStorage key notes.theme, value ∈ {light, dark, system}. Owned by theme-storage.
  • Relationships: none.
  • Migration impact: none — additive key; absence is treated as system.
  • Retention & privacy: non-sensitive; no PII; lives only on the user’s device.
First load, no stored choice (US-2, FR-4/FR-5):
1. Browser parses <head> → theme-script runs
2. theme-storage.getPreference() → 'system'
3. resolveTheme('system', matchMedia('(prefers-color-scheme: dark)').matches)
4. set <html data-theme=...> → stylesheet paints correct theme (no flash)
5. React mounts; ThemeProvider reads the same resolved theme
Toggle to dark (US-1, FR-1/FR-2/FR-3):
1. User activates ThemeToggle
2. ThemeProvider.setTheme('dark') → theme-storage.setPreference('dark')
3. set <html data-theme="dark"> → tokens repaint
4. Toggle reflects new state (aria-pressed)
localStorage unavailable (NFR-3):
1. getPreference() catches → returns 'system'
2. setPreference() catches → no-op; app keeps working in the resolved OS theme
#DecisionChoiceADR
1Theme persistence mechanismlocalStorage + inline pre-paint scriptadr/0001-theme-persistence.md
2Palette mechanismExtend existing CSS-variable tokens keyed on data-themeone-line note (reversible; no ADR)
  • Security: no trust boundary; the only persisted value is a constrained enum. No authz/secret handling.
  • Error handling: every localStorage access is wrapped; failures degrade to the OS theme (NFR-3) rather than surfacing an error.
  • Observability: none required — the feature has no backend and no failure mode worth logging beyond the silent fallback.
  • Performance budget: the inline script is a few lines, well within the < 5 ms / < 1 KB budget (NFR-1). It must stay inline (not an external file) so it blocks paint without a network round-trip.
  • Risk: the inline script must sit before the stylesheet in <head>; placing it later reintroduces the flash (FR-5). Mitigation: a regression test asserts the initial data-theme is present before mount.
  • Open question (carried from PRD Q1): two-state vs. three-state toggle. The storage contract already supports system, so exposing it later is a UI-only change — no structural rework. Decision deferred to the PM; does not block this design.