- 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>
3.8 KiB
3.8 KiB
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-variantAPI. - Prevent theme flash on page load with an inline blocking script.
- Persist user preference in
localStorageunder the keytheme. - 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: 180mson 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 *))inglobals.cssenables thedark: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 becauseuseEffectruns 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 plusdark:overrides so the mapping is explicit and maintainable.
Migration Plan
- Update
globals.csswith@custom-variant darkand dark-mode CSS variable overrides. - Create the
ThemeToggleclient component. - Update
layout.tsxto add the blocking script and renderThemeTogglein the header. - Update
site-nav.tsxwith dark-mode nav styles. - Update
home-dashboard.tsx,expense-workspace.tsx,paycheck-workspace.tsx, andrecurring-expense-manager.tsxwithdark:variants. - 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.