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:
2026-03-23 22:04:20 -04:00
parent 5f2111ea66
commit 012385e9e1
17 changed files with 457 additions and 198 deletions

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-23

View 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.

View File

@@ -0,0 +1,27 @@
## Why
The app currently ships with a warm light theme only. Adding a system-aware dark mode with a manual toggle removes eye strain in low-light conditions and meets baseline accessibility expectations for a personal finance tool used at any hour.
## What Changes
- Add a class-based dark mode variant using Tailwind v4 `@custom-variant` so all `dark:` utilities are controlled by a `dark` class on `<html>`.
- Add a `ThemeToggle` component that persists the user's preference in `localStorage` and initialises the correct class before first paint to prevent flash.
- Update the root layout to inject the inline initialisation script and render the toggle inside the existing header.
- Apply `dark:` variants to all UI components to produce a warm, deep-stone dark palette consistent with the existing amber/stone design language.
## Capabilities
### New Capabilities
- `dark-mode`: Users can switch between light and dark themes with a toggle that persists across sessions. System preference is respected on first visit.
### Modified Capabilities
- `expense-tracking`: Expense workspace and recurring expense manager now render correctly in both themes.
- `paycheck-tracking`: Paycheck workspace now renders correctly in both themes.
- `monthly-dashboard`: Dashboard sections, insight cards, category bars, and stat cards now render correctly in both themes.
## Impact
- Affected code: `globals.css`, `layout.tsx`, all components under `src/components/`, and page header text in `src/app/`.
- APIs: None.
- Dependencies: None — uses Tailwind v4 built-ins and the Web Storage API.
- Systems: No server-side changes required; theme state is fully client-side.

View File

@@ -0,0 +1,25 @@
## 1. CSS and variant setup
- [x] 1.1 Add `@custom-variant dark (&:where(.dark, .dark *))` to `globals.css` and update `:root` / `@theme` blocks with dark-mode CSS variable overrides.
## 2. Theme toggle infrastructure
- [x] 2.1 Create `src/components/theme-toggle.tsx` — a client component that reads and writes `localStorage` theme preference, toggles the `dark` class on `<html>`, and renders a sun/moon button.
- [x] 2.2 Update `src/app/layout.tsx` to inject the inline blocking script in `<head>` and render `ThemeToggle` in the header alongside `SiteNav`.
## 3. Component dark-mode styles
- [x] 3.1 Update `src/components/site-nav.tsx` with `dark:` variants for link backgrounds, borders, and text.
- [x] 3.2 Update `src/components/home-dashboard.tsx` with `dark:` variants for all section cards, stat tiles, insight blocks, progress bars, and empty states.
- [x] 3.3 Update `src/components/expense-workspace.tsx` with `dark:` variants for the form card, list card, inputs, and expense articles.
- [x] 3.4 Update `src/components/paycheck-workspace.tsx` with `dark:` variants for the schedule panel, form, and paycheck list.
- [x] 3.5 Update `src/components/recurring-expense-manager.tsx` with `dark:` variants for the panel, inline form, and definition articles.
## 4. Page header text
- [x] 4.1 Update hardcoded text colours in `src/app/add-expense/page.tsx` and `src/app/income/page.tsx` with `dark:` overrides.
## 5. Verification
- [ ] 5.1 Visually verify light and dark modes across the dashboard, add-expense, and income pages in the browser.
- [ ] 5.2 Verify that theme preference persists across page refreshes and that there is no flash of the wrong theme on load.