Files
famapp/docs/tasks/08-theming.md
T
ginnoir 35a14b81c9 Add task 08 — theming infrastructure
Multi-theme × {light, dark} per-user theming, switchable on the fly.
Slots into phase 1 before modules start so the token system is the
foundation rather than a retrofit. Update STATUS to point at task 03
correctly and reflect 08 in the phase 1 roadmap.
2026-05-06 00:56:17 -05:00

3.9 KiB
Raw Blame History

08 — Theming infrastructure (multi-theme + dark mode)

Goal

Set up a CSS-variable-based theme system that supports multiple named themes × {light, dark}, persists per user, and switches on the fly without a flash of unstyled content.

Why

Cheap to set up before modules exist, painful to retrofit afterwards. Locking in the structure now means every later component "just works" with theming, and adding a new theme is a CSS block — no code change.

Depends on

  • 02 (Next.js skeleton, shadcn already initialized)
  • 07 (users table, so we can add columns)

Scope

Schema additions (new migration in src/modules/_core/schema.ts)

  • users.theme — text, default 'default'.
  • users.theme_mode — text, default 'system', check constraint ('light', 'dark', 'system').

CSS structure (src/app/globals.css)

Use shadcn's CSS-variable conventions, scoped by data-theme on <html>:

:root                       { /* default light tokens */ }
.dark                       { /* default dark tokens */ }

[data-theme="warm"]         { /* warm light */ }
[data-theme="warm"].dark    { /* warm dark */ }

Ship at least two themes (default + one more) so the architecture is actually exercised. Token values can be placeholder — refining the palettes is a separate later concern.

Theme registry (src/modules/_core/themes.ts)

export type ThemeMode = "light" | "dark" | "system";
export type ThemeId = "default" | "warm" | string;

export const THEMES: ReadonlyArray<{ id: ThemeId; label: string }> = [
  { id: "default", label: "Default" },
  { id: "warm", label: "Warm" },
];

Picker reads from this list — no hardcoded options in the UI.

Root layout (server component)

  • Reads the current session; pulls theme + theme_mode.
  • Renders <html data-theme={theme} className={resolvedMode === "dark" ? "dark" : ""}> on first paint. No flash.
  • For the system mode, the resolved value comes from a small inline <script> in <head> that reads prefers-color-scheme and localStorage.theme before paint — covers signed-out users (/login, /s/<token>) too.

Client hook + picker

  • useTheme() hook: returns { theme, mode, setTheme, setMode }. Optimistically flips <html> attributes, writes to localStorage, and (if signed in) calls a setUserTheme server action to persist.
  • <ThemePicker /> component: theme <select> + light/dark/system segmented control. Mounted in /settings. Reusable.

Server action

  • setUserTheme({ theme, mode }) updates the current user row. Validates against the THEMES registry and the mode whitelist.

Out of scope

  • Final color palettes — placeholder values are fine; you'll iterate visually later.
  • High-contrast / accessibility-tuned variants (separate task if needed).
  • Per-household theme.
  • Animated transitions between themes (instant swap is fine).
  • Theme preview thumbnails in the picker.

Acceptance criteria

  • On /settings, picking a theme and a mode applies instantly with no page reload.
  • Reloading preserves the choice: signed in → from DB; signed out → from localStorage.
  • First paint after sign-in matches the stored theme — verify by adding a noticeable contrast in the alt theme and reloading; no flash.
  • prefers-color-scheme: dark is honored when theme_mode = 'system'.
  • Adding a third theme requires only: one CSS block in globals.css + one entry in THEMES. No other code touched.
  • Existing shadcn components render correctly across both themes × both modes (manual visual check is fine).

Notes

  • Don't introduce custom semantic tokens beyond what shadcn ships with until a real design need surfaces. The fewer tokens, the easier theming stays.
  • Tailwind v4 picks up :root / .dark CSS variables automatically — no tailwind.config plumbing required.
  • Keep the inline pre-paint <script> tiny (under ~30 lines). It runs before React hydrates so anything heavier is wrong.