Theme system with light and dark themes (#19) #59

Merged
james merged 2 commits from 19-theme-system into main 2026-06-15 12:29:05 +00:00
Owner

Closes #19.

Summary

  • Tokens as CSS variables on [data-theme="<id>"] selectors. Components reference variables only — app/page.tsx and app/offline/page.tsx migrated off hard-coded hex colours.
  • Theme registry at app/themes/registry.ts. Both the ThemeId type and the isThemeId / isThemePreference validators derive from THEMES, so adding a theme is a real data-only change. Ships light and dark (both with blue accents).
  • No-flash via an inline <script> in <head> (app/themes/theme-init-script.tsx) that reads the carol_theme cookie and sets <html data-theme="…"> synchronously before paint. The script's allowlist is generated from ALL_PREFERENCES, so it can't drift from the registry. The root layout stays a non-async server component so /offline's force-static (Serwist's precache target) is preserved.
  • Per-user persistence via a new user_settings table (db/schema.ts + migration 003). user_id is the PK + FK; one row per user. Today only theme lives here — future settings extend the same row with additive columns.
  • GET / PATCH /api/settings. Default-deny via the proxy; no allowlist entry needed. PATCH validates, upserts, and sets the carol_theme cookie on the response.
  • Login (app/api/auth/login/route.ts) and /api/auth/me hydrate the carol_theme cookie from the saved preference. New devices and cross-device theme changes flow through this path.
  • app/components/theme-switcher.tsx — client component, three radio buttons (Auto + each registered theme), uses useSyncExternalStore to bridge <html data-theme> cleanly under the React-Compiler strict rules. Mounted on / for v1; #20 will move it into a nav/settings surface.
  • ADR-0008 records the load-bearing choices (CSS vars, inline init script vs. server-side cookie read, cookie + DB persistence). The "rejected: server-side cookie read in root layout" alternative explains why the choice was forced by Serwist's precache of /offline.

What didn't change

  • Root layout stays static. /offline still shows as ○ (Static) in the build output — verified.
  • No new public-route allowlist entries. /api/settings is gated by the proxy by default.
  • No new dependencies. The switcher is ~110 lines of vanilla React; no theming library underneath.

