Add dark mode with theme toggle and OpenSpec change
- Add @custom-variant dark in globals.css for class-based dark mode - Add ThemeToggle component with localStorage persistence and system preference fallback - Inject blocking inline script in layout to prevent flash on load - Apply dark: variants across all components (layout, site-nav, home-dashboard, expense-workspace, paycheck-workspace, recurring-expense-manager) and page headers - Create openspec/changes/theming-dark-mode with proposal, design, and tasks artifacts Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
54
openspec/changes/theming-dark-mode/design.md
Normal file
54
openspec/changes/theming-dark-mode/design.md
Normal file
@@ -0,0 +1,54 @@
|
||||
## Context
|
||||
|
||||
The project uses Next.js with Tailwind CSS v4 and a warm stone/amber palette. All styling is done via Tailwind utility classes directly in JSX. There are no separate CSS modules for components. The root layout owns the `<html>` and `<body>` tags, making it the natural place to manage the theme class and prevent flash-of-unstyled-content.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Implement class-based dark mode that works with Tailwind v4's `@custom-variant` API.
|
||||
- Prevent theme flash on page load with an inline blocking script.
|
||||
- Persist user preference in `localStorage` under the key `theme`.
|
||||
- Fall back to system preference (`prefers-color-scheme`) when no preference is saved.
|
||||
- Keep the dark palette warm and consistent with the existing amber/stone design language.
|
||||
|
||||
**Non-Goals:**
|
||||
- Server-side theme rendering or cookies (local-only app, no SSR theme concerns beyond flash prevention).
|
||||
- Per-page or per-component theme overrides.
|
||||
- Animated theme transitions beyond the existing `transition-duration: 180ms` on interactive elements.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Use Tailwind v4 `@custom-variant` for class-based dark mode
|
||||
- Rationale: Tailwind v4's default dark mode is media-query-based. Adding `@custom-variant dark (&:where(.dark, .dark *))` in `globals.css` enables the `dark:` prefix to respond to a class on any ancestor, which is the standard pattern for toggle-able dark mode.
|
||||
- Alternative considered: Continue using media-query dark mode with no toggle. Rejected because users cannot override system preference.
|
||||
|
||||
### Store theme preference in `localStorage`
|
||||
- Rationale: Simple, synchronous, requires no server or cookies. Reads correctly in the blocking inline script.
|
||||
- Alternative considered: Cookies for SSR. Rejected because this app is local-first and has no meaningful SSR theme benefit.
|
||||
|
||||
### Inject an inline blocking script in `<head>` to set the `dark` class before first paint
|
||||
- Rationale: Prevents the flash where the page briefly renders in light mode before React hydrates and reads `localStorage`. The script is small and runs synchronously.
|
||||
- Alternative considered: Set the class in a React `useEffect`. Rejected because `useEffect` runs after paint, causing a visible flash.
|
||||
|
||||
### Warm deep-stone dark palette
|
||||
- Rationale: The light theme is built on warm stone and amber tones. The dark theme mirrors this with deep stone backgrounds (`stone-950`, `stone-900`, `stone-800`) and dimmed amber/emerald accents, keeping the visual identity coherent.
|
||||
- Alternative considered: Neutral dark greys. Rejected because they clash with the warm amber accents and feel disconnected from the brand.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- [Inline script adds a small amount of render-blocking HTML] → Acceptable; the script is under 200 bytes and only runs once per page load.
|
||||
- [LocalStorage is unavailable in some privacy modes] → The script wraps the read in a try/catch and falls back to system preference.
|
||||
- [Many components have hardcoded warm background strings like `bg-[#fffaf2]`] → These are replaced with equivalent Tailwind tokens plus `dark:` overrides so the mapping is explicit and maintainable.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Update `globals.css` with `@custom-variant dark` and dark-mode CSS variable overrides.
|
||||
2. Create the `ThemeToggle` client component.
|
||||
3. Update `layout.tsx` to add the blocking script and render `ThemeToggle` in the header.
|
||||
4. Update `site-nav.tsx` with dark-mode nav styles.
|
||||
5. Update `home-dashboard.tsx`, `expense-workspace.tsx`, `paycheck-workspace.tsx`, and `recurring-expense-manager.tsx` with `dark:` variants.
|
||||
6. Update page-level header text that uses hardcoded colour classes.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Should the toggle also expose a "System" option (three-way: Light / Dark / System) rather than a binary flip? Deferred to a follow-up; the initialisation script already handles system fallback on first visit.
|
||||
Reference in New Issue
Block a user