Fix dashboard hydration and archive theming

This commit is contained in:
2026-03-23 23:08:18 -04:00
parent c852ad0d80
commit feb2f2c37e
25 changed files with 290 additions and 13 deletions

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.