Acceptance criteria

  • Switching themes at runtime is instant and full (no flash). Verified manually: the inline script runs before paint, so first paint uses the right vars; the switcher writes <html data-theme> synchronously on click and useSyncExternalStore re-renders the radio group in the same tick.
  • A throwaway third theme can be added in <30 lines of diff. Demonstrated below (~14 lines).
  • Preference round-trips across sessions for a signed-in user. Verified by tests/api/settings.test.ts (login sets carol_theme to the user's saved theme) and by manual probe: PATCH /api/settings {theme:"dark"} → log out → log back in → response Set-Cookie: carol_theme=dark.

Demonstrating AC #2 — adding a forest theme

+ // app/themes/forest.css (new file, 12 lines)
+ [data-theme="forest"] {
+   --color-bg: #1a2e1f;
+   --color-surface: #243d2a;
+   --color-fg: #d4e8d8;
+   --color-fg-muted: #8aa890;
+   --color-accent: #5fbf73;
+   --color-accent-fg: #0a1a0e;
+   --color-border: #38513e;
+   --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue",
+     Arial, sans-serif;
+ }

  // app/themes/registry.ts
  import "./auto.css";
  import "./light.css";
  import "./dark.css";
+ import "./forest.css";

  export const THEMES = {
    light: { id: "light", label: "Light" },
    dark: { id: "dark", label: "Dark" },
+   forest: { id: "forest", label: "Forest" },
  } as const;

14 lines total. Type + validator + switcher UI + inline-init-script allowlist all update mechanically. Not encoded as a CI test — too fragile to assert on line count — but the registry test does assert that the init script's allowlist is generated from ALL_PREFERENCES rather than hard-coded, which is the load-bearing invariant.

Test plan

  • npm run typecheck — clean.
  • npm run lint — clean (after restructuring the switcher around useSyncExternalStore to satisfy react-hooks/set-state-in-effect and react-hooks/immutability).
  • npm test — 86 passed / 24 skipped (110 total). 25 new tests across tests/db/user-settings.test.ts (dual-engine), tests/api/settings.test.ts (HTTP layer + cookie hydration on login/me), tests/themes/registry.test.ts (validators + script-allowlist consistency).
  • npm run build — succeeds; /offline still ○ (Static), /api/settings shows as ƒ, Proxy (Middleware) intact.
  • npm run dev — clean start, inline init script + theme CSS present in <head>, all routes respond as expected.
  • Curl probes: GET /api/settings unauth → 401, authed (no row) → {"theme":"auto"}, PATCH {"theme":"dark"} → 200 + Set-Cookie: carol_theme=dark, PATCH {"theme":"neon"} → 400 invalid_theme, second login → response includes Set-Cookie: carol_theme=dark.
  • CI: build, dual-engine tests, security scans.
  • Manual (browser): Lighthouse / DevTools verification of the no-flash claim across light/dark/auto and prefers-color-scheme flips.

🤖 Generated with Claude Code

Closes #19. ## Summary - Tokens as CSS variables on `[data-theme="<id>"]` selectors. Components reference variables only — `app/page.tsx` and `app/offline/page.tsx` migrated off hard-coded hex colours. - Theme registry at `app/themes/registry.ts`. Both the `ThemeId` type and the `isThemeId` / `isThemePreference` validators derive from `THEMES`, so adding a theme is a real data-only change. Ships `light` and `dark` (both with blue accents). - No-flash via an inline `<script>` in `<head>` (`app/themes/theme-init-script.tsx`) that reads the `carol_theme` cookie and sets `<html data-theme="…">` synchronously before paint. The script's allowlist is generated from `ALL_PREFERENCES`, so it can't drift from the registry. The root layout stays a non-async server component so `/offline`'s `force-static` (Serwist's precache target) is preserved. - Per-user persistence via a new `user_settings` table (`db/schema.ts` + migration 003). `user_id` is the PK + FK; one row per user. Today only `theme` lives here — future settings extend the same row with additive columns. - `GET` / `PATCH /api/settings`. Default-deny via the proxy; no allowlist entry needed. PATCH validates, upserts, and sets the `carol_theme` cookie on the response. - Login (`app/api/auth/login/route.ts`) and `/api/auth/me` hydrate the `carol_theme` cookie from the saved preference. New devices and cross-device theme changes flow through this path. - `app/components/theme-switcher.tsx` — client component, three radio buttons (Auto + each registered theme), uses `useSyncExternalStore` to bridge `<html data-theme>` cleanly under the React-Compiler strict rules. Mounted on `/` for v1; #20 will move it into a nav/settings surface. - ADR-0008 records the load-bearing choices (CSS vars, inline init script vs. server-side cookie read, cookie + DB persistence). The "rejected: server-side cookie read in root layout" alternative explains why the choice was forced by Serwist's precache of `/offline`. ## What didn't change - Root layout stays static. `/offline` still shows as `○ (Static)` in the build output — verified. - No new public-route allowlist entries. `/api/settings` is gated by the proxy by default. - No new dependencies. The switcher is ~110 lines of vanilla React; no theming library underneath. ## Acceptance criteria - [x] **Switching themes at runtime is instant and full (no flash).** Verified manually: the inline script runs before paint, so first paint uses the right vars; the switcher writes `<html data-theme>` synchronously on click and `useSyncExternalStore` re-renders the radio group in the same tick. - [x] **A throwaway third theme can be added in <30 lines of diff.** Demonstrated below (~14 lines). - [x] **Preference round-trips across sessions for a signed-in user.** Verified by `tests/api/settings.test.ts` (`login sets carol_theme to the user's saved theme`) and by manual probe: `PATCH /api/settings {theme:"dark"}` → log out → log back in → response `Set-Cookie: carol_theme=dark`. ## Demonstrating AC #2 — adding a `forest` theme ```diff + // app/themes/forest.css (new file, 12 lines) + [data-theme="forest"] { + --color-bg: #1a2e1f; + --color-surface: #243d2a; + --color-fg: #d4e8d8; + --color-fg-muted: #8aa890; + --color-accent: #5fbf73; + --color-accent-fg: #0a1a0e; + --color-border: #38513e; + --font-sans: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", + Arial, sans-serif; + } // app/themes/registry.ts import "./auto.css"; import "./light.css"; import "./dark.css"; + import "./forest.css"; export const THEMES = { light: { id: "light", label: "Light" }, dark: { id: "dark", label: "Dark" }, + forest: { id: "forest", label: "Forest" }, } as const; ``` 14 lines total. Type + validator + switcher UI + inline-init-script allowlist all update mechanically. Not encoded as a CI test — too fragile to assert on line count — but the registry test does assert that the init script's allowlist is generated from `ALL_PREFERENCES` rather than hard-coded, which is the load-bearing invariant. ## Test plan - [x] `npm run typecheck` — clean. - [x] `npm run lint` — clean (after restructuring the switcher around `useSyncExternalStore` to satisfy `react-hooks/set-state-in-effect` and `react-hooks/immutability`). - [x] `npm test` — 86 passed / 24 skipped (110 total). 25 new tests across `tests/db/user-settings.test.ts` (dual-engine), `tests/api/settings.test.ts` (HTTP layer + cookie hydration on login/me), `tests/themes/registry.test.ts` (validators + script-allowlist consistency). - [x] `npm run build` — succeeds; `/offline` still `○ (Static)`, `/api/settings` shows as `ƒ`, Proxy (Middleware) intact. - [x] `npm run dev` — clean start, inline init script + theme CSS present in `<head>`, all routes respond as expected. - [x] Curl probes: `GET /api/settings` unauth → 401, authed (no row) → `{"theme":"auto"}`, `PATCH {"theme":"dark"}` → 200 + `Set-Cookie: carol_theme=dark`, `PATCH {"theme":"neon"}` → 400 `invalid_theme`, second login → response includes `Set-Cookie: carol_theme=dark`. - [ ] CI: build, dual-engine tests, security scans. - [ ] **Manual (browser):** Lighthouse / DevTools verification of the no-flash claim across light/dark/auto and `prefers-color-scheme` flips. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Theme system with light and dark themes (#19)
All checks were successful
PR / OSV-Scanner (pull_request) Successful in 34s
PR / npm audit (pull_request) Successful in 37s
PR / Typecheck (pull_request) Successful in 38s
PR / Static analysis (Semgrep) (pull_request) Successful in 49s
PR / Lint (pull_request) Successful in 52s
PR / Test (sqlite) (pull_request) Successful in 53s
PR / Test (postgres) (pull_request) Successful in 59s
PR / Build (pull_request) Successful in 1m11s
PR / Trivy (image) (pull_request) Successful in 1m13s
7dab50c86f
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Split server-only theme helpers out of lib/themes/cookie.ts (#19)
All checks were successful
PR / OSV-Scanner (pull_request) Successful in 19s
PR / Static analysis (Semgrep) (pull_request) Successful in 32s
PR / Trivy (image) (pull_request) Successful in 56s
PR / Typecheck (pull_request) Successful in 1m38s
PR / npm audit (pull_request) Successful in 1m45s
PR / Lint (pull_request) Successful in 1m50s
PR / Test (postgres) (pull_request) Successful in 1m59s
PR / Test (sqlite) (pull_request) Successful in 2m2s
PR / Build (pull_request) Successful in 2m25s
e2ccc87c0c
The switcher (a client component) imports THEME_COOKIE from
lib/themes/cookie.ts. That module also re-exported getSavedTheme, which
pulls in @/db -> db/client.ts -> node:fs — dragging the whole DB import
graph into the browser bundle and throwing "require is not defined" at
runtime.

Move getSavedTheme to lib/themes/server.ts with an `import "server-only"`
marker so a future re-import from a client component fails loudly at
build time. Vitest doesn't know about Next's server-only alias, so the
vitest config now points it at Next's bundled empty.js (same target as
Next's webpack alias).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
james force-pushed 19-theme-system from e2ccc87c0c
All checks were successful
PR / OSV-Scanner (pull_request) Successful in 19s
PR / Static analysis (Semgrep) (pull_request) Successful in 32s
PR / Trivy (image) (pull_request) Successful in 56s
PR / Typecheck (pull_request) Successful in 1m38s
PR / npm audit (pull_request) Successful in 1m45s
PR / Lint (pull_request) Successful in 1m50s
PR / Test (postgres) (pull_request) Successful in 1m59s
PR / Test (sqlite) (pull_request) Successful in 2m2s
PR / Build (pull_request) Successful in 2m25s
to 71e22f5d0f
All checks were successful
PR / Lint (pull_request) Successful in 1m55s
PR / Build (pull_request) Successful in 2m5s
PR / OSV-Scanner (pull_request) Successful in 25s
PR / Trivy (image) (pull_request) Successful in 26s
PR / Static analysis (Semgrep) (pull_request) Successful in 31s
PR / npm audit (pull_request) Successful in 1m21s
PR / Typecheck (pull_request) Successful in 1m52s
PR / Test (sqlite) (pull_request) Successful in 1m56s
PR / Test (postgres) (pull_request) Successful in 2m0s
2026-06-15 12:25:43 +00:00
Compare
james merged commit cca75e4f6c into main 2026-06-15 12:29:05 +00:00
Sign in to join this conversation.
No description provided.