···204204**Invariants:**
205205- User preference cookies are always validated against the current `policy.availableThemes` on read. Stale or removed theme URIs silently fall through to the forum default.
206206- `resolveTheme()` never throws -- it always returns a usable `ResolvedTheme`.
207207-- Settings routes (`/settings`, `/settings/appearance`, `/settings/preview`) require authentication. The preference form is only rendered when `policy.allowUserChoice` is true.
207207+- Settings routes (`/settings`, `/settings/appearance`) require authentication. The preview endpoint (`/settings/preview`) is unauthenticated — it returns only public theme data (color swatches). The preference form is only rendered when `policy.allowUserChoice` is true.
208208209209## Middleware Patterns
210210
···4545- **user-theme-preferences.AC5.2 Success:** Theme resolution ignores user preference cookies when `allowUserChoice: false` and uses forum default
46464747### user-theme-preferences.AC6: Preview endpoint
4848-- **user-theme-preferences.AC6.1 Success:** `GET /settings/preview?theme=<valid-uri>` returns an HTML fragment with swatches and theme name
4949-- **user-theme-preferences.AC6.2 Edge:** `GET /settings/preview?theme=<unknown-uri>` returns an empty `<div id="theme-preview">` fragment without crashing
4848+- **user-theme-preferences.AC6.1 Success:** `GET /settings/preview?lightThemeUri=<valid-uri>` (or `?darkThemeUri=`) returns an HTML fragment with swatches and theme name
4949+- **user-theme-preferences.AC6.2 Edge:** `GET /settings/preview?lightThemeUri=<unknown-uri>` (or `?darkThemeUri=`) returns an empty `<div id="theme-preview">` fragment without crashing
50505151## Glossary
5252···7474A new route file (`apps/web/src/routes/settings.tsx`) registers three endpoints:
75757676- `GET /settings` — fetches the theme policy, partitions available themes by `colorScheme`, reads existing preference cookies to pre-select values, and renders the settings page inside `BaseLayout`
7777-- `GET /settings/preview?theme=<uri>` — HTMX endpoint; fetches the theme by URI and returns an HTML fragment of color swatches (used to preview a theme before saving)
7777+- `GET /settings/preview?lightThemeUri=<uri>` (or `?darkThemeUri=<uri>`) — HTMX endpoint; whichever select fired sends its name/value via `hx-include="this"`. Fetches the theme by rkey and returns an HTML fragment of color swatches.
7878- `POST /settings/appearance` — validates `lightThemeUri` and `darkThemeUri` against the current policy's `availableThemes`, sets `atbb-light-theme` and `atbb-dark-theme` cookies, redirects 302 to `/settings?saved=1`
79798080The theme resolution waterfall in `apps/web/src/lib/theme-resolution.ts` gains a new first lookup step: read the user preference cookie for the active color scheme and validate the stored URI is still present in `availableThemes`. If valid, use it; if stale or missing, fall through to the existing forum-default and preset-fallback steps.
···106106107107**Done when:** Tests pass for all cases; `pnpm test` passes; theme resolution uses user cookie when valid.
108108109109-**Covers:** user-theme-preferences.AC3.1, user-theme-preferences.AC3.2, user-theme-preferences.AC3.3, user-theme-preferences.AC5.1, user-theme-preferences.AC5.2
109109+**Covers:** user-theme-preferences.AC3.1, user-theme-preferences.AC3.2, user-theme-preferences.AC3.3, user-theme-preferences.AC5.2
110110<!-- END_PHASE_1 -->
111111112112<!-- START_PHASE_2 -->
···125125126126**Done when:** All integration tests pass; `pnpm test` passes; preferences round-trip correctly.
127127128128-**Covers:** user-theme-preferences.AC1.1, user-theme-preferences.AC1.2, user-theme-preferences.AC2.1, user-theme-preferences.AC2.2, user-theme-preferences.AC2.3, user-theme-preferences.AC4.1, user-theme-preferences.AC4.2, user-theme-preferences.AC4.3, user-theme-preferences.AC4.4, user-theme-preferences.AC6.1, user-theme-preferences.AC6.2
128128+**Covers:** user-theme-preferences.AC1.2, user-theme-preferences.AC1.5, user-theme-preferences.AC2.1, user-theme-preferences.AC2.2, user-theme-preferences.AC2.3, user-theme-preferences.AC4.1, user-theme-preferences.AC4.2, user-theme-preferences.AC4.3, user-theme-preferences.AC4.4, user-theme-preferences.AC5.1
129129<!-- END_PHASE_2 -->
130130131131<!-- START_PHASE_3 -->
···134134**Goal:** Add the color swatch preview fragment returned when the user changes a `<select>`.
135135136136**Components:**
137137-- `GET /settings/preview?theme=<uri>` handler in `apps/web/src/routes/settings.tsx` — fetches theme from cache, extracts key tokens (`color-bg`, `color-surface`, `color-primary`, `color-text`, `color-border`), returns an HTML fragment with swatches and theme name; returns empty `<div id="theme-preview">` on unknown URI or fetch failure
138138-- `<select>` elements in the settings page carry `hx-get="/settings/preview?theme={value}"`, `hx-trigger="change"`, `hx-target="#theme-preview"`, `hx-swap="outerHTML"`
137137+- `GET /settings/preview` handler in `apps/web/src/routes/settings.tsx` — accepts `?lightThemeUri=` or `?darkThemeUri=` (whichever select fired via `hx-include="this"`); fetches theme from AppView by rkey, extracts key tokens (`color-bg`, `color-surface`, `color-primary`, `color-text`, `color-border`), returns an HTML fragment with swatches and theme name; returns empty `<div id="theme-preview">` on unknown URI or fetch failure
138138+- `<select>` elements in the settings page carry `hx-get="/settings/preview"`, `hx-trigger="change"`, `hx-target="#theme-preview"`, `hx-swap="outerHTML"`, `hx-include="this"`
139139- Tests: preview returns swatch fragment for valid URI; returns empty div for unknown URI; select attributes are present in rendered page HTML
140140141141**Dependencies:** Phase 2 (settings page and route file exist)
142142143143**Done when:** Tests pass; selecting a theme in the UI swaps in a swatch preview; `pnpm test` passes.
144144145145-**Covers:** user-theme-preferences.AC1.3, user-theme-preferences.AC1.4
145145+**Covers:** user-theme-preferences.AC1.3, user-theme-preferences.AC1.4, user-theme-preferences.AC6.1, user-theme-preferences.AC6.2
146146<!-- END_PHASE_3 -->
147147148148<!-- START_PHASE_4 -->
···158158159159**Done when:** Tests pass; authenticated users see Settings link; unauthenticated users do not; `pnpm test` passes.
160160161161-**Covers:** user-theme-preferences.AC1.1 (discoverability), user-theme-preferences.AC2.1
161161+**Covers:** user-theme-preferences.AC1.1
162162<!-- END_PHASE_4 -->
163163164164<!-- START_PHASE_5 -->