PRD: Dark Mode Toggle
Worked example for the Notes web app (React + TypeScript, bun). Source of truth for what and why. Produced by idea-to-prd.
1. Executive Summary
Section titled “1. Executive Summary”- Problem Statement: Notes renders in a single permanently-light theme. Readers who use the app in low light report eye strain, and a dark theme is the most-requested item in recent feedback. There is no theming layer today.
- Proposed Solution: Introduce a light/dark theme built on CSS custom properties, a user-visible toggle on every page, and a persisted preference that defaults to the operating system setting. Apply the resolved theme before first paint so the page never flashes the wrong colors.
- Success Criteria:
- A user can switch themes in one interaction from any page.
- The chosen theme persists across reloads and sessions (0 resets on refresh).
- No flash of the wrong theme on load — first paint matches the resolved theme.
- With no stored choice, the app matches the OS
prefers-color-schemeon 100% of first visits. - Every text/background pair in the dark theme meets WCAG 2.1 AA contrast (≥ 4.5:1 normal text).
2. Goals and Non-Goals
Section titled “2. Goals and Non-Goals”- Provide a light and a dark theme with a visible toggle control.
- Persist the user’s choice locally and restore it on later visits.
- Default to the OS theme when the user has not chosen one.
- Eliminate the flash of incorrect theme (FOUC) on initial load.
- Meet WCAG 2.1 AA contrast in both themes.
Non-Goals
Section titled “Non-Goals”- We will not add per-component, custom, or user-authored color themes — only light, dark, and “follow system.”
- We will not store the preference server-side or sync it across devices (no backend change).
- We will not add automatic time-of-day theme switching.
- We will not theme exported/printed output or transactional email.
Constraints
Section titled “Constraints”- Must use the existing React + TypeScript + bun stack; no new state-management or theming dependency.
- Must work entirely client-side — Notes has no per-user backend store for settings.
- Must support the project’s existing browser matrix (last two major versions of evergreen browsers).
Scope Check
Section titled “Scope Check”One cohesive feature, six functional requirements, no subsystem split needed. A single PRD is appropriate.
3. User Stories and Requirements
Section titled “3. User Stories and Requirements”User Personas
Section titled “User Personas”- Night reader — a returning user who reads notes in low-light settings and wants a comfortable dark UI.
- System-preference user — has set a dark mode at the OS level and expects apps to honor it without extra configuration; may rely on sufficient contrast.
User Stories
Section titled “User Stories”- US-1 (Night reader): As a reader, I want to switch to a dark theme so I can read comfortably at night. Priority: P0
- Acceptance Criteria:
- A theme toggle is visible on every page.
- Activating it switches the theme immediately, with no reload.
- The choice persists after a full page reload and in a new session.
- Acceptance Criteria:
- US-2 (System-preference user): As a user with an OS dark-mode setting, I want Notes to default to my system theme so I don’t have to configure it. Priority: P1
- Acceptance Criteria:
- On a first visit with no stored choice, the app renders the OS theme.
- An explicit toggle choice overrides the OS default and is remembered.
- Acceptance Criteria:
Functional Requirements
Section titled “Functional Requirements”- FR-1: The app must present a theme toggle control reachable from every page. (P0)
- FR-2: The app must apply the selected theme (light or dark) to all UI surfaces. (P0)
- FR-3: The app must persist the user’s theme choice locally and restore it on subsequent loads. (P0)
- FR-4: When no choice is stored, the app must resolve the theme from the OS
prefers-color-scheme. (P1) - FR-5: The app must apply the resolved theme before first paint, so no flash of the wrong theme occurs. (P0)
- FR-6: The toggle must reflect the current theme and be fully operable by keyboard and screen reader. (P1)
Non-Functional Requirements
Section titled “Non-Functional Requirements”- NFR-1 (Performance): The pre-paint theme resolution must run in < 5 ms and add < 1 KB to the initial HTML.
- NFR-2 (Accessibility): All foreground/background text pairs must meet WCAG 2.1 AA contrast (≥ 4.5:1 normal, ≥ 3:1 large) in both themes; the toggle must satisfy WCAG 2.1 AA for keyboard and screen-reader operation.
- NFR-3 (Compatibility/Resilience): If
localStorageis unavailable (e.g. private mode), the app must fall back to the OS theme without error.
4. Solution Design
Section titled “4. Solution Design”Approach
Section titled “Approach”Define both palettes as CSS custom properties keyed off a data-theme attribute on the document root. A tiny inline script in the document head reads the stored preference (or the OS media query) and sets data-theme before the stylesheet paints — this is what prevents the flash. A small React context (ThemeProvider) holds the resolved theme for the UI, and the toggle writes the choice back to storage and updates the attribute. This reuses the project’s existing CSS-variable design tokens rather than introducing a theming library.
Key Design Decisions
Section titled “Key Design Decisions”- Decision: Persist the preference in
localStorage, applied via an inline pre-paint script.- Context: The preference must survive reloads and be readable before the React app mounts.
- Options Considered:
localStorage+ inline script; cookie; server-side user setting. - Rationale: Notes is a client-rendered SPA with no per-user settings backend;
localStorageis readable synchronously before paint and needs no network or backend work. - Trade-offs: Preference is per-device, not synced across devices (acceptable per non-goals).
- The full rationale and rejected alternatives are recorded as an ADR during design — see design.md and adr/0001-theme-persistence.md.
Architecture Overview
Section titled “Architecture Overview”The toggle and provider live in the existing component tree; the inline script lives in index.html. The only new “data” is a single string key in localStorage. No new services or dependencies are introduced.
Modular Design Principles
Section titled “Modular Design Principles”- Reuse the existing CSS-variable token layer; add a second palette rather than a new styling system.
- Keep storage access behind one small module so the fallback (NFR-3) lives in one place.
Security Considerations
Section titled “Security Considerations”No authentication, authorization, or PII is involved. The only persisted value is a non-sensitive theme string written to localStorage. Input is constrained to the enum light | dark | system. Security is not a primary concern for this feature.
5. Alternatives Considered
Section titled “5. Alternatives Considered”- CSS-only
prefers-color-scheme(no toggle)- Pros: Zero JavaScript; honors the OS automatically.
- Cons: Cannot honor an explicit user override (fails US-1).
- Verdict: Rejected — an explicit, persisted toggle is a core requirement.
- A CSS-in-JS theming library
- Pros: Rich theming API out of the box.
- Cons: New runtime dependency; duplicates the existing CSS-variable tokens.
- Verdict: Rejected — the token layer already does what we need.
6. Implementation Plan
Section titled “6. Implementation Plan”Phased Rollout
Section titled “Phased Rollout”- Phase 1 (MVP): Token palettes,
ThemeProvider, the toggle, persistence, and the pre-paint script. Delivers US-1, US-2, and FR-1–FR-5. - Phase 2 (Accessibility & polish): Keyboard/screen-reader support for the toggle and a dark-palette contrast audit. Delivers FR-6 and NFR-2.
Tech Stack Alignment
Section titled “Tech Stack Alignment”- React + TypeScript components following the existing
src/components/conventions;bunfor all scripts. No new libraries.
Migration and Compatibility
Section titled “Migration and Compatibility”- Purely additive. No data migration. Default behavior for existing users matches their OS theme until they choose otherwise.
7. Testing Strategy
Section titled “7. Testing Strategy”Testing Levels
Section titled “Testing Levels”- Unit: theme resolution (stored → OS → default), storage read/write, and the
localStorage-unavailable fallback. - Integration: toggling flips
data-themeand writes storage; the value is restored on remount. - End-to-End / regression: a test asserting the initial
data-themeis set before app mount (guards FR-5 against regression).
Validation Approach
Section titled “Validation Approach”- Use the project’s existing Vitest setup and the standard test locations. All existing checks must continue to pass.
Quality Gates
Section titled “Quality Gates”- QG-1:
bun run check— type checking passes with zero errors - QG-2:
bun run format— formatting matches project standards - QG-3:
bun run lint— no lint warnings or errors - QG-4:
bun run test— all existing and new tests pass - QG-5:
bun run build— build completes successfully - QG-6: Code review completed
8. Risks and Mitigations
Section titled “8. Risks and Mitigations”| Risk | Likelihood | Impact | Mitigation |
|---|---|---|---|
| Inline script placed after the stylesheet → flash of wrong theme | Med | High | Place the script in <head> before the stylesheet; add a regression test asserting the initial data-theme (FR-5). |
localStorage blocked (private mode) throws on read/write | Low | Med | Wrap access in try/catch; fall back to the OS theme (NFR-3). |
| Dark palette fails AA contrast on some surfaces | Med | Med | Dedicated contrast-audit task before release (NFR-2). |
9. Open Questions
Section titled “9. Open Questions”- Two-state or three-state toggle? Should the control be light↔dark, or expose an explicit “System” option as a third state?
- Owner: PM.
- Impact: Affects the toggle UI and one storage value (
system). - Proposed default: Ship a two-state toggle that defaults to the OS theme until the user chooses; revisit an explicit “System” option if requested. Tracked downstream as a Future Consideration.