Files

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