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.
1. Context
Section titled “1. Context”- 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
2. Architecture Overview
Section titled “2. Architecture Overview”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.
3. Component Responsibilities
Section titled “3. Component Responsibilities”- theme-script
- Responsibility: apply the correct
data-themeto<html>before first paint. - Owns: the pre-hydration value of the
data-themeattribute. - Depends on:
localStorageand theprefers-color-schememedia query. - Requirements: FR-4, FR-5, NFR-1.
- Responsibility: apply the correct
- theme-storage
- Responsibility: read and write the persisted preference, safely.
- Owns: the
notes.themestorage 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.
4. API & Interface Contracts
Section titled “4. API & Interface Contracts”Internal module interfaces (no network endpoints in this feature):
type Theme = 'light' | 'dark';type Preference = Theme | 'system';
// theme-storagegetPreference(): Preference // 'system' when unset or storage unavailablesetPreference(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:
resolveThemeis pure and total;setPreferencenever throws. - Versioning: all-new contracts; no existing interface changes.
5. Data Model & Contracts
Section titled “5. Data Model & Contracts”- Entity: a single persisted string under
localStoragekeynotes.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.
6. Sequence Flows
Section titled “6. Sequence Flows”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 theme7. Key Decisions
Section titled “7. Key Decisions”| # | Decision | Choice | ADR |
|---|---|---|---|
| 1 | Theme persistence mechanism | localStorage + inline pre-paint script | adr/0001-theme-persistence.md |
| 2 | Palette mechanism | Extend existing CSS-variable tokens keyed on data-theme | one-line note (reversible; no ADR) |
8. Cross-Cutting Concerns
Section titled “8. Cross-Cutting Concerns”- Security: no trust boundary; the only persisted value is a constrained enum. No authz/secret handling.
- Error handling: every
localStorageaccess 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.
9. Risks & Open Technical Questions
Section titled “9. Risks & Open Technical Questions”- 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 initialdata-themeis 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.