src/lib/dev-login-config.ts — startup assertion: throws if NODE_ENV=production + ENABLE_DEV_LOGIN=true, scoped to runtime (skipped during next build).
Container
scripts/migrate.mjs — runs Drizzle migrations against DATABASE_URL.
deploy/docker-entrypoint.sh — runs migrations then exec node server.js. Skip with RUN_MIGRATIONS=false.
Dockerfile — copies drizzle/, scripts/migrate.mjs, entrypoint into runner stage; ENTRYPOINT now points at the script.
Compose
deploy/compose.yaml — famapp now image: ${FAMAPP_IMAGE:-ghcr.io/ginnoir/famapp:latest} (build still works locally as fallback). Authentik pinned via AUTHENTIK_IMAGE_TAG (default 2024.12.3). New RUN_MIGRATIONS env passed through.
.env.production.example — documents FAMAPP_IMAGE, AUTHENTIK_IMAGE_TAG, RUN_MIGRATIONS.
CI/CD
.github/workflows/ci.yml — push/PR: typecheck + lint + format:check + build.
.github/workflows/release.yml — v* tag: build + push ghcr.io/ginnoir/famapp:vX.Y.Z, :X.Y, :latest to GHCR.
Docs
deploy/README.md — full deploy/rollback/release runbook.
CHANGELOG.md — release log seeded with an Unreleased entry.
docs/tasks/09-pre-deploy-checklist.md — task 09 reframed from one-shot removal to a recurring pre-deploy checklist.
STATUS.md — updated.
Verified: pnpm typecheck, pnpm format, pnpm build, and docker compose config all clean.
96 lines
3.9 KiB
Markdown
96 lines
3.9 KiB
Markdown
# 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>`:
|
||
|
||
```css
|
||
: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`)
|
||
|
||
```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.
|