WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto

feat: user theme preferences (#99)

* docs: add user theme preferences design plan

Completed brainstorming session. Design includes:
- Cookie-based preference storage (atbb-light-theme, atbb-dark-theme)
- PRG form with HTMX live color swatch preview
- User preference as first step in theme resolution waterfall
- 5 implementation phases with full acceptance criteria

* feat: add resolveUserThemePreference to theme resolution waterfall

* test: resolveUserThemePreference unit and resolveTheme integration tests

* fix: use Array<T> syntax instead of T[] for complex object types

Per TypeScript house style, complex object array types should use
Array<{ uri: string }> syntax instead of { uri: string }[].

File: apps/web/src/lib/theme-resolution.ts, line 78
Function: resolveUserThemePreference()

* feat: add settings page with light/dark theme preference form

* test: settings route GET and POST integration tests

* fix: address code review feedback on Phase 2 settings routes

- [Critical] Wrap decodeURIComponent(errorParam) in try/catch to prevent URIError on malformed URLs
- [Minor] Change array type syntax from T[] to Array<T> for consistency
- [Minor] Remove duplicate POST happy-path test

All settings tests pass; build and lint succeed.

* fix: use Array<string> instead of string[] in settings test helper

* feat: add HTMX theme preview endpoint and wire up select elements

* test: settings preview endpoint and HTMX attribute tests

* feat: add Settings nav link for authenticated users

* test: Settings nav link auth visibility tests

* docs: add Bruno collection entries for settings endpoints

* docs: update project context for user-theme-preferences branch

Document the theme resolution waterfall (now 5 steps with user
preference cookies) and the cookie protocol contract for
atbb-light-theme / atbb-dark-theme in CLAUDE.md.

* docs: clarify preview endpoint is unauthenticated in CLAUDE.md

* docs: add test plan for user theme preferences

* chore: gitignore .claude/ and add user theme preferences implementation plan

Adds .claude/ to .gitignore (Claude Code local state, machine-specific).
Commits the 5-phase implementation plan and test requirements used to
implement user theme preferences.

* fix: address PR review feedback on user theme preferences

Security:
- Add CSS injection guard to ThemeSwatchPreview (rejects values containing
; < }) matching the pattern already used in admin-themes.tsx

Error handling:
- Split network/JSON try blocks in preview, GET themes list, POST policy
fetch — SyntaxError from res.json() is a data error, not a code bug,
and must not be re-thrown via isProgrammingError
- Promote logger.warn → logger.error for themes list fetch failure
- Add logger.warn to preview endpoint catch block (was silently swallowing
AppView failures)

User-facing:
- Map ?error= codes to friendly messages; drop unknown codes (phishing
vector for crafted URLs showing raw internal codes like "invalid-theme")

Tests:
- Add getSetCookie absence assertions to allowUserChoice:false and
invalid-theme POST rejection tests
- Update ?error=invalid-theme GET test to verify friendly message in
settings-banner--error element
- Add tests for themes list non-ok response and network throw paths
- Add test for unknown ?error= code producing no banner

Docs:
- Align theme-resolution.ts internal section comments to use descriptive
headings instead of "Step N" (conflicted with JSDoc 5-step waterfall)
- CLAUDE.md: clarify settings routes bypass ThemeCache intentionally

authored by

Malpercio and committed by
GitHub
f0e0556b 73d65a1e

+2976 -12
+3
.gitignore
··· 20 20 # OS 21 21 .DS_Store 22 22 23 + # Claude Code 24 + .claude/ 25 + 23 26 # Devenv 24 27 .devenv* 25 28 devenv.local.nix
+26
CLAUDE.md
··· 181 181 } 182 182 ``` 183 183 184 + ## Theme Resolution & Cookie Protocol 185 + 186 + <!-- freshness: 2026-03-20 --> 187 + 188 + The web app resolves which theme to render via a 5-step waterfall in `apps/web/src/lib/theme-resolution.ts`: 189 + 190 + 1. **Detect color scheme** -- `atbb-color-scheme` cookie or `Sec-CH-Prefers-Color-Scheme` hint (default: `light`) 191 + 2. **Fetch theme policy** -- `GET /api/theme-policy` from AppView (cached in-memory with TTL) 192 + 3. **User preference** -- `atbb-light-theme` / `atbb-dark-theme` cookie (validated against `policy.availableThemes`; ignored when `policy.allowUserChoice` is false) 193 + 4. **Forum default** -- `defaultLightThemeUri` / `defaultDarkThemeUri` from policy 194 + 5. **Hardcoded fallback** -- neobrutal-light or neobrutal-dark bundled presets 195 + 196 + **Cookie contracts:** 197 + 198 + | Cookie | Written by | Contains | Max-Age | 199 + |--------|-----------|----------|---------| 200 + | `atbb-color-scheme` | Client-side JS (toggle button) | `light` or `dark` | 1 year | 201 + | `atbb-light-theme` | `POST /settings/appearance` | AT URI of chosen light theme | 1 year | 202 + | `atbb-dark-theme` | `POST /settings/appearance` | AT URI of chosen dark theme | 1 year | 203 + 204 + **Invariants:** 205 + - 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. 206 + - `resolveTheme()` never throws -- it always returns a usable `ResolvedTheme`. 207 + - 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. 208 + - **Settings routes bypass ThemeCache intentionally.** `GET /settings` and `POST /settings/appearance` call `fetch()` directly (not via `resolveTheme()`), so they always get fresh policy data. This is by design: preference saves must validate against the current policy, not a potentially stale cached copy. 209 + 184 210 ## Middleware Patterns 185 211 186 212 ### Middleware Composition
+54
apps/web/src/layouts/__tests__/base.test.tsx
··· 211 211 expect(html).toContain('method="post"'); 212 212 expect(html).toContain("Log out"); 213 213 }); 214 + 215 + it("shows Settings link when authenticated", async () => { 216 + const auth: WebSession = { 217 + authenticated: true, 218 + did: "did:plc:abc123", 219 + handle: "alice.bsky.social", 220 + }; 221 + const authApp = new Hono().get("/", (c) => 222 + c.html( 223 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 224 + content 225 + </BaseLayout> 226 + ) 227 + ); 228 + const res = await authApp.request("/"); 229 + const html = await res.text(); 230 + expect(html).toContain('href="/settings"'); 231 + expect(html).toContain("Settings"); 232 + }); 233 + 234 + it("does not show Settings link when unauthenticated", async () => { 235 + const auth: WebSession = { authenticated: false }; 236 + const unauthApp = new Hono().get("/", (c) => 237 + c.html( 238 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 239 + content 240 + </BaseLayout> 241 + ) 242 + ); 243 + const res = await unauthApp.request("/"); 244 + const html = await res.text(); 245 + expect(html).not.toContain('href="/settings"'); 246 + expect(html).not.toContain("Settings"); 247 + }); 214 248 }); 215 249 216 250 describe("accessibility", () => { ··· 356 390 // Both mobile and desktop nav should have "Log out" 357 391 const logoutMatches = html.match(/Log out/g); 358 392 expect(logoutMatches!.length).toBe(2); 393 + }); 394 + 395 + it("renders Settings link in both desktop and mobile nav when authenticated", async () => { 396 + const auth: WebSession = { 397 + authenticated: true, 398 + did: "did:plc:abc123", 399 + handle: "alice.bsky.social", 400 + }; 401 + const authApp = new Hono().get("/", (c) => 402 + c.html( 403 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 404 + content 405 + </BaseLayout> 406 + ) 407 + ); 408 + const res = await authApp.request("/"); 409 + const html = await res.text(); 410 + // NavContent is rendered twice (desktop + mobile), so the link appears twice 411 + const settingsMatches = [...html.matchAll(/href="\/settings"/g)]; 412 + expect(settingsMatches).toHaveLength(2); 359 413 }); 360 414 }); 361 415 });
+3
apps/web/src/layouts/base.tsx
··· 24 24 {auth?.authenticated ? ( 25 25 <> 26 26 <span class="site-header__handle">{auth.handle}</span> 27 + <a href="/settings" class="site-header__settings-link"> 28 + Settings 29 + </a> 27 30 <form action="/logout" method="post" class="site-header__logout-form"> 28 31 <button type="submit" class="site-header__logout-btn"> 29 32 Log out
+132
apps/web/src/lib/__tests__/theme-resolution.test.ts
··· 2 2 import { 3 3 detectColorScheme, 4 4 parseRkeyFromUri, 5 + resolveUserThemePreference, 5 6 FALLBACK_THEME, 6 7 fallbackForScheme, 7 8 resolveTheme, ··· 62 63 63 64 it("returns null for empty string", () => { 64 65 expect(parseRkeyFromUri("")).toBeNull(); 66 + }); 67 + }); 68 + 69 + describe("resolveUserThemePreference", () => { 70 + const availableThemes = [ 71 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, 72 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, 73 + ]; 74 + 75 + it("returns null when allowUserChoice is false", () => { 76 + const result = resolveUserThemePreference( 77 + "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight", 78 + "light", 79 + availableThemes, 80 + false 81 + ); 82 + expect(result).toBeNull(); 83 + }); 84 + 85 + it("returns atbb-light-theme URI when cookie matches and is in availableThemes", () => { 86 + const result = resolveUserThemePreference( 87 + "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight", 88 + "light", 89 + availableThemes, 90 + true 91 + ); 92 + expect(result).toBe("at://did:plc:forum/space.atbb.forum.theme/3lbllight"); 93 + }); 94 + 95 + it("returns atbb-dark-theme URI when cookie matches and is in availableThemes", () => { 96 + const result = resolveUserThemePreference( 97 + "atbb-dark-theme=at://did:plc:forum/space.atbb.forum.theme/3lbldark", 98 + "dark", 99 + availableThemes, 100 + true 101 + ); 102 + expect(result).toBe("at://did:plc:forum/space.atbb.forum.theme/3lbldark"); 103 + }); 104 + 105 + it("returns null when cookie URI is not in availableThemes (stale/removed)", () => { 106 + const result = resolveUserThemePreference( 107 + "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/stale", 108 + "light", 109 + availableThemes, 110 + true 111 + ); 112 + expect(result).toBeNull(); 113 + }); 114 + 115 + it("returns null when cookieHeader is undefined", () => { 116 + const result = resolveUserThemePreference( 117 + undefined, 118 + "light", 119 + availableThemes, 120 + true 121 + ); 122 + expect(result).toBeNull(); 123 + }); 124 + 125 + it("returns null when cookie value is empty string after cookie name", () => { 126 + const result = resolveUserThemePreference( 127 + "atbb-light-theme=", 128 + "light", 129 + availableThemes, 130 + true 131 + ); 132 + expect(result).toBeNull(); 133 + }); 134 + 135 + it("does not match x-atbb-light-theme as a cookie prefix", () => { 136 + const result = resolveUserThemePreference( 137 + "x-atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight", 138 + "light", 139 + availableThemes, 140 + true 141 + ); 142 + expect(result).toBeNull(); 65 143 }); 66 144 }); 67 145 ··· 377 455 expect.stringContaining("CID mismatch"), 378 456 expect.any(Object) 379 457 ); 458 + }); 459 + 460 + it("resolves light preference cookie when URI is in availableThemes", async () => { 461 + mockFetch 462 + .mockResolvedValueOnce(policyResponse()) 463 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 464 + 465 + const cookieHeader = "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight"; 466 + const result = await resolveTheme(APPVIEW, cookieHeader, undefined); 467 + 468 + expect(result.tokens["color-bg"]).toBe("#fff"); 469 + expect(result.colorScheme).toBe("light"); 470 + // Verify that the user's theme was fetched (rkey 3lbllight) not the forum default 471 + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbllight")); 472 + }); 473 + 474 + it("resolves dark preference cookie when URI is in availableThemes", async () => { 475 + mockFetch 476 + .mockResolvedValueOnce(policyResponse()) 477 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 478 + 479 + const cookieHeader = "atbb-color-scheme=dark; atbb-dark-theme=at://did:plc:forum/space.atbb.forum.theme/3lbldark"; 480 + const result = await resolveTheme(APPVIEW, cookieHeader, undefined); 481 + 482 + expect(result.tokens["color-bg"]).toBe("#111"); 483 + expect(result.colorScheme).toBe("dark"); 484 + // Verify that the user's theme was fetched (rkey 3lbldark) 485 + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbldark")); 486 + }); 487 + 488 + it("falls back to forum default when preference cookie URI is not in availableThemes", async () => { 489 + mockFetch 490 + .mockResolvedValueOnce(policyResponse()) 491 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 492 + 493 + const cookieHeader = "atbb-color-scheme=light; atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/stale-uri"; 494 + const result = await resolveTheme(APPVIEW, cookieHeader, undefined); 495 + 496 + // Preference URI is stale, so forum default is used 497 + expect(result.tokens["color-bg"]).toBe("#fff"); 498 + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbllight")); // forum default rkey 499 + }); 500 + 501 + it("ignores preference cookie when policy has allowUserChoice: false", async () => { 502 + mockFetch 503 + .mockResolvedValueOnce(policyResponse({ allowUserChoice: false })) 504 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 505 + 506 + const cookieHeader = "atbb-color-scheme=light; atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/stale"; 507 + const result = await resolveTheme(APPVIEW, cookieHeader, undefined); 508 + 509 + // User choice is disabled, so forum default is used even though cookie is set 510 + expect(result.tokens["color-bg"]).toBe("#fff"); 511 + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbllight")); // forum default rkey 380 512 }); 381 513 }); 382 514
+37 -12
apps/web/src/lib/theme-resolution.ts
··· 67 67 return parts[4] ?? null; 68 68 } 69 69 70 + /** 71 + * Reads the user's theme preference cookie for the active color scheme and 72 + * validates it against the policy's availableThemes list. 73 + * Returns the validated AT URI, or null if absent, stale, or choice not allowed. 74 + */ 75 + export function resolveUserThemePreference( 76 + cookieHeader: string | undefined, 77 + colorScheme: "light" | "dark", 78 + availableThemes: Array<{ uri: string }>, 79 + allowUserChoice: boolean 80 + ): string | null { 81 + if (!allowUserChoice) return null; 82 + const cookieName = colorScheme === "light" ? "atbb-light-theme" : "atbb-dark-theme"; 83 + const match = cookieHeader?.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`)); 84 + if (!match) return null; 85 + const uri = match[1].trim(); 86 + return availableThemes.some((t) => t.uri === uri) ? uri : null; 87 + } 70 88 71 89 /** 72 90 * Resolves which theme to render for a request using the waterfall: 73 - * 1. User preference — not yet implemented (TODO: Theme Phase 4) 74 - * 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint 75 - * 3. Forum default — fetched from GET /api/theme-policy (cached in memory) 76 - * 4. Hardcoded fallback — neobrutal-light or neobrutal-dark per color scheme 91 + * 1. Detect color scheme — atbb-color-scheme cookie or Sec-CH hint 92 + * 2. Fetch theme policy — GET /api/theme-policy (cached in memory) 93 + * 3. User preference — atbb-light-theme / atbb-dark-theme cookie (validated against policy) 94 + * 4. Forum default — defaultLightThemeUri / defaultDarkThemeUri from policy 95 + * 5. Hardcoded fallback — neobrutal-light or neobrutal-dark per color scheme 77 96 * 78 97 * Pass a ThemeCache instance to enable in-memory TTL caching of policy and 79 98 * theme data. The cache is checked before each network request and populated ··· 88 107 cache?: ThemeCache 89 108 ): Promise<ResolvedTheme> { 90 109 const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); 91 - // TODO: user preference (Theme Phase 4) 92 110 93 - // ── Step 1: Get theme policy (from cache or AppView) ─────────────────────── 111 + // ── Policy: fetch or restore from cache ──────────────────────────────────── 94 112 let policy: CachedPolicy | null = cache?.getPolicy() ?? null; 95 113 96 114 if (!policy) { ··· 127 145 } 128 146 } 129 147 130 - // ── Step 2: Extract default theme URI and rkey ───────────────────────────── 148 + // ── URI resolution: user preference → forum default ─────────────────────── 149 + const userPreferenceUri = resolveUserThemePreference( 150 + cookieHeader, 151 + colorScheme, 152 + policy.availableThemes, 153 + policy.allowUserChoice 154 + ); 131 155 const defaultUri = 132 - colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri; 156 + userPreferenceUri ?? 157 + (colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri); 133 158 if (!defaultUri) return fallbackForScheme(colorScheme); 134 159 135 160 const rkey = parseRkeyFromUri(defaultUri); ··· 145 170 // cid may be absent for live refs (e.g. canonical atbb.space presets) — that is expected 146 171 const expectedCid = matchingTheme?.cid ?? null; 147 172 148 - // ── Step 3: Get theme (from cache or AppView) ────────────────────────────── 173 + // ── Theme data: restore from cache if available ──────────────────────────── 149 174 const cachedTheme: CachedTheme | null = cache?.getTheme(defaultUri, colorScheme) ?? null; 150 175 151 176 if (cachedTheme !== null) { ··· 173 198 } 174 199 } 175 200 176 - // ── Step 4: Fetch theme from AppView ────────────────────────────────────── 201 + // ── Theme data: fetch from AppView ──────────────────────────────────────── 177 202 let themeRes: Response; 178 203 try { 179 204 themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); ··· 196 221 return fallbackForScheme(colorScheme); 197 222 } 198 223 199 - // ── Step 5: Parse theme JSON ─────────────────────────────────────────────── 224 + // ── Theme data: parse response JSON ─────────────────────────────────────── 200 225 let theme: CachedTheme; 201 226 try { 202 227 theme = (await themeRes.json()) as CachedTheme; ··· 209 234 return fallbackForScheme(colorScheme); 210 235 } 211 236 212 - // ── Step 6: CID integrity check ──────────────────────────────────────────── 237 + // ── CID integrity check ──────────────────────────────────────────────────── 213 238 if (expectedCid && theme.cid !== expectedCid) { 214 239 logger.warn("Theme CID mismatch — using hardcoded fallback", { 215 240 operation: "resolveTheme",
+614
apps/web/src/routes/__tests__/settings.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("createSettingsRoutes — GET /settings and POST /settings/appearance", () => { 6 + beforeEach(() => { 7 + vi.stubGlobal("fetch", mockFetch); 8 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 9 + vi.resetModules(); 10 + }); 11 + 12 + afterEach(() => { 13 + vi.unstubAllGlobals(); 14 + vi.unstubAllEnvs(); 15 + mockFetch.mockReset(); 16 + }); 17 + 18 + function mockResponse(body: unknown, ok = true, status = 200) { 19 + return { 20 + ok, 21 + status, 22 + statusText: ok ? "OK" : "Error", 23 + json: () => Promise.resolve(body), 24 + }; 25 + } 26 + 27 + /** 28 + * Sets up the fetch mock sequence for an authenticated session on GET /settings. 29 + * Fetch order (no theme middleware in route factories): 30 + * 1. GET /settings handler: GET /api/auth/session 31 + * 2. GET /settings handler: GET /api/theme-policy 32 + * 3. GET /settings handler: GET /api/themes 33 + */ 34 + function setupAuthenticatedSessionGet( 35 + allowUserChoice: boolean = true, 36 + defaultLightThemeUri: string | null = "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 37 + defaultDarkThemeUri: string | null = "at://did:plc:forum/space.atbb.forum.theme/3lbldark" 38 + ) { 39 + const policyResponse = { 40 + allowUserChoice, 41 + defaultLightThemeUri, 42 + defaultDarkThemeUri, 43 + availableThemes: [ 44 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, 45 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, 46 + ], 47 + }; 48 + const themesResponse = { 49 + themes: [ 50 + { 51 + uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 52 + name: "Clean Light", 53 + colorScheme: "light", 54 + }, 55 + { 56 + uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 57 + name: "Neobrutal Dark", 58 + colorScheme: "dark", 59 + }, 60 + ], 61 + }; 62 + // GET /settings handler fetches 63 + mockFetch.mockResolvedValueOnce( 64 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 65 + ); 66 + mockFetch.mockResolvedValueOnce(mockResponse(policyResponse)); 67 + mockFetch.mockResolvedValueOnce(mockResponse(themesResponse)); 68 + } 69 + 70 + /** 71 + * Sets up the fetch mock sequence for an authenticated session on POST /settings/appearance. 72 + * Fetch order (no theme middleware in route factories): 73 + * 1. POST /settings/appearance handler: GET /api/auth/session 74 + * 2. POST /settings/appearance handler: GET /api/theme-policy 75 + */ 76 + function setupAuthenticatedSessionPost( 77 + allowUserChoice: boolean = true, 78 + availableThemeUris: Array<string> = [ 79 + "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 80 + "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 81 + ] 82 + ) { 83 + const policyResponse = { 84 + allowUserChoice, 85 + availableThemes: availableThemeUris.map((uri) => ({ uri })), 86 + }; 87 + // POST handler fetches 88 + mockFetch.mockResolvedValueOnce( 89 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 90 + ); 91 + mockFetch.mockResolvedValueOnce(mockResponse(policyResponse)); 92 + } 93 + 94 + async function loadSettingsRoutes() { 95 + const { createSettingsRoutes } = await import("../settings.js"); 96 + return createSettingsRoutes("http://localhost:3000"); 97 + } 98 + 99 + // ── GET /settings — Unauthenticated ────────────────────────────────── 100 + 101 + it("GET /settings redirects unauthenticated users to /login", async () => { 102 + // No cookie in request → getSession returns unauthenticated immediately without fetch 103 + // So no mocks needed 104 + const routes = await loadSettingsRoutes(); 105 + const res = await routes.request("/settings"); 106 + expect(res.status).toBe(302); 107 + expect(res.headers.get("location")).toBe("/login"); 108 + }); 109 + 110 + // ── GET /settings — Policy fetch failure ───────────────────────────── 111 + 112 + it("GET /settings shows error banner when policy fetch returns non-ok", async () => { 113 + // Fetch order: 114 + // 1. GET /settings handler: GET /api/auth/session 115 + // 2. GET /settings handler: GET /api/theme-policy (fails) 116 + mockFetch.mockResolvedValueOnce( 117 + // GET /settings: auth succeeds 118 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 119 + ); 120 + mockFetch.mockResolvedValueOnce( 121 + // GET /settings: policy fails 122 + mockResponse({}, false, 500) 123 + ); 124 + const routes = await loadSettingsRoutes(); 125 + const res = await routes.request("/settings", { 126 + headers: { cookie: "atbb_session=token" }, 127 + }); 128 + expect(res.status).toBe(200); 129 + const html = await res.text(); 130 + expect(html).toContain("Theme settings are temporarily unavailable"); 131 + expect(html).not.toContain("<select"); 132 + }); 133 + 134 + // ── GET /settings — Themes list fetch failure ──────────────────────── 135 + 136 + it("GET /settings renders form with empty selects when themes list returns non-ok", async () => { 137 + // Auth + policy succeed; themes fetch returns 500 138 + mockFetch.mockResolvedValueOnce( 139 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 140 + ); 141 + mockFetch.mockResolvedValueOnce( 142 + mockResponse({ 143 + allowUserChoice: true, 144 + defaultLightThemeUri: null, 145 + defaultDarkThemeUri: null, 146 + availableThemes: [], 147 + }) 148 + ); 149 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); // themes fetch fails 150 + const routes = await loadSettingsRoutes(); 151 + const res = await routes.request("/settings", { 152 + headers: { cookie: "atbb_session=token" }, 153 + }); 154 + expect(res.status).toBe(200); 155 + const html = await res.text(); 156 + // Form renders (no crash), but no theme options 157 + expect(html).toContain("id=\"lightThemeUri\""); 158 + expect(html).not.toContain("<option"); 159 + }); 160 + 161 + it("GET /settings renders form with empty selects when themes list fetch throws", async () => { 162 + // Auth + policy succeed; themes fetch throws network error 163 + mockFetch.mockResolvedValueOnce( 164 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 165 + ); 166 + mockFetch.mockResolvedValueOnce( 167 + mockResponse({ 168 + allowUserChoice: true, 169 + defaultLightThemeUri: null, 170 + defaultDarkThemeUri: null, 171 + availableThemes: [], 172 + }) 173 + ); 174 + mockFetch.mockRejectedValueOnce(new Error("Network error")); // themes fetch throws 175 + const routes = await loadSettingsRoutes(); 176 + const res = await routes.request("/settings", { 177 + headers: { cookie: "atbb_session=token" }, 178 + }); 179 + expect(res.status).toBe(200); 180 + const html = await res.text(); 181 + // Form renders (no crash), but no theme options 182 + expect(html).toContain("id=\"lightThemeUri\""); 183 + expect(html).not.toContain("<option"); 184 + }); 185 + 186 + // ── GET /settings — allowUserChoice: false ─────────────────────────── 187 + 188 + it("GET /settings shows informational banner when allowUserChoice: false", async () => { 189 + setupAuthenticatedSessionGet(false); 190 + const routes = await loadSettingsRoutes(); 191 + const res = await routes.request("/settings", { 192 + headers: { cookie: "atbb_session=token" }, 193 + }); 194 + expect(res.status).toBe(200); 195 + const html = await res.text(); 196 + expect(html).toContain("Theme selection is managed by the forum administrator"); 197 + expect(html).not.toContain("id=\"lightThemeUri\""); 198 + expect(html).not.toContain("id=\"darkThemeUri\""); 199 + }); 200 + 201 + // ── GET /settings — Happy path ─────────────────────────────────────── 202 + 203 + it("GET /settings renders form with light/dark theme selects when allowUserChoice: true", async () => { 204 + setupAuthenticatedSessionGet(); 205 + const routes = await loadSettingsRoutes(); 206 + const res = await routes.request("/settings", { 207 + headers: { cookie: "atbb_session=token" }, 208 + }); 209 + expect(res.status).toBe(200); 210 + const html = await res.text(); 211 + expect(html).toContain("id=\"lightThemeUri\""); 212 + expect(html).toContain("id=\"darkThemeUri\""); 213 + expect(html).toContain("Clean Light"); 214 + expect(html).toContain("Neobrutal Dark"); 215 + }); 216 + 217 + it("renders selects with hx-get attribute when allowUserChoice is true", async () => { 218 + setupAuthenticatedSessionGet(); 219 + const routes = await loadSettingsRoutes(); 220 + const res = await routes.request("/settings", { 221 + headers: { cookie: "atbb_session=token" }, 222 + }); 223 + expect(res.status).toBe(200); 224 + const html = await res.text(); 225 + expect(html).toContain('hx-get="/settings/preview"'); 226 + }); 227 + 228 + it("GET /settings with ?saved=1 shows success banner", async () => { 229 + setupAuthenticatedSessionGet(); 230 + const routes = await loadSettingsRoutes(); 231 + const res = await routes.request("/settings?saved=1", { 232 + headers: { cookie: "atbb_session=token" }, 233 + }); 234 + expect(res.status).toBe(200); 235 + const html = await res.text(); 236 + expect(html).toContain("Preferences saved"); 237 + }); 238 + 239 + it("GET /settings pre-selects current preference cookie value in dropdown", async () => { 240 + setupAuthenticatedSessionGet(); 241 + const routes = await loadSettingsRoutes(); 242 + const res = await routes.request("/settings", { 243 + headers: { 244 + cookie: 245 + "atbb_session=token; atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight", 246 + }, 247 + }); 248 + expect(res.status).toBe(200); 249 + const html = await res.text(); 250 + // Find the option with the URI and check it has selected attribute 251 + expect(html).toContain( 252 + 'value="at://did:plc:forum/space.atbb.forum.theme/3lbllight" selected' 253 + ); 254 + }); 255 + 256 + it("GET /settings with ?error=invalid-theme shows friendly message in error banner", async () => { 257 + setupAuthenticatedSessionGet(); 258 + const routes = await loadSettingsRoutes(); 259 + const res = await routes.request("/settings?error=invalid-theme", { 260 + headers: { cookie: "atbb_session=token" }, 261 + }); 262 + expect(res.status).toBe(200); 263 + const html = await res.text(); 264 + expect(html).toContain("settings-banner--error"); 265 + expect(html).toContain("no longer available"); 266 + }); 267 + 268 + it("GET /settings with unknown ?error= code shows no error banner", async () => { 269 + setupAuthenticatedSessionGet(); 270 + const routes = await loadSettingsRoutes(); 271 + const res = await routes.request("/settings?error=some-unknown-code", { 272 + headers: { cookie: "atbb_session=token" }, 273 + }); 274 + expect(res.status).toBe(200); 275 + const html = await res.text(); 276 + expect(html).not.toContain("settings-banner--error"); 277 + expect(html).not.toContain("some-unknown-code"); 278 + }); 279 + 280 + // ── POST /settings/appearance — Unauthenticated ────────────────────── 281 + 282 + it("POST /settings/appearance redirects unauthenticated users to /login", async () => { 283 + mockFetch.mockResolvedValueOnce( 284 + mockResponse({ authenticated: false }, false, 401) 285 + ); 286 + const routes = await loadSettingsRoutes(); 287 + const res = await routes.request("/settings/appearance", { 288 + method: "POST", 289 + headers: { "content-type": "application/x-www-form-urlencoded" }, 290 + body: new URLSearchParams({ 291 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 292 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 293 + }).toString(), 294 + }); 295 + expect(res.status).toBe(302); 296 + expect(res.headers.get("location")).toBe("/login"); 297 + // No cookie header means getSession returns unauthenticated 298 + // which triggers the redirect immediately without body parsing 299 + }); 300 + 301 + // ── POST /settings/appearance — Validation failures ─────────────────── 302 + 303 + it("POST /settings/appearance rejects empty body with ?error=invalid", async () => { 304 + mockFetch.mockResolvedValueOnce( 305 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 306 + ); 307 + const routes = await loadSettingsRoutes(); 308 + const res = await routes.request("/settings/appearance", { 309 + method: "POST", 310 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 311 + body: "", 312 + }); 313 + expect(res.status).toBe(302); 314 + expect(res.headers.get("location")).toBe("/settings?error=invalid"); 315 + }); 316 + 317 + it("POST /settings/appearance rejects missing lightThemeUri with ?error=invalid", async () => { 318 + mockFetch.mockResolvedValueOnce( 319 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 320 + ); 321 + const routes = await loadSettingsRoutes(); 322 + const res = await routes.request("/settings/appearance", { 323 + method: "POST", 324 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 325 + body: new URLSearchParams({ 326 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 327 + }).toString(), 328 + }); 329 + expect(res.status).toBe(302); 330 + expect(res.headers.get("location")).toBe("/settings?error=invalid"); 331 + }); 332 + 333 + it("POST /settings/appearance rejects missing darkThemeUri with ?error=invalid", async () => { 334 + mockFetch.mockResolvedValueOnce( 335 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 336 + ); 337 + const routes = await loadSettingsRoutes(); 338 + const res = await routes.request("/settings/appearance", { 339 + method: "POST", 340 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 341 + body: new URLSearchParams({ 342 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 343 + }).toString(), 344 + }); 345 + expect(res.status).toBe(302); 346 + expect(res.headers.get("location")).toBe("/settings?error=invalid"); 347 + }); 348 + 349 + it("POST /settings/appearance rejects invalid light theme URI with ?error=invalid-theme", async () => { 350 + setupAuthenticatedSessionPost(); 351 + const routes = await loadSettingsRoutes(); 352 + const res = await routes.request("/settings/appearance", { 353 + method: "POST", 354 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 355 + body: new URLSearchParams({ 356 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/invalid", 357 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 358 + }).toString(), 359 + }); 360 + expect(res.status).toBe(302); 361 + expect(res.headers.get("location")).toBe("/settings?error=invalid-theme"); 362 + const cookies = res.headers.getSetCookie?.() ?? []; 363 + expect(cookies.length).toBe(0); 364 + }); 365 + 366 + it("POST /settings/appearance rejects invalid dark theme URI with ?error=invalid-theme", async () => { 367 + setupAuthenticatedSessionPost(); 368 + const routes = await loadSettingsRoutes(); 369 + const res = await routes.request("/settings/appearance", { 370 + method: "POST", 371 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 372 + body: new URLSearchParams({ 373 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 374 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/invalid", 375 + }).toString(), 376 + }); 377 + expect(res.status).toBe(302); 378 + expect(res.headers.get("location")).toBe("/settings?error=invalid-theme"); 379 + const cookies = res.headers.getSetCookie?.() ?? []; 380 + expect(cookies.length).toBe(0); 381 + }); 382 + 383 + // ── POST /settings/appearance — Policy fetch failure ────────────────── 384 + 385 + it("POST /settings/appearance rejects when policy fetch fails with ?error=unavailable", async () => { 386 + // Fetch order: 387 + // 1. POST handler: GET /api/auth/session 388 + // 2. POST handler: GET /api/theme-policy (fails) 389 + mockFetch.mockResolvedValueOnce( 390 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 391 + ); 392 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); // policy fetch fails 393 + const routes = await loadSettingsRoutes(); 394 + const res = await routes.request("/settings/appearance", { 395 + method: "POST", 396 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 397 + body: new URLSearchParams({ 398 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 399 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 400 + }).toString(), 401 + }); 402 + expect(res.status).toBe(302); 403 + expect(res.headers.get("location")).toBe("/settings?error=unavailable"); 404 + // Verify no cookies are set 405 + const cookies = res.headers.getSetCookie?.() ?? []; 406 + expect(cookies.length).toBe(0); 407 + }); 408 + 409 + // ── POST /settings/appearance — allowUserChoice: false ──────────────── 410 + 411 + it("POST /settings/appearance rejects when allowUserChoice: false with ?error=not-allowed", async () => { 412 + setupAuthenticatedSessionPost(false); 413 + const routes = await loadSettingsRoutes(); 414 + const res = await routes.request("/settings/appearance", { 415 + method: "POST", 416 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 417 + body: new URLSearchParams({ 418 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 419 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 420 + }).toString(), 421 + }); 422 + expect(res.status).toBe(302); 423 + expect(res.headers.get("location")).toBe("/settings?error=not-allowed"); 424 + // Verify no cookies are set (reject before reaching cookie-write code) 425 + const cookies = res.headers.getSetCookie?.() ?? []; 426 + expect(cookies.length).toBe(0); 427 + }); 428 + 429 + // ── POST /settings/appearance — Happy path ────────────────────────── 430 + 431 + it("POST /settings/appearance sets theme cookies and redirects to /settings?saved=1", async () => { 432 + setupAuthenticatedSessionPost(); 433 + const routes = await loadSettingsRoutes(); 434 + const res = await routes.request("/settings/appearance", { 435 + method: "POST", 436 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 437 + body: new URLSearchParams({ 438 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 439 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 440 + }).toString(), 441 + }); 442 + expect(res.status).toBe(302); 443 + expect(res.headers.get("location")).toBe("/settings?saved=1"); 444 + 445 + // Verify both cookies are set 446 + const cookies = res.headers.getSetCookie?.() ?? []; 447 + expect(cookies.some((c) => c.startsWith("atbb-light-theme="))).toBe(true); 448 + expect(cookies.some((c) => c.startsWith("atbb-dark-theme="))).toBe(true); 449 + 450 + // Verify cookie values match 451 + expect(cookies.some((c) => 452 + c.startsWith( 453 + "atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight" 454 + ) 455 + )).toBe(true); 456 + expect(cookies.some((c) => 457 + c.startsWith("atbb-dark-theme=at://did:plc:forum/space.atbb.forum.theme/3lbldark") 458 + )).toBe(true); 459 + 460 + // Verify cookie attributes 461 + const lightCookie = cookies.find((c) => c.startsWith("atbb-light-theme=")); 462 + expect(lightCookie).toContain("Path=/"); 463 + expect(lightCookie).toContain("Max-Age=31536000"); 464 + expect(lightCookie).toContain("SameSite=Lax"); 465 + }); 466 + 467 + it("POST /settings/appearance does not set cookies when policy fetch fails", async () => { 468 + // Same as the unavailable test - just verifying no cookies are set 469 + mockFetch.mockResolvedValueOnce( 470 + mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }) 471 + ); 472 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 500)); 473 + const routes = await loadSettingsRoutes(); 474 + const res = await routes.request("/settings/appearance", { 475 + method: "POST", 476 + headers: { cookie: "atbb_session=token", "content-type": "application/x-www-form-urlencoded" }, 477 + body: new URLSearchParams({ 478 + lightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 479 + darkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 480 + }).toString(), 481 + }); 482 + expect(res.status).toBe(302); 483 + const cookies = res.headers.getSetCookie?.() ?? []; 484 + expect(cookies.length).toBe(0); 485 + }); 486 + 487 + // ── GET /settings/preview ──────────────────────────────────────────────────── 488 + 489 + describe("GET /settings/preview", () => { 490 + it("returns empty fragment when no query params provided", async () => { 491 + const routes = await loadSettingsRoutes(); 492 + const res = await routes.request("/settings/preview"); 493 + expect(res.status).toBe(200); 494 + const html = await res.text(); 495 + expect(html).toBe('<div id="theme-preview"></div>'); 496 + // Verify no fetch was made 497 + expect(mockFetch).not.toHaveBeenCalled(); 498 + }); 499 + 500 + it("returns empty fragment when lightThemeUri is malformed (no slash separators)", async () => { 501 + const routes = await loadSettingsRoutes(); 502 + const res = await routes.request( 503 + "/settings/preview?lightThemeUri=not-a-uri" 504 + ); 505 + expect(res.status).toBe(200); 506 + const html = await res.text(); 507 + expect(html).toBe('<div id="theme-preview"></div>'); 508 + // Verify no fetch was made for malformed URI 509 + expect(mockFetch).not.toHaveBeenCalled(); 510 + }); 511 + 512 + it("returns swatch preview for valid lightThemeUri", async () => { 513 + const themeResponse = { 514 + name: "Clean Light", 515 + colorScheme: "light", 516 + tokens: { 517 + "color-bg": "#f5f0e8", 518 + "color-surface": "#fff", 519 + "color-primary": "#ff5c00", 520 + "color-text": "#1a1a1a", 521 + "color-border": "#000", 522 + }, 523 + }; 524 + mockFetch.mockResolvedValueOnce(mockResponse(themeResponse)); 525 + 526 + const routes = await loadSettingsRoutes(); 527 + const res = await routes.request( 528 + "/settings/preview?lightThemeUri=at://did:plc:forum/space.atbb.forum.theme/3lbllight" 529 + ); 530 + expect(res.status).toBe(200); 531 + const html = await res.text(); 532 + expect(html).toContain('id="theme-preview"'); 533 + expect(html).toContain("Clean Light"); 534 + expect(html).toContain('class="theme-preview__swatch"'); 535 + // Verify the five swatch spans are present 536 + const swatchCount = (html.match(/class="theme-preview__swatch"/g) || []).length; 537 + expect(swatchCount).toBe(5); 538 + }); 539 + 540 + it("returns swatch preview for valid darkThemeUri", async () => { 541 + const themeResponse = { 542 + name: "Neobrutal Dark", 543 + colorScheme: "dark", 544 + tokens: { 545 + "color-bg": "#1a1a1a", 546 + "color-surface": "#2a2a2a", 547 + "color-primary": "#ff5c00", 548 + "color-text": "#f5f0e8", 549 + "color-border": "#3a3a3a", 550 + }, 551 + }; 552 + mockFetch.mockResolvedValueOnce(mockResponse(themeResponse)); 553 + 554 + const routes = await loadSettingsRoutes(); 555 + const res = await routes.request( 556 + "/settings/preview?darkThemeUri=at://did:plc:forum/space.atbb.forum.theme/3lbldark" 557 + ); 558 + expect(res.status).toBe(200); 559 + const html = await res.text(); 560 + expect(html).toContain('id="theme-preview"'); 561 + expect(html).toContain("Neobrutal Dark"); 562 + expect(html).toContain('class="theme-preview__swatch"'); 563 + }); 564 + 565 + it("returns empty fragment when /api/themes/:rkey returns non-ok status", async () => { 566 + mockFetch.mockResolvedValueOnce(mockResponse({}, false, 404)); 567 + 568 + const routes = await loadSettingsRoutes(); 569 + const res = await routes.request( 570 + "/settings/preview?lightThemeUri=at://did:plc:forum/space.atbb.forum.theme/unknown" 571 + ); 572 + expect(res.status).toBe(200); 573 + const html = await res.text(); 574 + expect(html).toBe('<div id="theme-preview"></div>'); 575 + }); 576 + 577 + it("returns empty fragment when fetch throws error (network failure)", async () => { 578 + mockFetch.mockRejectedValueOnce(new Error("Network error")); 579 + 580 + const routes = await loadSettingsRoutes(); 581 + const res = await routes.request( 582 + "/settings/preview?lightThemeUri=at://did:plc:forum/space.atbb.forum.theme/3lbllight" 583 + ); 584 + expect(res.status).toBe(200); 585 + const html = await res.text(); 586 + expect(html).toBe('<div id="theme-preview"></div>'); 587 + }); 588 + 589 + it("includes swatch color values in style attribute", async () => { 590 + const themeResponse = { 591 + name: "Test Theme", 592 + tokens: { 593 + "color-bg": "#ffffff", 594 + "color-surface": "#f0f0f0", 595 + "color-primary": "#0066ff", 596 + "color-text": "#000000", 597 + "color-border": "#cccccc", 598 + }, 599 + }; 600 + mockFetch.mockResolvedValueOnce(mockResponse(themeResponse)); 601 + 602 + const routes = await loadSettingsRoutes(); 603 + const res = await routes.request( 604 + "/settings/preview?lightThemeUri=at://did:plc:forum/space.atbb.forum.theme/test" 605 + ); 606 + expect(res.status).toBe(200); 607 + const html = await res.text(); 608 + expect(html).toContain('style="background:#ffffff"'); 609 + expect(html).toContain('style="background:#0066ff"'); 610 + expect(html).toContain('title="color-bg"'); 611 + expect(html).toContain('title="color-primary"'); 612 + }); 613 + }); 614 + });
+2
apps/web/src/routes/index.ts
··· 10 10 import { createAuthRoutes } from "./auth.js"; 11 11 import { createModActionRoute } from "./mod.js"; 12 12 import { createAdminRoutes } from "./admin.js"; 13 + import { createSettingsRoutes } from "./settings.js"; 13 14 import { createNotFoundRoute } from "./not-found.js"; 14 15 15 16 const config = loadConfig(); ··· 24 25 .route("/", createAuthRoutes(config.appviewUrl)) 25 26 .route("/", createModActionRoute(config.appviewUrl)) 26 27 .route("/", createAdminRoutes(config.appviewUrl)) 28 + .route("/", createSettingsRoutes(config.appviewUrl)) 27 29 .route("/", createNotFoundRoute(config.appviewUrl));
+321
apps/web/src/routes/settings.tsx
··· 1 + import { Hono } from "hono"; 2 + import { BaseLayout } from "../layouts/base.js"; 3 + import { getSession } from "../lib/session.js"; 4 + import { 5 + resolveUserThemePreference, 6 + FALLBACK_THEME, 7 + parseRkeyFromUri, 8 + } from "../lib/theme-resolution.js"; 9 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 10 + import { isProgrammingError } from "../lib/errors.js"; 11 + import { logger } from "../lib/logger.js"; 12 + 13 + type ThemeSummary = { uri: string; name: string; colorScheme: string }; 14 + type Policy = { 15 + availableThemes: Array<{ uri: string }>; 16 + allowUserChoice: boolean; 17 + defaultLightThemeUri: string | null; 18 + defaultDarkThemeUri: string | null; 19 + }; 20 + 21 + export function createSettingsRoutes(appviewUrl: string) { 22 + const app = new Hono<WebAppEnv>(); 23 + 24 + // ── GET /settings ────────────────────────────────────────────────────────── 25 + app.get("/settings", async (c) => { 26 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 27 + const cookieHeader = c.req.header("cookie"); 28 + const auth = await getSession(appviewUrl, cookieHeader); 29 + if (!auth.authenticated) return c.redirect("/login"); 30 + 31 + const saved = c.req.query("saved") === "1"; 32 + const rawError = c.req.query("error"); 33 + const ERROR_MESSAGES: Record<string, string> = { 34 + invalid: "Please fill in all required fields.", 35 + "invalid-theme": "The selected theme is no longer available. Please choose another.", 36 + "not-allowed": "Theme selection is managed by the forum administrator.", 37 + unavailable: "Theme settings are temporarily unavailable. Please try again later.", 38 + }; 39 + const errorMessage = rawError ? (ERROR_MESSAGES[rawError] ?? undefined) : undefined; 40 + 41 + // Fetch theme policy 42 + let policy: Policy | null = null; 43 + try { 44 + const policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 45 + if (policyRes.ok) { 46 + policy = (await policyRes.json()) as Policy; 47 + } 48 + } catch (error) { 49 + if (isProgrammingError(error)) throw error; 50 + logger.error("Failed to fetch theme policy for settings page", { 51 + operation: "GET /settings", 52 + error: error instanceof Error ? error.message : String(error), 53 + }); 54 + } 55 + 56 + if (!policy) { 57 + return c.html( 58 + <BaseLayout title="Settings — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 59 + <main class="settings-page"> 60 + <h1>Settings</h1> 61 + <p class="settings-banner settings-banner--error"> 62 + Theme settings are temporarily unavailable. Please try again later. 63 + </p> 64 + </main> 65 + </BaseLayout> 66 + ); 67 + } 68 + 69 + // Fetch available themes list (already filtered by policy server-side in AppView) 70 + let allThemes: Array<ThemeSummary> = []; 71 + let themesRes: Response | undefined; 72 + try { 73 + themesRes = await fetch(`${appviewUrl}/api/themes`); 74 + } catch (err) { 75 + if (isProgrammingError(err)) throw err; 76 + logger.error("Failed to fetch themes list for settings page", { 77 + operation: "GET /settings", 78 + error: err instanceof Error ? err.message : String(err), 79 + }); 80 + } 81 + if (themesRes?.ok) { 82 + try { 83 + const data = (await themesRes.json()) as { themes: Array<ThemeSummary> }; 84 + allThemes = data.themes ?? []; 85 + } catch { 86 + logger.error("Themes list response contained invalid JSON", { 87 + operation: "GET /settings", 88 + }); 89 + } 90 + } 91 + 92 + const lightThemes = allThemes.filter((t) => t.colorScheme === "light"); 93 + const darkThemes = allThemes.filter((t) => t.colorScheme === "dark"); 94 + 95 + // Pre-select current preference cookie, falling back to forum default 96 + const currentLightUri = 97 + resolveUserThemePreference(cookieHeader, "light", allThemes, policy.allowUserChoice) ?? 98 + policy.defaultLightThemeUri; 99 + const currentDarkUri = 100 + resolveUserThemePreference(cookieHeader, "dark", allThemes, policy.allowUserChoice) ?? 101 + policy.defaultDarkThemeUri; 102 + 103 + return c.html( 104 + <BaseLayout title="Settings — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 105 + <main class="settings-page"> 106 + <h1>Settings</h1> 107 + {saved && ( 108 + <p class="settings-banner settings-banner--success">Preferences saved.</p> 109 + )} 110 + {errorMessage && ( 111 + <p class="settings-banner settings-banner--error"> 112 + {errorMessage} 113 + </p> 114 + )} 115 + <section> 116 + <h2>Appearance</h2> 117 + {policy.allowUserChoice ? ( 118 + <form method="post" action="/settings/appearance" class="settings-form"> 119 + <div class="settings-form__field"> 120 + <label for="lightThemeUri">Light theme</label> 121 + <select 122 + id="lightThemeUri" 123 + name="lightThemeUri" 124 + hx-get="/settings/preview" 125 + hx-trigger="change" 126 + hx-target="#theme-preview" 127 + hx-swap="outerHTML" 128 + hx-include="this"> 129 + {lightThemes.map((t) => ( 130 + <option value={t.uri} selected={t.uri === currentLightUri}> 131 + {t.name} 132 + </option> 133 + ))} 134 + </select> 135 + </div> 136 + <div class="settings-form__field"> 137 + <label for="darkThemeUri">Dark theme</label> 138 + <select 139 + id="darkThemeUri" 140 + name="darkThemeUri" 141 + hx-get="/settings/preview" 142 + hx-trigger="change" 143 + hx-target="#theme-preview" 144 + hx-swap="outerHTML" 145 + hx-include="this"> 146 + {darkThemes.map((t) => ( 147 + <option value={t.uri} selected={t.uri === currentDarkUri}> 148 + {t.name} 149 + </option> 150 + ))} 151 + </select> 152 + </div> 153 + <div id="theme-preview"></div> 154 + <button type="submit" class="settings-form__submit"> 155 + Save preferences 156 + </button> 157 + </form> 158 + ) : ( 159 + <p class="settings-banner"> 160 + Theme selection is managed by the forum administrator. 161 + </p> 162 + )} 163 + </section> 164 + </main> 165 + </BaseLayout> 166 + ); 167 + }); 168 + 169 + // ── POST /settings/appearance ─────────────────────────────────────────────── 170 + app.post("/settings/appearance", async (c) => { 171 + const cookieHeader = c.req.header("cookie"); 172 + const auth = await getSession(appviewUrl, cookieHeader); 173 + if (!auth.authenticated) return c.redirect("/login"); 174 + 175 + let body: Record<string, string | File>; 176 + try { 177 + body = await c.req.parseBody(); 178 + } catch (err) { 179 + if (isProgrammingError(err)) throw err; 180 + return c.redirect("/settings?error=invalid", 302); 181 + } 182 + 183 + const lightThemeUri = 184 + typeof body.lightThemeUri === "string" ? body.lightThemeUri.trim() : ""; 185 + const darkThemeUri = 186 + typeof body.darkThemeUri === "string" ? body.darkThemeUri.trim() : ""; 187 + if (!lightThemeUri || !darkThemeUri) { 188 + return c.redirect("/settings?error=invalid", 302); 189 + } 190 + 191 + // Fetch FRESH policy (bypass cache) so recently-removed themes can't be saved 192 + type PostPolicy = { availableThemes: Array<{ uri: string }>; allowUserChoice: boolean }; 193 + let policy: PostPolicy | null = null; 194 + let postPolicyRes: Response | undefined; 195 + try { 196 + postPolicyRes = await fetch(`${appviewUrl}/api/theme-policy`); 197 + } catch (err) { 198 + if (isProgrammingError(err)) throw err; 199 + logger.error("Failed to fetch theme policy during preference save", { 200 + operation: "POST /settings/appearance", 201 + error: err instanceof Error ? err.message : String(err), 202 + }); 203 + } 204 + if (postPolicyRes?.ok) { 205 + try { 206 + policy = (await postPolicyRes.json()) as PostPolicy; 207 + } catch { 208 + logger.error("Theme policy response contained invalid JSON during preference save", { 209 + operation: "POST /settings/appearance", 210 + }); 211 + } 212 + } 213 + 214 + if (!policy) { 215 + return c.redirect("/settings?error=unavailable", 302); 216 + } 217 + if (!policy.allowUserChoice) { 218 + return c.redirect("/settings?error=not-allowed", 302); 219 + } 220 + 221 + const availableUris = policy.availableThemes.map((t: { uri: string }) => t.uri); 222 + if (!availableUris.includes(lightThemeUri) || !availableUris.includes(darkThemeUri)) { 223 + return c.redirect("/settings?error=invalid-theme", 302); 224 + } 225 + 226 + // Set preference cookies (1 year). 227 + // AT URIs (at://did:plc:.../rkey) are valid cookie values per RFC 6265 — 228 + // colons and slashes are permitted; no encoding needed. 229 + const headers = new Headers(); 230 + headers.append( 231 + "set-cookie", 232 + `atbb-light-theme=${lightThemeUri}; Path=/; Max-Age=31536000; SameSite=Lax` 233 + ); 234 + headers.append( 235 + "set-cookie", 236 + `atbb-dark-theme=${darkThemeUri}; Path=/; Max-Age=31536000; SameSite=Lax` 237 + ); 238 + headers.set("location", "/settings?saved=1"); 239 + return new Response(null, { status: 302, headers }); 240 + }); 241 + 242 + // ── ThemeSwatchPreview component ──────────────────────────────────────────── 243 + function ThemeSwatchPreview({ 244 + name, 245 + tokens, 246 + }: { 247 + name: string; 248 + tokens: Record<string, string>; 249 + }) { 250 + const swatchTokens = [ 251 + "color-bg", 252 + "color-surface", 253 + "color-primary", 254 + "color-text", 255 + "color-border", 256 + ] as const; 257 + return ( 258 + <div id="theme-preview" class="theme-preview"> 259 + <span class="theme-preview__name">{name}</span> 260 + <div class="theme-preview__swatches"> 261 + {swatchTokens.map((token) => { 262 + const color = tokens[token]; 263 + // Reject values that could escape the style attribute via CSS injection 264 + if (!color || color.includes(";") || color.includes("<") || color.includes("}")) { 265 + return null; 266 + } 267 + return ( 268 + <span 269 + class="theme-preview__swatch" 270 + style={`background:${color}`} 271 + title={token} 272 + /> 273 + ); 274 + })} 275 + </div> 276 + </div> 277 + ); 278 + } 279 + 280 + // ── GET /settings/preview ──────────────────────────────────────────────────── 281 + app.get("/settings/preview", async (c) => { 282 + const emptyFragment = <div id="theme-preview"></div>; 283 + const themeUri = c.req.query("lightThemeUri") ?? c.req.query("darkThemeUri"); 284 + if (!themeUri) return c.html(emptyFragment); 285 + 286 + const rkey = parseRkeyFromUri(themeUri); 287 + if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return c.html(emptyFragment); 288 + 289 + let previewRes: Response; 290 + try { 291 + previewRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); 292 + } catch (err) { 293 + if (isProgrammingError(err)) throw err; 294 + logger.warn("Failed to fetch theme for preview", { 295 + operation: "GET /settings/preview", 296 + rkey, 297 + error: err instanceof Error ? err.message : String(err), 298 + }); 299 + return c.html(emptyFragment); 300 + } 301 + if (!previewRes.ok) return c.html(emptyFragment); 302 + 303 + try { 304 + const theme = (await previewRes.json()) as { 305 + name: string; 306 + tokens: Record<string, string>; 307 + }; 308 + return c.html( 309 + <ThemeSwatchPreview name={theme.name ?? ""} tokens={theme.tokens ?? {}} /> 310 + ); 311 + } catch { 312 + logger.warn("Theme preview response contained invalid JSON", { 313 + operation: "GET /settings/preview", 314 + rkey, 315 + }); 316 + return c.html(emptyFragment); 317 + } 318 + }); 319 + 320 + return app; 321 + }
+43
bruno/AppView API/Settings/Get Settings Page.bru
··· 1 + meta { 2 + name: Get Settings Page 3 + type: http 4 + seq: 1 5 + } 6 + 7 + get { 8 + url: {{web_url}}/settings 9 + } 10 + 11 + assert { 12 + res.status: eq 200 13 + } 14 + 15 + docs { 16 + Renders the user settings page. 17 + 18 + Requires authentication (session cookie). Unauthenticated users are redirected 19 + to /login (302). 20 + 21 + Returns HTML page containing: 22 + - Light-theme <select id="lightThemeUri"> when allowUserChoice is true 23 + - Dark-theme <select id="darkThemeUri"> when allowUserChoice is true 24 + - Informational banner when allowUserChoice is false 25 + - "Preferences saved." banner when ?saved=1 query param is present 26 + - Error banner when ?error=<message> query param is present 27 + - <div id="theme-preview"> HTMX swap target 28 + 29 + Both selects carry HTMX attributes: 30 + hx-get="/settings/preview" 31 + hx-trigger="change" 32 + hx-target="#theme-preview" 33 + hx-swap="outerHTML" 34 + hx-include="this" 35 + 36 + Query parameters: 37 + - saved: "1" (optional) — shows "Preferences saved." success banner 38 + - error: string (optional) — shows decoded error message in error banner 39 + 40 + Error codes: 41 + - 302: Not authenticated → redirects to /login 42 + - 200: Success — page rendered (with or without selects depending on policy) 43 + }
+50
bruno/AppView API/Settings/Preview Theme.bru
··· 1 + meta { 2 + name: Preview Theme 3 + type: http 4 + seq: 2 5 + } 6 + 7 + get { 8 + url: {{web_url}}/settings/preview?lightThemeUri=at://{{forum_did}}/space.atbb.forum.theme/3lbllight 9 + } 10 + 11 + params:query { 12 + lightThemeUri: at://{{forum_did}}/space.atbb.forum.theme/3lbllight 13 + } 14 + 15 + assert { 16 + res.status: eq 200 17 + } 18 + 19 + docs { 20 + HTMX endpoint — returns an HTML fragment with color swatches for the given theme. 21 + 22 + Called automatically by the settings page when the user changes a theme select. 23 + Returns a <div id="theme-preview"> fragment that HTMX swaps into the page. 24 + 25 + Accepts exactly one of: 26 + - lightThemeUri: AT URI of a light theme 27 + - darkThemeUri: AT URI of a dark theme 28 + 29 + The endpoint fetches the theme from the AppView by rkey, extracts these tokens: 30 + color-bg, color-surface, color-primary, color-text, color-border 31 + 32 + Returns the fragment: 33 + <div id="theme-preview" class="theme-preview"> 34 + <span class="theme-preview__name">Theme Name</span> 35 + <div class="theme-preview__swatches"> 36 + <span class="theme-preview__swatch" style="background:#hex" title="color-bg" /> 37 + ... 38 + </div> 39 + </div> 40 + 41 + Returns empty fragment on any of: 42 + - No query param provided 43 + - URI fails rkey extraction (malformed) 44 + - AppView returns non-ok status (unknown theme) 45 + - Network error reaching AppView 46 + 47 + Error codes: 48 + - 200: Always — never errors, returns empty fragment on failure 49 + Empty fragment: <div id="theme-preview"></div> 50 + }
+49
bruno/AppView API/Settings/Save Appearance.bru
··· 1 + meta { 2 + name: Save Appearance 3 + type: http 4 + seq: 3 5 + } 6 + 7 + post { 8 + url: {{web_url}}/settings/appearance 9 + } 10 + 11 + body:form { 12 + lightThemeUri: at://{{forum_did}}/space.atbb.forum.theme/3lbllight 13 + darkThemeUri: at://{{forum_did}}/space.atbb.forum.theme/3lbldark 14 + } 15 + 16 + assert { 17 + res.status: eq 302 18 + res.headers.location: contains /settings 19 + } 20 + 21 + docs { 22 + Saves the user's light and dark theme preferences as cookies. 23 + 24 + Requires authentication (session cookie). Unauthenticated users are redirected 25 + to /login (302). 26 + 27 + Body parameters (application/x-www-form-urlencoded): 28 + - lightThemeUri: string (required) — AT URI of the chosen light theme 29 + - darkThemeUri: string (required) — AT URI of the chosen dark theme 30 + 31 + Validation: 32 + 1. Both URIs must be non-empty strings 33 + 2. Forum theme policy is re-fetched fresh (bypasses cache) to prevent stale themes 34 + 3. allowUserChoice must be true in the policy 35 + 4. Both URIs must be present in policy.availableThemes 36 + 37 + On success: 38 + - Sets cookie: atbb-light-theme=<uri>; Path=/; Max-Age=31536000; SameSite=Lax 39 + - Sets cookie: atbb-dark-theme=<uri>; Path=/; Max-Age=31536000; SameSite=Lax 40 + - Redirects 302 to /settings?saved=1 41 + 42 + Error codes: 43 + - 302 → /login: Not authenticated 44 + - 302 → /settings?error=invalid: Missing or non-string body fields 45 + - 302 → /settings?error=unavailable: Policy fetch failed (no cookies set) 46 + - 302 → /settings?error=not-allowed: allowUserChoice is false 47 + - 302 → /settings?error=invalid-theme: URI not in availableThemes 48 + - 302 → /settings?saved=1: Success (cookies set) 49 + }
+184
docs/design-plans/2026-03-20-user-theme-preferences.md
··· 1 + # User Theme Preferences Design 2 + 3 + ## Summary 4 + 5 + This design adds a `/settings` page where authenticated users can select their preferred light and dark themes from the set the forum administrator has made available. Preferences are stored as two browser cookies (`atbb-light-theme`, `atbb-dark-theme`) and applied on every subsequent page load without requiring the user to reconfigure anything. A live color swatch preview, powered by HTMX, lets users see a theme's palette immediately when they change the dropdown selection — before they commit by saving the form. 6 + 7 + The implementation slots into the existing theme system. The forum already resolves a theme per request through a "waterfall": it checks the color-scheme cookie, looks up the forum's default theme from a cached policy, and falls back to a hardcoded preset if everything else fails. This design adds a new first step to that waterfall: if the user has a saved preference cookie and the referenced theme is still present in the current policy, it is used instead of the forum default. The settings page reuses the same `ThemeCache`, the same POST-Redirect-GET form pattern as the admin theme editor, and the same raw `Set-Cookie` header approach as the authentication routes. 8 + 9 + ## Definition of Done 10 + 11 + - Users can open a `/settings` page and choose which light theme and which dark theme to use from the forum's available themes 12 + - Preferences are stored as two cookies (`atbb-light-theme`, `atbb-dark-theme`) and applied on every page load 13 + - Selecting a theme in the settings UI shows a live color preview before the user saves 14 + - The theme resolution waterfall respects user preferences over forum defaults 15 + - If `allowUserChoice` is false, the settings page informs the user and does not offer selection 16 + - Server validates cookie values against the current theme policy before applying 17 + 18 + ## Acceptance Criteria 19 + 20 + ### user-theme-preferences.AC1: Settings page is accessible 21 + - **user-theme-preferences.AC1.1 Success:** Authenticated users see a Settings link in the site nav 22 + - **user-theme-preferences.AC1.2 Success:** `/settings` renders with light-theme and dark-theme selects when `allowUserChoice: true` 23 + - **user-theme-preferences.AC1.3 Success:** Changing the light-theme select swaps in a color swatch preview 24 + - **user-theme-preferences.AC1.4 Success:** Changing the dark-theme select swaps in a color swatch preview 25 + - **user-theme-preferences.AC1.5 Failure:** Unauthenticated users visiting `/settings` are redirected to `/login` 26 + 27 + ### user-theme-preferences.AC2: Preferences can be saved 28 + - **user-theme-preferences.AC2.1 Success:** Submitting the form with valid URIs sets `atbb-light-theme` and `atbb-dark-theme` cookies 29 + - **user-theme-preferences.AC2.2 Success:** After saving, the page shows a "Preferences saved" confirmation banner 30 + - **user-theme-preferences.AC2.3 Success:** On revisiting `/settings`, the saved themes are pre-selected in the dropdowns 31 + 32 + ### user-theme-preferences.AC3: Preferences are applied on page load 33 + - **user-theme-preferences.AC3.1 Success:** When `atbb-light-theme` cookie is set and still in the policy, that theme renders in light mode 34 + - **user-theme-preferences.AC3.2 Success:** When `atbb-dark-theme` cookie is set and still in the policy, that theme renders in dark mode 35 + - **user-theme-preferences.AC3.3 Edge:** When a cookie URI is no longer in the theme policy, the forum default is used silently (no error page) 36 + 37 + ### user-theme-preferences.AC4: Server validates inputs 38 + - **user-theme-preferences.AC4.1 Failure:** POST with a URI absent from `availableThemes` is rejected (redirects with `?error=invalid-theme`) 39 + - **user-theme-preferences.AC4.2 Failure:** POST when `allowUserChoice: false` is rejected server-side (even without the form) 40 + - **user-theme-preferences.AC4.3 Failure:** POST with missing or malformed body fields is rejected (`?error=invalid`) 41 + - **user-theme-preferences.AC4.4 Failure:** POST when policy fetch fails returns safe error — no cookies are set 42 + 43 + ### user-theme-preferences.AC5: `allowUserChoice: false` is respected 44 + - **user-theme-preferences.AC5.1 Success:** Settings page shows an informational banner instead of selects when `allowUserChoice: false` 45 + - **user-theme-preferences.AC5.2 Success:** Theme resolution ignores user preference cookies when `allowUserChoice: false` and uses forum default 46 + 47 + ### user-theme-preferences.AC6: Preview endpoint 48 + - **user-theme-preferences.AC6.1 Success:** `GET /settings/preview?lightThemeUri=<valid-uri>` (or `?darkThemeUri=`) returns an HTML fragment with swatches and theme name 49 + - **user-theme-preferences.AC6.2 Edge:** `GET /settings/preview?lightThemeUri=<unknown-uri>` (or `?darkThemeUri=`) returns an empty `<div id="theme-preview">` fragment without crashing 50 + 51 + ## Glossary 52 + 53 + - **allowUserChoice**: A field on the forum's theme policy that determines whether users are permitted to override the forum's default theme. When `false`, the settings page shows an informational message and the POST endpoint rejects submissions server-side. 54 + - **AT URI**: A stable identifier for a record in the AT Protocol, in the form `at://<did>/<collection>/<rkey>`. Themes are referenced by AT URI throughout this design rather than by a name or database ID. 55 + - **availableThemes**: The list of theme AT URIs (with optional CIDs) that the forum administrator has permitted for use. User-submitted theme preferences are validated against this list. 56 + - **BaseLayout / NavContent**: The shared Hono JSX layout component that wraps every page. `NavContent` is the portion that renders the navigation bar, where the Settings link is added. 57 + - **CID (Content Identifier)**: A content-addressed hash of an AT Protocol record's data. A theme policy may reference a specific CID to pin a theme to a version. 58 + - **colorScheme**: Whether a given theme is intended for light or dark display environments. The forum selects which theme to apply based on the user's detected color scheme and this field. 59 + - **defense-in-depth**: A security principle applied here by validating `allowUserChoice` on the server side even when the UI already hides the form, so a crafted HTTP request cannot bypass an administrator's configuration. 60 + - **fail-closed**: A security posture where any error (e.g., a failed policy fetch) results in a safe default being used rather than an unsafe action being permitted. 61 + - **Hono**: The TypeScript web framework used for both the AppView and the web frontend. Routes, middleware, and JSX templates all use Hono's API. 62 + - **HTML fragment**: A partial HTML response (not a full page) returned by an endpoint. HTMX swaps the fragment into the existing page DOM without a full navigation. The `/settings/preview` endpoint returns a fragment of color swatches. 63 + - **HTMX**: A library that adds declarative Ajax and partial-page updates to HTML via attributes like `hx-get`, `hx-trigger`, and `hx-target`. Used here to fire the preview endpoint when a `<select>` changes. 64 + - **POST-Redirect-GET (PRG)**: A web pattern where a form submission (POST) is handled server-side, then the server responds with a redirect (302) to a GET URL. Prevents duplicate submissions on browser refresh; used here for saving theme preferences. 65 + - **theme policy**: The forum-level configuration record (fetched from the AppView at `/api/theme-policy`) that specifies which themes are available, which are defaults for light/dark mode, and whether users may choose their own. 66 + - **theme resolution waterfall**: The ordered sequence of fallback steps the server uses to decide which theme to render for a request. This design adds user preference cookies as the first (highest-priority) step before the forum default. 67 + - **ThemeCache**: The in-memory, TTL-based cache in `apps/web/src/lib/theme-cache.ts` that stores fetched theme policy and individual theme token data for up to 5 minutes, reducing repeated AppView requests. 68 + - **TTL (time-to-live)**: The duration an item is kept in a cache before being considered stale. The `ThemeCache` uses a 5-minute TTL. 69 + 70 + ## Architecture 71 + 72 + PRG (POST-Redirect-GET) for saving, with an HTMX live-preview panel that fires on `<select>` change before the user commits. 73 + 74 + A new route file (`apps/web/src/routes/settings.tsx`) registers three endpoints: 75 + 76 + - `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` 77 + - `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. 78 + - `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` 79 + 80 + The 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. 81 + 82 + `BaseLayout`'s `NavContent` gains a Settings link visible only to authenticated users. 83 + 84 + ## Existing Patterns 85 + 86 + The closest codebase analogue is the admin theme policy form in `apps/web/src/routes/admin-themes.tsx`. That form uses plain `<form method="post">` with `c.req.parseBody()` on the POST handler and a 302 redirect on success — the same PRG pattern this design follows. 87 + 88 + HTMX live preview is already used in `admin-themes.tsx` (`POST /admin/themes/:rkey/preview`) for the admin theme editor. The user-facing preview here uses `hx-get` on `<select> change` instead of `hx-post`, but the fragment-return pattern is the same. 89 + 90 + Cookie handling follows `apps/web/src/routes/auth.ts` (lines 47–50): raw `Set-Cookie` headers on a `new Response()`, not Hono's `c.cookie()` helper. 91 + 92 + Theme policy and individual theme data are fetched through `apps/web/src/lib/theme-cache.ts`, which provides a 5-minute in-memory TTL cache. This design uses the same cache for both the settings page render and the POST validation step. 93 + 94 + ## Implementation Phases 95 + 96 + <!-- START_PHASE_1 --> 97 + ### Phase 1: Theme Resolution — User Preference Waterfall 98 + 99 + **Goal:** Make the theme system read and apply user preference cookies before falling back to forum defaults. 100 + 101 + **Components:** 102 + - `apps/web/src/lib/theme-resolution.ts` — new `resolveUserThemePreference()` function and updated `resolveTheme()` call site to slot it in at step 1 of the waterfall 103 + - Unit tests for `resolveUserThemePreference()` covering: valid cookie URI in policy (uses it), stale URI not in policy (returns null), missing cookie (returns null), `allowUserChoice: false` (returns null) 104 + 105 + **Dependencies:** None — this is a pure function change with no UI surface. 106 + 107 + **Done when:** Tests pass for all cases; `pnpm test` passes; theme resolution uses user cookie when valid. 108 + 109 + **Covers:** user-theme-preferences.AC3.1, user-theme-preferences.AC3.2, user-theme-preferences.AC3.3, user-theme-preferences.AC5.2 110 + <!-- END_PHASE_1 --> 111 + 112 + <!-- START_PHASE_2 --> 113 + ### Phase 2: Settings Route — GET and POST Handlers 114 + 115 + **Goal:** Implement the settings page route with form rendering and preference saving. 116 + 117 + **Components:** 118 + - `apps/web/src/routes/settings.tsx` — new route file with `GET /settings` and `POST /settings/appearance` handlers 119 + - `GET /settings`: reads theme policy from cache, partitions themes by `colorScheme`, reads preference cookies, renders settings page inside `BaseLayout`; redirects unauthenticated users to `/login`; shows informational banner when `allowUserChoice: false`; shows `?saved=1` and `?error=*` banners 120 + - `POST /settings/appearance`: parses form body, fetches fresh policy, validates URIs in `availableThemes`, sets raw `Set-Cookie` headers, redirects 302 121 + - Route registered in `apps/web/src/index.ts` (or equivalent main router) 122 + - Integration tests covering: page renders for authenticated user, redirects unauthenticated to login, saves valid preferences, rejects URIs not in policy, handles `allowUserChoice: false`, handles policy fetch failure 123 + 124 + **Dependencies:** Phase 1 (theme resolution updated to use cookies) 125 + 126 + **Done when:** All integration tests pass; `pnpm test` passes; preferences round-trip correctly. 127 + 128 + **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 129 + <!-- END_PHASE_2 --> 130 + 131 + <!-- START_PHASE_3 --> 132 + ### Phase 3: HTMX Live Preview Endpoint 133 + 134 + **Goal:** Add the color swatch preview fragment returned when the user changes a `<select>`. 135 + 136 + **Components:** 137 + - `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 138 + - `<select>` elements in the settings page carry `hx-get="/settings/preview"`, `hx-trigger="change"`, `hx-target="#theme-preview"`, `hx-swap="outerHTML"`, `hx-include="this"` 139 + - Tests: preview returns swatch fragment for valid URI; returns empty div for unknown URI; select attributes are present in rendered page HTML 140 + 141 + **Dependencies:** Phase 2 (settings page and route file exist) 142 + 143 + **Done when:** Tests pass; selecting a theme in the UI swaps in a swatch preview; `pnpm test` passes. 144 + 145 + **Covers:** user-theme-preferences.AC1.3, user-theme-preferences.AC1.4, user-theme-preferences.AC6.1, user-theme-preferences.AC6.2 146 + <!-- END_PHASE_3 --> 147 + 148 + <!-- START_PHASE_4 --> 149 + ### Phase 4: Navigation Link 150 + 151 + **Goal:** Expose the settings page via the site navigation. 152 + 153 + **Components:** 154 + - `apps/web/src/layouts/base.tsx` — add Settings link inside `NavContent`, visible only when `auth?.authenticated` is true 155 + - Test: authenticated nav renders settings link; unauthenticated nav does not 156 + 157 + **Dependencies:** Phase 2 (settings route exists and handles the request) 158 + 159 + **Done when:** Tests pass; authenticated users see Settings link; unauthenticated users do not; `pnpm test` passes. 160 + 161 + **Covers:** user-theme-preferences.AC1.1 162 + <!-- END_PHASE_4 --> 163 + 164 + <!-- START_PHASE_5 --> 165 + ### Phase 5: Bruno API Collection 166 + 167 + **Goal:** Document the three new endpoints in the Bruno collection. 168 + 169 + **Components:** 170 + - `bruno/` collection entries for `GET /settings`, `GET /settings/preview`, `POST /settings/appearance` 171 + - Each entry documents request shape, expected response, and all HTTP status codes the handler can return 172 + 173 + **Dependencies:** Phases 2–3 (endpoints implemented) 174 + 175 + **Done when:** Bruno files committed alongside route implementation; all status codes documented. 176 + <!-- END_PHASE_5 --> 177 + 178 + ## Additional Considerations 179 + 180 + **Cookie validation security:** The `POST /settings/appearance` handler fetches a fresh copy of the theme policy (not the cached version used for rendering) to validate URIs. A stale cache hit could allow a recently-removed theme to be set as a preference. Fresh fetch on POST prevents this. 181 + 182 + **`allowUserChoice` enforcement:** The POST handler checks `allowUserChoice` server-side even though the UI hides the form when it is false. This follows defense-in-depth — a crafted POST must not bypass admin intent. 183 + 184 + **Stale preference cookies:** If an admin removes a theme from the policy after a user has saved it as their preference, `resolveUserThemePreference()` will return `null` on the next page load and fall through to the forum default. No error is surfaced — the user simply gets the forum default until they revisit settings and re-pick.
+169
docs/implementation-plans/2026-03-20-user-theme-preferences/phase_01.md
··· 1 + # User Theme Preferences Implementation Plan 2 + 3 + **Goal:** Add `resolveUserThemePreference()` and slot it into `resolveTheme()` as the first theme choice after the policy is fetched. 4 + 5 + **Architecture:** Pure function added to `theme-resolution.ts`; waterfall updated with a one-liner substitution at the `defaultUri` assignment. All existing CID-check, cache, and fetch logic reuses naturally for both user preference and forum default URIs. 6 + 7 + **Tech Stack:** TypeScript, Vitest 8 + 9 + **Scope:** Phase 1 of 5 10 + 11 + **Codebase verified:** 2026-03-20 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### user-theme-preferences.AC3: Preferences are applied on page load 18 + - **user-theme-preferences.AC3.1 Success:** When `atbb-light-theme` cookie is set and still in the policy, that theme renders in light mode 19 + - **user-theme-preferences.AC3.2 Success:** When `atbb-dark-theme` cookie is set and still in the policy, that theme renders in dark mode 20 + - **user-theme-preferences.AC3.3 Edge:** When a cookie URI is no longer in the theme policy, the forum default is used silently (no error page) 21 + 22 + ### user-theme-preferences.AC5: `allowUserChoice: false` is respected 23 + - **user-theme-preferences.AC5.2 Success:** Theme resolution ignores user preference cookies when `allowUserChoice: false` and uses forum default 24 + 25 + --- 26 + 27 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 28 + 29 + <!-- START_TASK_1 --> 30 + ### Task 1: Add resolveUserThemePreference() to theme-resolution.ts 31 + 32 + **Activate skills:** ed3d-house-style:coding-effectively, ed3d-house-style:howto-code-in-typescript 33 + 34 + **Verifies:** user-theme-preferences.AC3.1, user-theme-preferences.AC3.2, user-theme-preferences.AC3.3, user-theme-preferences.AC5.2 35 + 36 + **Files:** 37 + - Modify: `apps/web/src/lib/theme-resolution.ts` 38 + 39 + **Implementation:** 40 + 41 + Add the following exported function after `parseRkeyFromUri()` (after line 68) and before the `resolveTheme()` JSDoc: 42 + 43 + ```typescript 44 + /** 45 + * Reads the user's theme preference cookie for the active color scheme and 46 + * validates it against the policy's availableThemes list. 47 + * Returns the validated AT URI, or null if absent, stale, or choice not allowed. 48 + */ 49 + export function resolveUserThemePreference( 50 + cookieHeader: string | undefined, 51 + colorScheme: "light" | "dark", 52 + availableThemes: { uri: string }[], 53 + allowUserChoice: boolean 54 + ): string | null { 55 + if (!allowUserChoice) return null; 56 + const cookieName = colorScheme === "light" ? "atbb-light-theme" : "atbb-dark-theme"; 57 + const match = cookieHeader?.match(new RegExp(`(?:^|;\\s*)${cookieName}=([^;]+)`)); 58 + if (!match) return null; 59 + const uri = match[1].trim(); 60 + return availableThemes.some((t) => t.uri === uri) ? uri : null; 61 + } 62 + ``` 63 + 64 + Update the `resolveTheme()` JSDoc at lines 73–76 to remove the TODO line and reflect the updated waterfall: 65 + 66 + ``` 67 + * 1. Detect color scheme — atbb-color-scheme cookie or Sec-CH hint 68 + * 2. Fetch theme policy — GET /api/theme-policy (cached in memory) 69 + * 3. User preference — atbb-light-theme / atbb-dark-theme cookie (validated against policy) 70 + * 4. Forum default — defaultLightThemeUri / defaultDarkThemeUri from policy 71 + * 5. Hardcoded fallback — neobrutal-light or neobrutal-dark per color scheme 72 + ``` 73 + 74 + Replace the `// ── Step 2` block starting at line 130 (the `const defaultUri = ...` assignment at lines 131–133) with: 75 + 76 + ```typescript 77 + // ── Step 2: User preference or forum default URI ─────────────────────────── 78 + const userPreferenceUri = resolveUserThemePreference( 79 + cookieHeader, 80 + colorScheme, 81 + policy.availableThemes, 82 + policy.allowUserChoice 83 + ); 84 + const defaultUri = 85 + userPreferenceUri ?? 86 + (colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri); 87 + ``` 88 + 89 + Also remove the standalone inline TODO comment at line 91 of `theme-resolution.ts`: 90 + 91 + ```typescript 92 + // TODO: user preference (Theme Phase 4) 93 + ``` 94 + 95 + Delete this line entirely — it is now superseded by the waterfall insertion above. 96 + 97 + **Step 1:** Make the four edits above (new function, JSDoc update, waterfall insertion, TODO removal). 98 + 99 + **Step 2:** Run the build to verify TypeScript compiles: 100 + 101 + ```bash 102 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH pnpm --filter @atbb/web build 103 + ``` 104 + 105 + Expected: no TypeScript errors. 106 + 107 + **Step 3:** Commit: 108 + 109 + ```bash 110 + git add apps/web/src/lib/theme-resolution.ts 111 + git commit -m "feat: add resolveUserThemePreference to theme resolution waterfall" 112 + ``` 113 + <!-- END_TASK_1 --> 114 + 115 + <!-- START_TASK_2 --> 116 + ### Task 2: Tests for resolveUserThemePreference() and resolveTheme() integration 117 + 118 + **Verifies:** user-theme-preferences.AC3.1, user-theme-preferences.AC3.2, user-theme-preferences.AC3.3, user-theme-preferences.AC5.2 119 + 120 + **Files:** 121 + - Modify: `apps/web/src/lib/__tests__/theme-resolution.test.ts` 122 + 123 + **Implementation:** 124 + 125 + Add `resolveUserThemePreference` to the existing import at the top of the file. 126 + 127 + Add a new `describe("resolveUserThemePreference", ...)` block after the `parseRkeyFromUri` describe block. This is a pure function — no fetch mocks or cache setup needed. 128 + 129 + Tests to include: 130 + 131 + - `allowUserChoice: false` → returns `null` regardless of what cookies are present 132 + - `allowUserChoice: true`, `atbb-light-theme` cookie with a URI that is in `availableThemes` → returns that URI 133 + - `allowUserChoice: true`, `atbb-dark-theme` cookie with a URI that is in `availableThemes` → returns that URI 134 + - Cookie URI not in `availableThemes` (stale/removed) → returns `null` 135 + - Cookie absent (`undefined` cookieHeader) → returns `null` 136 + - Cookie value is empty string after cookie name → returns `null` 137 + - Cookie prefix boundary: `x-atbb-light-theme=...` does not match → returns `null` 138 + 139 + Then inside the existing `describe("resolveTheme", ...)` block, add integration tests using the same `mockFetch`, `policyResponse()`, and `themeResponse()` helpers already in the file. Pass a `cookieHeader` string containing the preference cookie as the third argument to `resolveTheme`: 140 + 141 + - Light preference cookie set to a URI in `availableThemes`: `resolveTheme` makes two fetches (policy, then user's theme). Assert that the user's theme tokens are returned, not the forum default tokens. 142 + - Dark preference cookie set to a URI in `availableThemes`: same structure for dark. 143 + - Preference cookie URI not in `availableThemes` policy: `resolveTheme` falls back to forum default (second fetch uses default rkey). 144 + - Policy has `allowUserChoice: false`: `resolveTheme` ignores preference cookie and uses forum default. 145 + 146 + Cookie string format to use in tests: 147 + `"atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight"` 148 + 149 + When testing that the user's preferred theme is fetched, assert `mockFetch` was called with the URL containing the rkey from the user's preference URI, not the forum default rkey. 150 + 151 + **Step 1:** Write the tests as described. 152 + 153 + **Step 2:** Run tests: 154 + 155 + ```bash 156 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 157 + ``` 158 + 159 + Expected: all tests pass, including new ones. 160 + 161 + **Step 3:** Commit: 162 + 163 + ```bash 164 + git add apps/web/src/lib/__tests__/theme-resolution.test.ts 165 + git commit -m "test: resolveUserThemePreference unit and resolveTheme integration tests" 166 + ``` 167 + <!-- END_TASK_2 --> 168 + 169 + <!-- END_SUBCOMPONENT_A -->
+350
docs/implementation-plans/2026-03-20-user-theme-preferences/phase_02.md
··· 1 + # User Theme Preferences Implementation Plan 2 + 3 + **Goal:** Implement the `/settings` page with form rendering and preference saving. 4 + 5 + **Architecture:** Factory function `createSettingsRoutes(appviewUrl)` with GET and POST handlers. GET fetches policy + themes list; POST fetches fresh policy for validation, sets two cookies, PRG redirects. No HTMX attributes on selects yet — those are added in Phase 3. 6 + 7 + **Tech Stack:** TypeScript, Hono JSX, Vitest 8 + 9 + **Scope:** Phase 2 of 5 10 + 11 + **Codebase verified:** 2026-03-20 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### user-theme-preferences.AC1: Settings page is accessible 18 + - **user-theme-preferences.AC1.2 Success:** `/settings` renders with light-theme and dark-theme selects when `allowUserChoice: true` 19 + - **user-theme-preferences.AC1.5 Failure:** Unauthenticated users visiting `/settings` are redirected to `/login` 20 + 21 + ### user-theme-preferences.AC2: Preferences can be saved 22 + - **user-theme-preferences.AC2.1 Success:** Submitting the form with valid URIs sets `atbb-light-theme` and `atbb-dark-theme` cookies 23 + - **user-theme-preferences.AC2.2 Success:** After saving, the page shows a "Preferences saved" confirmation banner 24 + - **user-theme-preferences.AC2.3 Success:** On revisiting `/settings`, the saved themes are pre-selected in the dropdowns 25 + 26 + ### user-theme-preferences.AC4: Server validates inputs 27 + - **user-theme-preferences.AC4.1 Failure:** POST with a URI absent from `availableThemes` is rejected (redirects with `?error=invalid-theme`) 28 + - **user-theme-preferences.AC4.2 Failure:** POST when `allowUserChoice: false` is rejected server-side (even without the form) 29 + - **user-theme-preferences.AC4.3 Failure:** POST with missing or malformed body fields is rejected (`?error=invalid`) 30 + - **user-theme-preferences.AC4.4 Failure:** POST when policy fetch fails returns safe error — no cookies are set 31 + 32 + ### user-theme-preferences.AC5: `allowUserChoice: false` is respected 33 + - **user-theme-preferences.AC5.1 Success:** Settings page shows an informational banner instead of selects when `allowUserChoice: false` 34 + 35 + --- 36 + 37 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 38 + 39 + <!-- START_TASK_1 --> 40 + ### Task 1: Create settings.tsx route and register it 41 + 42 + **Activate skills:** ed3d-house-style:coding-effectively, ed3d-house-style:howto-code-in-typescript 43 + 44 + **Verifies:** 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 45 + 46 + **Files:** 47 + - Create: `apps/web/src/routes/settings.tsx` 48 + - Modify: `apps/web/src/routes/index.ts` 49 + 50 + **Implementation:** 51 + 52 + `apps/web/src/routes/settings.tsx`: 53 + 54 + ```tsx 55 + import { Hono } from "hono"; 56 + import { BaseLayout } from "../layouts/base.js"; 57 + import { getSession } from "../lib/session.js"; 58 + import { resolveUserThemePreference, FALLBACK_THEME } from "../lib/theme-resolution.js"; 59 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 60 + import { isProgrammingError } from "../lib/errors.js"; 61 + import { logger } from "../lib/logger.js"; 62 + 63 + type ThemeSummary = { uri: string; name: string; colorScheme: string }; 64 + type Policy = { 65 + availableThemes: { uri: string }[]; 66 + allowUserChoice: boolean; 67 + defaultLightThemeUri: string | null; 68 + defaultDarkThemeUri: string | null; 69 + }; 70 + 71 + export function createSettingsRoutes(appviewUrl: string) { 72 + const app = new Hono<WebAppEnv>(); 73 + 74 + // ── GET /settings ────────────────────────────────────────────────────────── 75 + app.get("/settings", async (c) => { 76 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 77 + const cookieHeader = c.req.header("cookie"); 78 + const auth = await getSession(appviewUrl, cookieHeader); 79 + if (!auth.authenticated) return c.redirect("/login"); 80 + 81 + const saved = c.req.query("saved") === "1"; 82 + const errorParam = c.req.query("error"); 83 + 84 + // Fetch theme policy 85 + let policy: Policy | null = null; 86 + try { 87 + const policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 88 + if (policyRes.ok) { 89 + policy = (await policyRes.json()) as Policy; 90 + } 91 + } catch (error) { 92 + if (isProgrammingError(error)) throw error; 93 + logger.error("Failed to fetch theme policy for settings page", { 94 + operation: "GET /settings", 95 + error: error instanceof Error ? error.message : String(error), 96 + }); 97 + } 98 + 99 + if (!policy) { 100 + return c.html( 101 + <BaseLayout title="Settings — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 102 + <main class="settings-page"> 103 + <h1>Settings</h1> 104 + <p class="settings-banner settings-banner--error"> 105 + Theme settings are temporarily unavailable. Please try again later. 106 + </p> 107 + </main> 108 + </BaseLayout> 109 + ); 110 + } 111 + 112 + // Fetch available themes list (already filtered by policy server-side in AppView) 113 + let allThemes: ThemeSummary[] = []; 114 + try { 115 + const themesRes = await fetch(`${appviewUrl}/api/themes`); 116 + if (themesRes.ok) { 117 + const data = (await themesRes.json()) as { themes: ThemeSummary[] }; 118 + allThemes = data.themes ?? []; 119 + } 120 + } catch (err) { 121 + if (isProgrammingError(err)) throw err; 122 + logger.warn("Failed to fetch themes list for settings page", { 123 + operation: "GET /settings", 124 + error: err instanceof Error ? err.message : String(err), 125 + }); 126 + } 127 + 128 + const lightThemes = allThemes.filter((t) => t.colorScheme === "light"); 129 + const darkThemes = allThemes.filter((t) => t.colorScheme === "dark"); 130 + 131 + // Pre-select current preference cookie (Phase 1), falling back to forum default 132 + const currentLightUri = 133 + resolveUserThemePreference(cookieHeader, "light", allThemes, policy.allowUserChoice) ?? 134 + policy.defaultLightThemeUri; 135 + const currentDarkUri = 136 + resolveUserThemePreference(cookieHeader, "dark", allThemes, policy.allowUserChoice) ?? 137 + policy.defaultDarkThemeUri; 138 + 139 + return c.html( 140 + <BaseLayout title="Settings — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 141 + <main class="settings-page"> 142 + <h1>Settings</h1> 143 + {saved && ( 144 + <p class="settings-banner settings-banner--success">Preferences saved.</p> 145 + )} 146 + {errorParam && ( 147 + <p class="settings-banner settings-banner--error"> 148 + {decodeURIComponent(errorParam)} 149 + </p> 150 + )} 151 + <section> 152 + <h2>Appearance</h2> 153 + {policy.allowUserChoice ? ( 154 + <form method="post" action="/settings/appearance" class="settings-form"> 155 + <div class="settings-form__field"> 156 + <label for="lightThemeUri">Light theme</label> 157 + <select id="lightThemeUri" name="lightThemeUri"> 158 + {lightThemes.map((t) => ( 159 + <option value={t.uri} selected={t.uri === currentLightUri}> 160 + {t.name} 161 + </option> 162 + ))} 163 + </select> 164 + </div> 165 + <div class="settings-form__field"> 166 + <label for="darkThemeUri">Dark theme</label> 167 + <select id="darkThemeUri" name="darkThemeUri"> 168 + {darkThemes.map((t) => ( 169 + <option value={t.uri} selected={t.uri === currentDarkUri}> 170 + {t.name} 171 + </option> 172 + ))} 173 + </select> 174 + </div> 175 + <div id="theme-preview"></div> 176 + <button type="submit" class="settings-form__submit"> 177 + Save preferences 178 + </button> 179 + </form> 180 + ) : ( 181 + <p class="settings-banner"> 182 + Theme selection is managed by the forum administrator. 183 + </p> 184 + )} 185 + </section> 186 + </main> 187 + </BaseLayout> 188 + ); 189 + }); 190 + 191 + // ── POST /settings/appearance ─────────────────────────────────────────────── 192 + app.post("/settings/appearance", async (c) => { 193 + const cookieHeader = c.req.header("cookie"); 194 + const auth = await getSession(appviewUrl, cookieHeader); 195 + if (!auth.authenticated) return c.redirect("/login"); 196 + 197 + let body: Record<string, string | File>; 198 + try { 199 + body = await c.req.parseBody(); 200 + } catch (err) { 201 + if (isProgrammingError(err)) throw err; 202 + return c.redirect("/settings?error=invalid", 302); 203 + } 204 + 205 + const lightThemeUri = 206 + typeof body.lightThemeUri === "string" ? body.lightThemeUri.trim() : ""; 207 + const darkThemeUri = 208 + typeof body.darkThemeUri === "string" ? body.darkThemeUri.trim() : ""; 209 + if (!lightThemeUri || !darkThemeUri) { 210 + return c.redirect("/settings?error=invalid", 302); 211 + } 212 + 213 + // Fetch FRESH policy (bypass cache) so recently-removed themes can't be saved 214 + let policy: { availableThemes: { uri: string }[]; allowUserChoice: boolean } | null = 215 + null; 216 + try { 217 + const policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 218 + if (policyRes.ok) { 219 + policy = (await policyRes.json()) as typeof policy; 220 + } 221 + } catch (err) { 222 + if (isProgrammingError(err)) throw err; 223 + logger.error("Failed to fetch theme policy during preference save", { 224 + operation: "POST /settings/appearance", 225 + error: err instanceof Error ? err.message : String(err), 226 + }); 227 + } 228 + 229 + if (!policy) { 230 + return c.redirect("/settings?error=unavailable", 302); 231 + } 232 + if (!policy.allowUserChoice) { 233 + return c.redirect("/settings?error=not-allowed", 302); 234 + } 235 + 236 + const availableUris = policy.availableThemes.map((t) => t.uri); 237 + if (!availableUris.includes(lightThemeUri) || !availableUris.includes(darkThemeUri)) { 238 + return c.redirect("/settings?error=invalid-theme", 302); 239 + } 240 + 241 + // Set preference cookies (1 year). 242 + // AT URIs (at://did:plc:.../rkey) are valid cookie values per RFC 6265 — 243 + // colons and slashes are permitted; no encoding needed. 244 + const headers = new Headers(); 245 + headers.append( 246 + "set-cookie", 247 + `atbb-light-theme=${lightThemeUri}; Path=/; Max-Age=31536000; SameSite=Lax` 248 + ); 249 + headers.append( 250 + "set-cookie", 251 + `atbb-dark-theme=${darkThemeUri}; Path=/; Max-Age=31536000; SameSite=Lax` 252 + ); 253 + headers.set("location", "/settings?saved=1"); 254 + return new Response(null, { status: 302, headers }); 255 + }); 256 + 257 + return app; 258 + } 259 + ``` 260 + 261 + `apps/web/src/routes/index.ts` — add import and route registration alongside the other route factories: 262 + 263 + ```typescript 264 + import { createSettingsRoutes } from "./settings.js"; 265 + // ... 266 + .route("/", createSettingsRoutes(config.appviewUrl)) 267 + ``` 268 + 269 + **Step 1:** Create `settings.tsx` and update `routes/index.ts`. 270 + 271 + **Step 2:** Build to verify no TypeScript errors: 272 + 273 + ```bash 274 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH pnpm --filter @atbb/web build 275 + ``` 276 + 277 + **Step 3:** Commit: 278 + 279 + ```bash 280 + git add apps/web/src/routes/settings.tsx apps/web/src/routes/index.ts 281 + git commit -m "feat: add settings page with light/dark theme preference form" 282 + ``` 283 + <!-- END_TASK_1 --> 284 + 285 + <!-- START_TASK_2 --> 286 + ### Task 2: Integration tests for settings route 287 + 288 + **Verifies:** 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 289 + 290 + **Files:** 291 + - Create: `apps/web/src/routes/__tests__/settings.test.tsx` 292 + 293 + **Implementation:** 294 + 295 + Follow the exact pattern from `apps/web/src/routes/__tests__/admin.test.tsx`: `vi.stubGlobal("fetch", mockFetch)`, `vi.resetModules()`, `routes.request(path, options)`. 296 + 297 + **Fetch call order — GET /settings (happy path):** 298 + 1. `getSession` → `{ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" }` 299 + 2. `/api/theme-policy` → `{ allowUserChoice: true, defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", availableThemes: [{ uri: "...3lbllight" }, { uri: "...3lbldark" }] }` 300 + 3. `/api/themes` → `{ themes: [{ uri: "...3lbllight", name: "Clean Light", colorScheme: "light" }, { uri: "...3lbldark", name: "Neobrutal Dark", colorScheme: "dark" }] }` 301 + 302 + **Fetch call order — POST /settings/appearance (happy path):** 303 + 1. `getSession` → authenticated 304 + 2. `/api/theme-policy` → `{ allowUserChoice: true, availableThemes: [{ uri: "...3lbllight" }, { uri: "...3lbldark" }] }` 305 + 306 + **GET /settings tests:** 307 + 308 + - No cookie → 302 to `/login` 309 + - Authenticated, policy fetch returns non-ok → 200 with error banner text, no `<form>` in HTML 310 + - Authenticated, `allowUserChoice: false` → 200 with informational banner, no `<select>` in HTML 311 + - Authenticated, `allowUserChoice: true` → 200 with `<select id="lightThemeUri">` and `<select id="darkThemeUri">` in HTML 312 + - Authenticated, `?saved=1` query param → 200 with "Preferences saved" in HTML 313 + - Authenticated with cookie `atbb-light-theme=at://did:plc:forum/space.atbb.forum.theme/3lbllight` → HTML contains `selected` on the matching `<option value="...3lbllight">` 314 + 315 + **POST /settings/appearance tests:** 316 + 317 + - No cookie → 302 to `/login` 318 + - Empty body → 302 to `/settings?error=invalid` 319 + - `lightThemeUri` not in `availableThemes` → 302 to `/settings?error=invalid-theme` 320 + - `darkThemeUri` not in `availableThemes` → 302 to `/settings?error=invalid-theme` 321 + - Policy fetch fails (mock non-ok response) → 302 to `/settings?error=unavailable`; assert no `set-cookie` header present 322 + - `allowUserChoice: false` in policy → 302 to `/settings?error=not-allowed` 323 + - Valid URIs both in `availableThemes` → 302 to `/settings?saved=1`; assert both cookies are set: 324 + 325 + ```typescript 326 + const cookies = res.headers.getSetCookie?.() ?? []; 327 + expect(cookies.some((c) => c.startsWith("atbb-light-theme="))).toBe(true); 328 + expect(cookies.some((c) => c.startsWith("atbb-dark-theme="))).toBe(true); 329 + expect(res.headers.get("location")).toBe("/settings?saved=1"); 330 + ``` 331 + 332 + **Step 1:** Create the test file with all tests above. 333 + 334 + **Step 2:** Run tests: 335 + 336 + ```bash 337 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 338 + ``` 339 + 340 + Expected: all tests pass. 341 + 342 + **Step 3:** Commit: 343 + 344 + ```bash 345 + git add apps/web/src/routes/__tests__/settings.test.tsx 346 + git commit -m "test: settings route GET and POST integration tests" 347 + ``` 348 + <!-- END_TASK_2 --> 349 + 350 + <!-- END_SUBCOMPONENT_A -->
+222
docs/implementation-plans/2026-03-20-user-theme-preferences/phase_03.md
··· 1 + # User Theme Preferences Implementation Plan 2 + 3 + **Goal:** Add `GET /settings/preview` and add `hx-*` attributes to both selects so changing a theme shows a color swatch before saving. 4 + 5 + **Architecture:** Preview handler fetches the theme by rkey from AppView, extracts 5 key tokens, returns a `<div id="theme-preview">` fragment. Both selects get `hx-get="/settings/preview" hx-trigger="change" hx-target="#theme-preview" hx-swap="outerHTML" hx-include="this"`. The preview endpoint accepts `?lightThemeUri=` or `?darkThemeUri=` (whichever select fired). 6 + 7 + **Tech Stack:** TypeScript, Hono JSX, Vitest 8 + 9 + **Scope:** Phase 3 of 5 10 + 11 + **Codebase verified:** 2026-03-20 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### user-theme-preferences.AC1: Settings page is accessible 18 + - **user-theme-preferences.AC1.3 Success:** Changing the light-theme select swaps in a color swatch preview 19 + - **user-theme-preferences.AC1.4 Success:** Changing the dark-theme select swaps in a color swatch preview 20 + 21 + ### user-theme-preferences.AC6: Preview endpoint 22 + - **user-theme-preferences.AC6.1 Success:** `GET /settings/preview?lightThemeUri=<valid-uri>` (or `?darkThemeUri=`) returns an HTML fragment with swatches and theme name 23 + - **user-theme-preferences.AC6.2 Edge:** `GET /settings/preview?lightThemeUri=<unknown-uri>` (or `?darkThemeUri=`) returns an empty `<div id="theme-preview">` fragment without crashing 24 + 25 + --- 26 + 27 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 28 + 29 + <!-- START_TASK_1 --> 30 + ### Task 1: Add preview endpoint and hx-* attributes to settings.tsx 31 + 32 + **Activate skills:** ed3d-house-style:coding-effectively, ed3d-house-style:howto-code-in-typescript 33 + 34 + **Verifies:** user-theme-preferences.AC1.3, user-theme-preferences.AC1.4, user-theme-preferences.AC6.1, user-theme-preferences.AC6.2 35 + 36 + **Files:** 37 + - Modify: `apps/web/src/routes/settings.tsx` 38 + 39 + **Implementation — three changes to settings.tsx:** 40 + 41 + **CHANGE 1:** Update the `theme-resolution.ts` import to include `parseRkeyFromUri`: 42 + 43 + ```typescript 44 + import { 45 + resolveUserThemePreference, 46 + FALLBACK_THEME, 47 + parseRkeyFromUri, 48 + } from "../lib/theme-resolution.js"; 49 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 50 + ``` 51 + 52 + **CHANGE 2:** Add the `ThemeSwatchPreview` component and `GET /settings/preview` handler inside `createSettingsRoutes`, before the `return app` statement: 53 + 54 + ```tsx 55 + function ThemeSwatchPreview({ 56 + name, 57 + tokens, 58 + }: { 59 + name: string; 60 + tokens: Record<string, string>; 61 + }) { 62 + const swatchTokens = [ 63 + "color-bg", 64 + "color-surface", 65 + "color-primary", 66 + "color-text", 67 + "color-border", 68 + ] as const; 69 + return ( 70 + <div id="theme-preview" class="theme-preview"> 71 + <span class="theme-preview__name">{name}</span> 72 + <div class="theme-preview__swatches"> 73 + {swatchTokens.map((token) => { 74 + const color = tokens[token]; 75 + if (!color) return null; 76 + return ( 77 + <span 78 + class="theme-preview__swatch" 79 + style={`background:${color}`} 80 + title={token} 81 + /> 82 + ); 83 + })} 84 + </div> 85 + </div> 86 + ); 87 + } 88 + 89 + app.get("/settings/preview", async (c) => { 90 + const emptyFragment = <div id="theme-preview"></div>; 91 + const themeUri = c.req.query("lightThemeUri") ?? c.req.query("darkThemeUri"); 92 + if (!themeUri) return c.html(emptyFragment); 93 + 94 + const rkey = parseRkeyFromUri(themeUri); 95 + if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return c.html(emptyFragment); 96 + 97 + try { 98 + const res = await fetch(`${appviewUrl}/api/themes/${rkey}`); 99 + if (!res.ok) return c.html(emptyFragment); 100 + const theme = (await res.json()) as { 101 + name: string; 102 + tokens: Record<string, string>; 103 + }; 104 + return c.html( 105 + <ThemeSwatchPreview name={theme.name ?? ""} tokens={theme.tokens ?? {}} /> 106 + ); 107 + } catch (err) { 108 + if (isProgrammingError(err)) throw err; 109 + return c.html(emptyFragment); 110 + } 111 + }); 112 + ``` 113 + 114 + **CHANGE 3:** Update both `<select>` elements in the `GET /settings` handler to add HTMX attributes. Replace the light-theme select: 115 + 116 + ```tsx 117 + <select 118 + id="lightThemeUri" 119 + name="lightThemeUri" 120 + hx-get="/settings/preview" 121 + hx-trigger="change" 122 + hx-target="#theme-preview" 123 + hx-swap="outerHTML" 124 + hx-include="this"> 125 + {lightThemes.map((t) => ( 126 + <option value={t.uri} selected={t.uri === currentLightUri}> 127 + {t.name} 128 + </option> 129 + ))} 130 + </select> 131 + ``` 132 + 133 + Replace the dark-theme select: 134 + 135 + ```tsx 136 + <select 137 + id="darkThemeUri" 138 + name="darkThemeUri" 139 + hx-get="/settings/preview" 140 + hx-trigger="change" 141 + hx-target="#theme-preview" 142 + hx-swap="outerHTML" 143 + hx-include="this"> 144 + {darkThemes.map((t) => ( 145 + <option value={t.uri} selected={t.uri === currentDarkUri}> 146 + {t.name} 147 + </option> 148 + ))} 149 + </select> 150 + ``` 151 + 152 + **Step 1:** Apply the three changes above to `apps/web/src/routes/settings.tsx`. 153 + 154 + **Step 2:** Build to verify: 155 + 156 + ```bash 157 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH pnpm --filter @atbb/web build 158 + ``` 159 + 160 + Expected: no TypeScript errors. 161 + 162 + **Step 3:** Commit: 163 + 164 + ```bash 165 + git add apps/web/src/routes/settings.tsx 166 + git commit -m "feat: add HTMX theme preview endpoint and wire up select elements" 167 + ``` 168 + <!-- END_TASK_1 --> 169 + 170 + <!-- START_TASK_2 --> 171 + ### Task 2: Tests for preview endpoint and hx-* attributes 172 + 173 + **Verifies:** user-theme-preferences.AC1.3, user-theme-preferences.AC1.4, user-theme-preferences.AC6.1, user-theme-preferences.AC6.2 174 + 175 + **Files:** 176 + - Modify: `apps/web/src/routes/__tests__/settings.test.tsx` 177 + 178 + **Implementation:** 179 + 180 + Add a new `describe("GET /settings/preview", ...)` block to the existing test file. The preview endpoint makes one fetch call per request (no session check): 181 + 182 + **Fetch call order — happy path:** 183 + 1. `/api/themes/<rkey>` → `{ name: "Clean Light", colorScheme: "light", tokens: { "color-bg": "#f5f0e8", "color-surface": "#fff", "color-primary": "#ff5c00", "color-text": "#1a1a1a", "color-border": "#000" } }` 184 + 185 + **Tests for `GET /settings/preview`:** 186 + 187 + - No query params → 200, HTML equals `<div id="theme-preview"></div>` (no fetch call made) 188 + - `?lightThemeUri=at://did:plc:forum/space.atbb.forum.theme/3lbllight` (valid) → 200, HTML contains `"Clean Light"` and five `<span class="theme-preview__swatch"` elements 189 + - `?darkThemeUri=at://did:plc:forum/space.atbb.forum.theme/3lbldark` (valid) → 200, HTML contains theme name 190 + - `/api/themes/:rkey` returns non-ok (status 404) → 200, HTML equals `<div id="theme-preview"></div>` 191 + - Malformed URI (e.g. `?lightThemeUri=not-a-uri`) → 200, HTML equals `<div id="theme-preview"></div>` (no fetch made) 192 + 193 + **Additional test in the existing `GET /settings` describe block:** 194 + 195 + ```typescript 196 + it("renders selects with hx-get attribute when allowUserChoice is true", async () => { 197 + // ... mock session, policy, themes ... 198 + const res = await routes.request("/settings", { headers: { cookie: "atbb_session=token" } }); 199 + const html = await res.text(); 200 + expect(html).toContain('hx-get="/settings/preview"'); 201 + }); 202 + ``` 203 + 204 + **Step 1:** Add the preview describe block and the hx-* attribute test to `settings.test.tsx`. 205 + 206 + **Step 2:** Run tests: 207 + 208 + ```bash 209 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 210 + ``` 211 + 212 + Expected: all tests pass. 213 + 214 + **Step 3:** Commit: 215 + 216 + ```bash 217 + git add apps/web/src/routes/__tests__/settings.test.tsx 218 + git commit -m "test: settings preview endpoint and HTMX attribute tests" 219 + ``` 220 + <!-- END_TASK_2 --> 221 + 222 + <!-- END_SUBCOMPONENT_A -->
+177
docs/implementation-plans/2026-03-20-user-theme-preferences/phase_04.md
··· 1 + # User Theme Preferences Implementation Plan 2 + 3 + **Goal:** Add a Settings link to the site nav, visible only to authenticated users. 4 + 5 + **Architecture:** One-line addition inside the existing `auth?.authenticated` block in `NavContent`. Because `NavContent` is rendered twice (desktop + mobile), the link appears in both automatically. 6 + 7 + **Tech Stack:** TypeScript, Hono JSX, Vitest 8 + 9 + **Scope:** Phase 4 of 5 10 + 11 + **Codebase verified:** 2026-03-20 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + ### user-theme-preferences.AC1: Settings page is accessible 18 + - **user-theme-preferences.AC1.1 Success:** Authenticated users see a Settings link in the site nav 19 + 20 + --- 21 + 22 + <!-- START_SUBCOMPONENT_A (tasks 1-2) --> 23 + 24 + <!-- START_TASK_1 --> 25 + ### Task 1: Add Settings link to NavContent in base.tsx 26 + 27 + **Activate skills:** ed3d-house-style:coding-effectively, ed3d-house-style:howto-code-in-typescript 28 + 29 + **Verifies:** user-theme-preferences.AC1.1 30 + 31 + **Files:** 32 + - Modify: `apps/web/src/layouts/base.tsx` 33 + 34 + **Implementation — one change to base.tsx:** 35 + 36 + Inside `NavContent` (line 24), the authenticated branch is: 37 + 38 + ```tsx 39 + {auth?.authenticated ? ( 40 + <> 41 + <span class="site-header__handle">{auth.handle}</span> 42 + <form action="/logout" method="post" class="site-header__logout-form"> 43 + <button type="submit" class="site-header__logout-btn"> 44 + Log out 45 + </button> 46 + </form> 47 + </> 48 + ) : ( 49 + ``` 50 + 51 + Replace the authenticated fragment to insert a Settings link after the handle span and before the logout form: 52 + 53 + ```tsx 54 + {auth?.authenticated ? ( 55 + <> 56 + <span class="site-header__handle">{auth.handle}</span> 57 + <a href="/settings" class="site-header__settings-link"> 58 + Settings 59 + </a> 60 + <form action="/logout" method="post" class="site-header__logout-form"> 61 + <button type="submit" class="site-header__logout-btn"> 62 + Log out 63 + </button> 64 + </form> 65 + </> 66 + ) : ( 67 + ``` 68 + 69 + **Step 1:** Apply the change above to `apps/web/src/layouts/base.tsx` (insert lines 27–29 of the new version). 70 + 71 + **Step 2:** Build to verify: 72 + 73 + ```bash 74 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH pnpm --filter @atbb/web build 75 + ``` 76 + 77 + Expected: no TypeScript errors. 78 + 79 + **Step 3:** Commit: 80 + 81 + ```bash 82 + git add apps/web/src/layouts/base.tsx 83 + git commit -m "feat: add Settings nav link for authenticated users" 84 + ``` 85 + <!-- END_TASK_1 --> 86 + 87 + <!-- START_TASK_2 --> 88 + ### Task 2: Tests for Settings nav link 89 + 90 + **Verifies:** user-theme-preferences.AC1.1 91 + 92 + **Files:** 93 + - Modify: `apps/web/src/layouts/__tests__/base.test.tsx` 94 + 95 + **Implementation:** 96 + 97 + Add two tests inside the existing `describe("auth-aware navigation", ...)` block (currently lines 145–214): 98 + 99 + ```typescript 100 + it("shows Settings link when authenticated", async () => { 101 + const auth: WebSession = { 102 + authenticated: true, 103 + did: "did:plc:abc123", 104 + handle: "alice.bsky.social", 105 + }; 106 + const authApp = new Hono().get("/", (c) => 107 + c.html( 108 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 109 + content 110 + </BaseLayout> 111 + ) 112 + ); 113 + const res = await authApp.request("/"); 114 + const html = await res.text(); 115 + expect(html).toContain('href="/settings"'); 116 + expect(html).toContain("Settings"); 117 + }); 118 + 119 + it("does not show Settings link when unauthenticated", async () => { 120 + const auth: WebSession = { authenticated: false }; 121 + const unauthApp = new Hono().get("/", (c) => 122 + c.html( 123 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 124 + content 125 + </BaseLayout> 126 + ) 127 + ); 128 + const res = await unauthApp.request("/"); 129 + const html = await res.text(); 130 + expect(html).not.toContain('href="/settings"'); 131 + expect(html).not.toContain("Settings"); 132 + }); 133 + ``` 134 + 135 + Add one test inside the existing `describe("mobile navigation", ...)` block (currently lines 313–361) to verify the Settings link appears in both desktop and mobile navs: 136 + 137 + ```typescript 138 + it("renders Settings link in both desktop and mobile nav when authenticated", async () => { 139 + const auth: WebSession = { 140 + authenticated: true, 141 + did: "did:plc:abc123", 142 + handle: "alice.bsky.social", 143 + }; 144 + const authApp = new Hono().get("/", (c) => 145 + c.html( 146 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 147 + content 148 + </BaseLayout> 149 + ) 150 + ); 151 + const res = await authApp.request("/"); 152 + const html = await res.text(); 153 + // NavContent is rendered twice (desktop + mobile), so the link appears twice 154 + const settingsMatches = [...html.matchAll(/href="\/settings"/g)]; 155 + expect(settingsMatches).toHaveLength(2); 156 + }); 157 + ``` 158 + 159 + **Step 1:** Add the three tests above to `base.test.tsx`. 160 + 161 + **Step 2:** Run tests: 162 + 163 + ```bash 164 + PATH=/Users/jacob.zweifel/workspace/malpercio-dev/atbb-monorepo/.devenv/profile/bin:$PATH pnpm --filter @atbb/web test 165 + ``` 166 + 167 + Expected: all tests pass. 168 + 169 + **Step 3:** Commit: 170 + 171 + ```bash 172 + git add apps/web/src/layouts/__tests__/base.test.tsx 173 + git commit -m "test: Settings nav link auth visibility tests" 174 + ``` 175 + <!-- END_TASK_2 --> 176 + 177 + <!-- END_SUBCOMPONENT_A -->
+220
docs/implementation-plans/2026-03-20-user-theme-preferences/phase_05.md
··· 1 + # User Theme Preferences Implementation Plan 2 + 3 + **Goal:** Document the three new settings endpoints in the Bruno API collection. 4 + 5 + **Architecture:** Create a `Settings/` folder inside `bruno/AppView API/` with one `.bru` file per endpoint. Settings endpoints are served by the web app (`{{web_url}}`), not the AppView API. 6 + 7 + **Tech Stack:** Bruno 8 + 9 + **Scope:** Phase 5 of 5 10 + 11 + **Codebase verified:** 2026-03-20 12 + 13 + --- 14 + 15 + ## Acceptance Criteria Coverage 16 + 17 + _This is an infrastructure/documentation phase. Verifies: None._ 18 + 19 + --- 20 + 21 + <!-- START_SUBCOMPONENT_A (tasks 1) --> 22 + 23 + <!-- START_TASK_1 --> 24 + ### Task 1: Create Bruno collection entries for settings endpoints 25 + 26 + **Verifies:** None (documentation phase) 27 + 28 + **Files:** 29 + - Create: `bruno/AppView API/Settings/Get Settings Page.bru` 30 + - Create: `bruno/AppView API/Settings/Preview Theme.bru` 31 + - Create: `bruno/AppView API/Settings/Save Appearance.bru` 32 + 33 + **Implementation:** 34 + 35 + **Note on folder placement:** These files live under `bruno/AppView API/Settings/` even though they use `{{web_url}}` (the web frontend, port 3001). The `AppView API/` folder is the top-level collection folder in this project and contains all request types, including web-app routes (see `Auth/` for precedent). The `.bru` file docs and URL blocks use `{{web_url}}` explicitly to make the distinction clear. 36 + 37 + **Note on commit timing:** Per `CONTRIBUTING.md`, Bruno files should be committed in the same commit as the route implementation. During execution of this plan, commit these files alongside the `settings.tsx` route changes from Phases 2 and 3. 38 + 39 + --- 40 + 41 + **`bruno/AppView API/Settings/Get Settings Page.bru`:** 42 + 43 + ```bru 44 + meta { 45 + name: Get Settings Page 46 + type: http 47 + seq: 1 48 + } 49 + 50 + get { 51 + url: {{web_url}}/settings 52 + } 53 + 54 + assert { 55 + res.status: eq 200 56 + } 57 + 58 + docs { 59 + Renders the user settings page. 60 + 61 + Requires authentication (session cookie). Unauthenticated users are redirected 62 + to /login (302). 63 + 64 + Returns HTML page containing: 65 + - Light-theme <select id="lightThemeUri"> when allowUserChoice is true 66 + - Dark-theme <select id="darkThemeUri"> when allowUserChoice is true 67 + - Informational banner when allowUserChoice is false 68 + - "Preferences saved." banner when ?saved=1 query param is present 69 + - Error banner when ?error=<message> query param is present 70 + - <div id="theme-preview"> HTMX swap target 71 + 72 + Both selects carry HTMX attributes: 73 + hx-get="/settings/preview" 74 + hx-trigger="change" 75 + hx-target="#theme-preview" 76 + hx-swap="outerHTML" 77 + hx-include="this" 78 + 79 + Query parameters: 80 + - saved: "1" (optional) — shows "Preferences saved." success banner 81 + - error: string (optional) — shows decoded error message in error banner 82 + 83 + Error codes: 84 + - 302: Not authenticated → redirects to /login 85 + - 200: Success — page rendered (with or without selects depending on policy) 86 + } 87 + ``` 88 + 89 + --- 90 + 91 + **`bruno/AppView API/Settings/Preview Theme.bru`:** 92 + 93 + ```bru 94 + meta { 95 + name: Preview Theme 96 + type: http 97 + seq: 2 98 + } 99 + 100 + get { 101 + url: {{web_url}}/settings/preview?lightThemeUri=at://{{forum_did}}/space.atbb.forum.theme/3lbllight 102 + } 103 + 104 + params:query { 105 + lightThemeUri: at://{{forum_did}}/space.atbb.forum.theme/3lbllight 106 + } 107 + 108 + assert { 109 + res.status: eq 200 110 + } 111 + 112 + docs { 113 + HTMX endpoint — returns an HTML fragment with color swatches for the given theme. 114 + 115 + Called automatically by the settings page when the user changes a theme select. 116 + Returns a <div id="theme-preview"> fragment that HTMX swaps into the page. 117 + 118 + Accepts exactly one of: 119 + - lightThemeUri: AT URI of a light theme 120 + - darkThemeUri: AT URI of a dark theme 121 + 122 + The endpoint fetches the theme from the AppView by rkey, extracts these tokens: 123 + color-bg, color-surface, color-primary, color-text, color-border 124 + 125 + Returns the fragment: 126 + <div id="theme-preview" class="theme-preview"> 127 + <span class="theme-preview__name">Theme Name</span> 128 + <div class="theme-preview__swatches"> 129 + <span class="theme-preview__swatch" style="background:#hex" title="color-bg" /> 130 + ... 131 + </div> 132 + </div> 133 + 134 + Returns empty fragment on any of: 135 + - No query param provided 136 + - URI fails rkey extraction (malformed) 137 + - AppView returns non-ok status (unknown theme) 138 + - Network error reaching AppView 139 + 140 + Error codes: 141 + - 200: Always — never errors, returns empty fragment on failure 142 + Empty fragment: <div id="theme-preview"></div> 143 + } 144 + ``` 145 + 146 + --- 147 + 148 + **`bruno/AppView API/Settings/Save Appearance.bru`:** 149 + 150 + ```bru 151 + meta { 152 + name: Save Appearance 153 + type: http 154 + seq: 3 155 + } 156 + 157 + post { 158 + url: {{web_url}}/settings/appearance 159 + } 160 + 161 + body:form { 162 + lightThemeUri: at://{{forum_did}}/space.atbb.forum.theme/3lbllight 163 + darkThemeUri: at://{{forum_did}}/space.atbb.forum.theme/3lbldark 164 + } 165 + 166 + assert { 167 + res.status: eq 302 168 + res.headers.location: contains /settings 169 + } 170 + 171 + docs { 172 + Saves the user's light and dark theme preferences as cookies. 173 + 174 + Requires authentication (session cookie). Unauthenticated users are redirected 175 + to /login (302). 176 + 177 + Body parameters (application/x-www-form-urlencoded): 178 + - lightThemeUri: string (required) — AT URI of the chosen light theme 179 + - darkThemeUri: string (required) — AT URI of the chosen dark theme 180 + 181 + Validation: 182 + 1. Both URIs must be non-empty strings 183 + 2. Forum theme policy is re-fetched fresh (bypasses cache) to prevent stale themes 184 + 3. allowUserChoice must be true in the policy 185 + 4. Both URIs must be present in policy.availableThemes 186 + 187 + On success: 188 + - Sets cookie: atbb-light-theme=<uri>; Path=/; Max-Age=31536000; SameSite=Lax 189 + - Sets cookie: atbb-dark-theme=<uri>; Path=/; Max-Age=31536000; SameSite=Lax 190 + - Redirects 302 to /settings?saved=1 191 + 192 + Error codes: 193 + - 302 → /login: Not authenticated 194 + - 302 → /settings?error=invalid: Missing or non-string body fields 195 + - 302 → /settings?error=unavailable: Policy fetch failed (no cookies set) 196 + - 302 → /settings?error=not-allowed: allowUserChoice is false 197 + - 302 → /settings?error=invalid-theme: URI not in availableThemes 198 + - 302 → /settings?saved=1: Success (cookies set) 199 + } 200 + ``` 201 + 202 + --- 203 + 204 + **Step 1:** Create the `bruno/AppView API/Settings/` directory and the three `.bru` files above. 205 + 206 + **Step 2:** Open Bruno and verify the three requests appear in the collection under `Settings/`. 207 + 208 + **Step 3:** Start the dev server (`pnpm dev`) and manually test each request to confirm assertions pass. 209 + 210 + **Step 4:** Commit: 211 + 212 + ```bash 213 + git add "bruno/AppView API/Settings/" 214 + git commit -m "docs: add Bruno collection entries for settings endpoints" 215 + ``` 216 + 217 + **Note on commit timing:** Per `CONTRIBUTING.md`, these Bruno files should ideally ship in the same commit as the route code. When executing phases 2 and 3, combine the Bruno commit with the settings route commits, or amend the phase 2/3 commits to include the Bruno files before pushing. 218 + <!-- END_TASK_1 --> 219 + 220 + <!-- END_SUBCOMPONENT_A -->
+149
docs/implementation-plans/2026-03-20-user-theme-preferences/test-requirements.md
··· 1 + # Test Requirements: User Theme Preferences 2 + 3 + ## Summary 4 + 5 + This document maps every acceptance criterion from the [design plan](../../design-plans/2026-03-20-user-theme-preferences.md) to either an automated test or a documented human verification step. The test strategy covers three layers: 6 + 7 + - **Unit tests** for the pure `resolveUserThemePreference()` function (no I/O, no mocking) 8 + - **Integration tests** for the settings route handlers (`GET /settings`, `POST /settings/appearance`, `GET /settings/preview`) using Hono's `app.request()` with stubbed `fetch` 9 + - **Component tests** for the `BaseLayout` nav link visibility using Hono JSX rendering 10 + 11 + All automated tests use Vitest and run via `pnpm --filter @atbb/web test`. No e2e browser tests are required -- HTMX behavior (the actual DOM swap triggered by `hx-get` on `<select>` change) is the only piece that cannot be verified without a real browser, and it is covered by human verification. 12 + 13 + ### Key implementation decisions that shape the tests 14 + 15 + - **HTMX preview is wired via `hx-include="this"`, not `?theme=` query param.** The `<select>` sends its own `name=value` as the query parameter, so the preview endpoint receives `?lightThemeUri=<uri>` or `?darkThemeUri=<uri>` depending on which select fired. Tests must use these parameter names. 16 + - **POST uses form body (`c.req.parseBody()`), not JSON.** Test requests must send `Content-Type: application/x-www-form-urlencoded` bodies, not JSON. 17 + - **Preview endpoint always returns 200.** It returns an empty `<div id="theme-preview"></div>` fragment on any error or unknown URI -- never a 4xx/5xx. Tests assert 200 status and check HTML content to distinguish success from fallback. 18 + - **POST follows PRG (Post-Redirect-Get).** Success and all error cases return 302 redirects with different query parameters. Tests assert `Location` header values, not response bodies. 19 + - **Cookie format is raw `Set-Cookie` headers, not Hono's `c.cookie()`.** Tests read cookies via `res.headers.getSetCookie()` or `res.headers.get("set-cookie")`. 20 + - **`getSession` is the auth gate.** Both GET and POST handlers call `getSession(appviewUrl, cookieHeader)` as the first fetch. Unauthenticated mocks must return `{ authenticated: false }`. 21 + - **Preview endpoint does not require authentication.** Unlike GET/POST settings, the preview handler has no session check. Tests do not need to provide session cookies for preview requests. 22 + 23 + --- 24 + 25 + ## Automated Tests 26 + 27 + ### user-theme-preferences.AC1: Settings page is accessible 28 + 29 + | Criterion | Test Type | File | Description | 30 + |-----------|-----------|------|-------------| 31 + | AC1.1 | Component | `apps/web/src/layouts/__tests__/base.test.tsx` | Render `BaseLayout` with `auth.authenticated: true` and assert HTML contains `href="/settings"` and the text "Settings". Render with `auth.authenticated: false` and assert neither is present. Additionally, assert the link appears twice in authenticated HTML (desktop + mobile `NavContent` renders). | 32 + | AC1.2 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `GET /settings` with authenticated session and `allowUserChoice: true` policy returns 200. HTML contains `<select id="lightThemeUri"` and `<select id="darkThemeUri"`. | 33 + | AC1.3 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `GET /settings` with authenticated session renders selects with `hx-get="/settings/preview"` attribute in the HTML output. (Verifies the HTMX wiring is present; the actual browser-side swap is human-verified.) | 34 + | AC1.4 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | Same test as AC1.3 -- both selects carry identical `hx-get`, `hx-trigger="change"`, `hx-target="#theme-preview"`, `hx-swap="outerHTML"`, and `hx-include="this"` attributes. A single test asserting `hx-get="/settings/preview"` appears in the HTML covers both selects since they share the same attributes. | 35 + | AC1.5 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `GET /settings` without a session cookie (mock `getSession` returns `{ authenticated: false }`) returns 302 with `Location: /login`. | 36 + 37 + ### user-theme-preferences.AC2: Preferences can be saved 38 + 39 + | Criterion | Test Type | File | Description | 40 + |-----------|-----------|------|-------------| 41 + | AC2.1 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `POST /settings/appearance` with valid `lightThemeUri` and `darkThemeUri` (both present in `availableThemes`) returns 302 to `/settings?saved=1`. Assert response has two `Set-Cookie` headers: one starting with `atbb-light-theme=` and one starting with `atbb-dark-theme=`, each containing `Path=/; Max-Age=31536000; SameSite=Lax`. | 42 + | AC2.2 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `GET /settings?saved=1` with authenticated session returns 200. HTML contains the text "Preferences saved". | 43 + | AC2.3 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `GET /settings` with authenticated session and a `Cookie: atbb-light-theme=<uri>` header (where `<uri>` is in `availableThemes`) returns HTML where the matching `<option>` has the `selected` attribute. This verifies `resolveUserThemePreference()` is called to pre-select the current preference. | 44 + 45 + ### user-theme-preferences.AC3: Preferences are applied on page load 46 + 47 + | Criterion | Test Type | File | Description | 48 + |-----------|-----------|------|-------------| 49 + | AC3.1 | Unit | `apps/web/src/lib/__tests__/theme-resolution.test.ts` | Call `resolveUserThemePreference()` with `colorScheme: "light"`, a `cookieHeader` containing `atbb-light-theme=<uri>`, and `availableThemes` that includes that URI. Assert it returns the URI. Integration: call `resolveTheme()` with the same cookie and assert the returned theme tokens match the user's preferred theme, not the forum default. | 50 + | AC3.2 | Unit | `apps/web/src/lib/__tests__/theme-resolution.test.ts` | Same as AC3.1 but for `colorScheme: "dark"` and `atbb-dark-theme=<uri>`. Assert `resolveUserThemePreference()` returns the dark theme URI. Integration: `resolveTheme()` with dark cookie returns user's preferred dark theme tokens. | 51 + | AC3.3 | Unit | `apps/web/src/lib/__tests__/theme-resolution.test.ts` | Call `resolveUserThemePreference()` with a cookie URI that is NOT in `availableThemes`. Assert it returns `null`. Integration: `resolveTheme()` falls back to the forum default theme (second fetch uses the default rkey, not the stale preference rkey). | 52 + 53 + ### user-theme-preferences.AC4: Server validates inputs 54 + 55 + | Criterion | Test Type | File | Description | 56 + |-----------|-----------|------|-------------| 57 + | AC4.1 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `POST /settings/appearance` with a `lightThemeUri` not in `availableThemes` returns 302 to `/settings?error=invalid-theme`. Same test for `darkThemeUri` not in policy. Assert no `Set-Cookie` header is present on the response. | 58 + | AC4.2 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `POST /settings/appearance` when mock policy returns `allowUserChoice: false` returns 302 to `/settings?error=not-allowed`. Assert no `Set-Cookie` header is present. | 59 + | AC4.3 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `POST /settings/appearance` with an empty body (no `lightThemeUri` or `darkThemeUri` fields) returns 302 to `/settings?error=invalid`. Also test with only one field present -- should still redirect to `?error=invalid`. | 60 + | AC4.4 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `POST /settings/appearance` when the mock `/api/theme-policy` fetch returns a non-ok status (e.g., 500) returns 302 to `/settings?error=unavailable`. Assert no `Set-Cookie` header is present on the response. | 61 + 62 + ### user-theme-preferences.AC5: `allowUserChoice: false` is respected 63 + 64 + | Criterion | Test Type | File | Description | 65 + |-----------|-----------|------|-------------| 66 + | AC5.1 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `GET /settings` with authenticated session and policy returning `allowUserChoice: false` returns 200. HTML contains the informational banner text "Theme selection is managed by the forum administrator" and does NOT contain `<select`. | 67 + | AC5.2 | Unit | `apps/web/src/lib/__tests__/theme-resolution.test.ts` | Call `resolveUserThemePreference()` with `allowUserChoice: false` and a valid cookie and valid `availableThemes`. Assert it returns `null` regardless of cookie contents. Integration: `resolveTheme()` with `allowUserChoice: false` in the policy ignores the preference cookie and fetches the forum default theme. | 68 + 69 + ### user-theme-preferences.AC6: Preview endpoint 70 + 71 + | Criterion | Test Type | File | Description | 72 + |-----------|-----------|------|-------------| 73 + | AC6.1 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `GET /settings/preview?lightThemeUri=<valid-uri>` where mock `/api/themes/<rkey>` returns a theme with name and tokens. Assert 200 response. HTML contains the theme name and `theme-preview__swatch` class (swatch spans). Also test with `?darkThemeUri=<valid-uri>` to confirm both parameter names work. | 74 + | AC6.2 | Integration | `apps/web/src/routes/__tests__/settings.test.tsx` | `GET /settings/preview?lightThemeUri=<unknown-uri>` where mock `/api/themes/<rkey>` returns 404. Assert 200 response. HTML equals `<div id="theme-preview"></div>` (empty fragment). Also test: no query params returns empty fragment; malformed URI (e.g., `?lightThemeUri=not-a-uri`) returns empty fragment without making a fetch call. | 75 + 76 + --- 77 + 78 + ## Human Verification Required 79 + 80 + ### HV1: HTMX live preview swap (covers AC1.3, AC1.4) 81 + 82 + **Justification:** The automated tests verify that the correct `hx-get`, `hx-trigger`, `hx-target`, `hx-swap`, and `hx-include` attributes are rendered in the HTML and that the preview endpoint returns the correct fragment. However, the actual browser-side behavior -- HTMX intercepting the `<select>` change event, firing the GET request, and replacing `#theme-preview` with the returned fragment -- requires a real browser with HTMX loaded. This is a client-side JavaScript interaction that Hono's `app.request()` test harness cannot exercise. 83 + 84 + **Manual verification approach:** 85 + 1. Start the dev server (`pnpm dev`) 86 + 2. Log in as an authenticated user 87 + 3. Navigate to `/settings` 88 + 4. Verify the `#theme-preview` div is initially empty 89 + 5. Change the light-theme dropdown to a different theme 90 + 6. Observe that the `#theme-preview` area updates with color swatches and a theme name without a full page reload 91 + 7. Change the dark-theme dropdown 92 + 8. Observe that the `#theme-preview` area updates with the dark theme's swatches 93 + 9. Verify no JavaScript errors in the browser console 94 + 95 + ### HV2: Visual correctness of color swatches (covers AC6.1) 96 + 97 + **Justification:** Automated tests verify that swatch `<span>` elements with inline `style="background:<color>"` are present in the HTML fragment. They cannot verify that the rendered colors are visually correct or that the CSS layout makes them appear as recognizable color patches rather than invisible elements. 98 + 99 + **Manual verification approach:** 100 + 1. Navigate to `/settings` while authenticated 101 + 2. Select a light theme from the dropdown 102 + 3. Verify the preview shows visually distinct colored squares/circles that correspond to the theme's palette 103 + 4. Verify the theme name label is readable 104 + 5. Repeat for a dark theme selection 105 + 106 + ### HV3: Cookie persistence across browser sessions (covers AC2.1, AC3.1, AC3.2) 107 + 108 + **Justification:** Integration tests verify that `Set-Cookie` headers are returned with correct `Max-Age` and `Path` attributes, and that `resolveTheme()` reads cookies correctly. However, actual cookie persistence across browser restarts, and correct interaction with browser cookie storage, requires a real browser session. 109 + 110 + **Manual verification approach:** 111 + 1. Save theme preferences on the settings page 112 + 2. Close the browser entirely 113 + 3. Reopen the browser and navigate to the forum 114 + 4. Verify the forum renders with the previously selected themes (not the forum default) 115 + 5. Navigate to `/settings` and verify the dropdowns show the previously saved selections 116 + 117 + --- 118 + 119 + ## Coverage Summary 120 + 121 + | Criterion | Description | Automated | Human | Notes | 122 + |-----------|-------------|-----------|-------|-------| 123 + | AC1.1 | Settings link in nav | Yes (component) | -- | `base.test.tsx`: auth/unauth visibility, desktop + mobile | 124 + | AC1.2 | Settings page renders selects | Yes (integration) | -- | `settings.test.tsx`: assert `<select>` elements present | 125 + | AC1.3 | Light select triggers preview | Yes (integration) | Yes (HV1) | Automated: hx-* attributes in HTML. Manual: browser swap works | 126 + | AC1.4 | Dark select triggers preview | Yes (integration) | Yes (HV1) | Same as AC1.3 for dark select | 127 + | AC1.5 | Unauth redirect to /login | Yes (integration) | -- | `settings.test.tsx`: 302 to /login | 128 + | AC2.1 | Form sets preference cookies | Yes (integration) | Yes (HV3) | Automated: Set-Cookie headers. Manual: browser persistence | 129 + | AC2.2 | "Preferences saved" banner | Yes (integration) | -- | `settings.test.tsx`: ?saved=1 renders banner text | 130 + | AC2.3 | Saved themes pre-selected | Yes (integration) | -- | `settings.test.tsx`: option has `selected` attribute | 131 + | AC3.1 | Light cookie applied on load | Yes (unit + integration) | Yes (HV3) | Unit: resolveUserThemePreference. Integration: resolveTheme | 132 + | AC3.2 | Dark cookie applied on load | Yes (unit + integration) | Yes (HV3) | Same as AC3.1 for dark scheme | 133 + | AC3.3 | Stale cookie falls back silently | Yes (unit + integration) | -- | Unit: returns null. Integration: falls back to default | 134 + | AC4.1 | Reject URI not in policy | Yes (integration) | -- | `settings.test.tsx`: 302 to ?error=invalid-theme | 135 + | AC4.2 | Reject when allowUserChoice false | Yes (integration) | -- | `settings.test.tsx`: 302 to ?error=not-allowed | 136 + | AC4.3 | Reject missing/malformed body | Yes (integration) | -- | `settings.test.tsx`: 302 to ?error=invalid | 137 + | AC4.4 | Policy fetch failure is safe | Yes (integration) | -- | `settings.test.tsx`: 302 to ?error=unavailable, no cookies | 138 + | AC5.1 | Info banner when choice disabled | Yes (integration) | -- | `settings.test.tsx`: banner text present, no `<select>` | 139 + | AC5.2 | Resolution ignores cookie when disabled | Yes (unit + integration) | -- | Unit: returns null. Integration: resolveTheme uses default | 140 + | AC6.1 | Preview returns swatch fragment | Yes (integration) | Yes (HV2) | Automated: HTML content. Manual: visual correctness | 141 + | AC6.2 | Unknown URI returns empty fragment | Yes (integration) | -- | `settings.test.tsx`: empty div, 200 status | 142 + 143 + ### Test file inventory 144 + 145 + | File | Test Type | Phase | Criteria Covered | 146 + |------|-----------|-------|-----------------| 147 + | `apps/web/src/lib/__tests__/theme-resolution.test.ts` | Unit | 1 | AC3.1, AC3.2, AC3.3, AC5.2 | 148 + | `apps/web/src/routes/__tests__/settings.test.tsx` | Integration | 2, 3 | AC1.2, AC1.3, AC1.4, AC1.5, AC2.1, AC2.2, AC2.3, AC4.1, AC4.2, AC4.3, AC4.4, AC5.1, AC6.1, AC6.2 | 149 + | `apps/web/src/layouts/__tests__/base.test.tsx` | Component | 4 | AC1.1 |
+171
docs/test-plans/2026-03-20-user-theme-preferences.md
··· 1 + # Human Test Plan: User Theme Preferences 2 + 3 + **Feature branch:** `user-theme-preferences` 4 + **Automated coverage:** 18/18 ACs passing (1266 tests, 0 failures) 5 + 6 + --- 7 + 8 + ## Prerequisites 9 + 10 + - Development environment running via `devenv shell` 11 + - Dependencies installed via `pnpm install` 12 + - `.env` file configured with valid `APPVIEW_URL` 13 + - Development server started with `pnpm dev` 14 + - All automated tests passing: `pnpm --filter @atbb/web test` 15 + - At least two themes registered in the forum's theme policy (one light, one dark) 16 + - An AT Protocol account available for login (e.g., a test Bluesky account) 17 + 18 + --- 19 + 20 + ## Human Verification Required 21 + 22 + The following items cannot be validated by automated tests and **must be verified manually**: 23 + 24 + | ID | Criterion | Why Manual | 25 + |----|-----------|------------| 26 + | HV1 | HTMX live preview swap (AC1.3, AC1.4) | HTMX client-side JS intercepts `<select>` change events and performs DOM replacement. The test harness cannot exercise browser-side JS behavior. | 27 + | HV2 | Visual swatch correctness (AC6.1) | Automated tests verify swatch HTML and inline styles exist but cannot assess whether rendered colors are visually correct or CSS layout makes them visible. | 28 + | HV3 | Cookie persistence across sessions (AC2.1, AC3.1, AC3.2) | Integration tests verify Set-Cookie headers and cookie reading logic but cannot verify actual browser cookie storage and persistence across full browser restarts. | 29 + 30 + --- 31 + 32 + ## Phase 1: Settings Page Access and Navigation 33 + 34 + | Step | Action | Expected | 35 + |------|--------|----------| 36 + | 1.1 | Open the forum homepage in a browser while not logged in. Inspect the site header (desktop) and hamburger menu (mobile). | No "Settings" link is visible in either desktop or mobile navigation. "Log in" is visible. | 37 + | 1.2 | Click "Log in" and authenticate with a valid AT Protocol account. | Redirect to the forum homepage after successful login. | 38 + | 1.3 | Inspect the site header (desktop viewport, 1024px+). | A "Settings" link with `href="/settings"` is visible in the desktop navigation bar alongside the handle and "Log out" button. | 39 + | 1.4 | Resize the browser to mobile width (below 768px) and open the hamburger menu. | A "Settings" link is visible in the mobile dropdown navigation. | 40 + | 1.5 | Click the "Settings" link (either desktop or mobile). | Browser navigates to `/settings`. Page loads with a 200 status. The page title contains "Settings". | 41 + | 1.6 | Open a private/incognito window and navigate directly to `/settings` without logging in. | Browser is redirected to `/login`. The URL bar shows `/login`. | 42 + 43 + --- 44 + 45 + ## Phase 2: Theme Selection Form Rendering 46 + 47 + | Step | Action | Expected | 48 + |------|--------|----------| 49 + | 2.1 | On `/settings` while authenticated, inspect the form. | Two `<select>` dropdowns are present: one labeled for light theme (`id="lightThemeUri"`) and one for dark theme (`id="darkThemeUri"`). Each lists the available themes from the forum's theme policy. | 50 + | 2.2 | Open browser DevTools and inspect the `<select>` elements. | Each select has these attributes: `hx-get="/settings/preview"`, `hx-trigger="change"`, `hx-target="#theme-preview"`, `hx-swap="outerHTML"`, `hx-include="this"`. | 51 + | 2.3 | Verify that a `<div id="theme-preview">` element exists on the page below the selects. | The div is present and initially empty (no swatch content). | 52 + 53 + --- 54 + 55 + ## Phase 3: HTMX Live Preview — HV1 56 + 57 + | Step | Action | Expected | 58 + |------|--------|----------| 59 + | 3.1 | On `/settings`, change the light theme dropdown to a different theme. | Without a full page reload, the `#theme-preview` area updates to show the theme name and five colored swatch squares representing `color-bg`, `color-surface`, `color-primary`, `color-text`, and `color-border`. | 60 + | 3.2 | Open the browser Network tab (DevTools). Change the light theme dropdown again. | A GET request to `/settings/preview?lightThemeUri=at://...` appears in the network log. Response is 200 with an HTML fragment. No full page navigation occurs. | 61 + | 3.3 | Change the dark theme dropdown to a different dark theme. | The `#theme-preview` area updates with the dark theme's name and swatches. The network log shows a GET to `/settings/preview?darkThemeUri=at://...`. | 62 + | 3.4 | Open the browser Console tab. Perform several dropdown changes. | No JavaScript errors appear in the console during any preview interaction. | 63 + 64 + --- 65 + 66 + ## Phase 4: Saving Preferences 67 + 68 + | Step | Action | Expected | 69 + |------|--------|----------| 70 + | 4.1 | Select a specific light theme and a specific dark theme from the dropdowns. Click "Save preferences". | Browser redirects to `/settings?saved=1`. A success banner reading "Preferences saved." is visible. | 71 + | 4.2 | Open browser DevTools > Application > Cookies. Filter for the current domain. | Two cookies are present: `atbb-light-theme` (AT URI matching the selected light theme) and `atbb-dark-theme` (AT URI matching the selected dark theme). Both have `Path=/`, `Max-Age=31536000` (1 year), and `SameSite=Lax`. | 72 + | 4.3 | Refresh the `/settings` page. | The light and dark theme dropdowns pre-select the previously saved themes. The `selected` attribute is on the correct `<option>` elements. | 73 + 74 + --- 75 + 76 + ## Phase 5: Theme Application on Page Load 77 + 78 + | Step | Action | Expected | 79 + |------|--------|----------| 80 + | 5.1 | After saving preferences, navigate to the forum homepage (`/`). | The page renders using the selected light theme's color tokens. | 81 + | 5.2 | Click the color scheme toggle to switch to dark mode. | The page reloads and renders using the selected dark theme's color tokens. An `atbb-color-scheme=dark` cookie is set. | 82 + | 5.3 | Navigate to several pages (topic list, category view, etc.) while in dark mode. | All pages consistently use the selected dark theme. No flash of the default theme occurs during navigation. | 83 + 84 + --- 85 + 86 + ## Phase 6: Visual Swatch Correctness — HV2 87 + 88 + | Step | Action | Expected | 89 + |------|--------|----------| 90 + | 6.1 | On `/settings`, change the light theme dropdown. | The preview area shows visually distinct colored squares. Each swatch has a visible background color matching the theme's token values. | 91 + | 6.2 | Hover over each swatch (or inspect via DevTools). | Each swatch has a `title` attribute indicating the token (`color-bg`, `color-primary`, etc.). The inline `style="background:<color>"` matches the token value. | 92 + | 6.3 | Verify the theme name label above the swatches. | The name is clearly readable and matches the theme selected in the dropdown. | 93 + | 6.4 | Repeat steps 6.1-6.3 for a dark theme selection. | Dark theme swatches show appropriately dark colors. The theme name is correct. | 94 + 95 + --- 96 + 97 + ## Phase 7: Cookie Persistence Across Browser Sessions — HV3 98 + 99 + | Step | Action | Expected | 100 + |------|--------|----------| 101 + | 7.1 | Save theme preferences on `/settings` with non-default themes. | "Preferences saved." banner appears. | 102 + | 7.2 | Fully close the browser (all windows, not just the tab). | Browser process exits. | 103 + | 7.3 | Reopen the browser and navigate to the forum homepage. | The forum renders with the previously selected theme, not the forum default. | 104 + | 7.4 | Navigate to `/settings`. | The dropdowns show the previously saved selections. | 105 + | 7.5 | Switch to dark mode via the toggle and close/reopen the browser. | Dark mode with the custom dark theme persists across the browser restart. | 106 + 107 + --- 108 + 109 + ## Phase 8: Validation Error Flows 110 + 111 + | Step | Action | Expected | 112 + |------|--------|----------| 113 + | 8.1 | Using browser DevTools Console, submit a POST to `/settings/appearance` with a `lightThemeUri` not in the available themes. | Browser redirects to `/settings?error=invalid-theme`. No cookies are set or modified. | 114 + | 8.2 | Submit a POST to `/settings/appearance` with an empty body. | Browser redirects to `/settings?error=invalid`. | 115 + 116 + --- 117 + 118 + ## End-to-End: Full Theme Preference Lifecycle 119 + 120 + **Purpose:** Validate the complete flow from unauthenticated state through preference selection, persistence, application, and cross-session retention. 121 + 122 + 1. Open incognito window. Navigate to the forum homepage. Verify default theme is applied. 123 + 2. Navigate to `/settings`. Confirm redirect to `/login`. 124 + 3. Log in with a valid AT Protocol account. Navigate to `/settings`. 125 + 4. Verify the light and dark theme dropdowns are populated and defaults are selected. 126 + 5. Change the light theme dropdown. Verify the preview updates without page reload (HTMX swap). 127 + 6. Change the dark theme dropdown. Verify the preview updates with the dark theme's swatches. 128 + 7. Click Save. Confirm "Preferences saved." banner and `/settings?saved=1` URL. 129 + 8. Navigate to the homepage. Verify the selected light theme is applied. 130 + 9. Toggle to dark mode. Verify the selected dark theme is applied. 131 + 10. Close the browser entirely. Reopen and navigate to the forum. Verify the dark theme persists. 132 + 11. Navigate to `/settings`. Verify both dropdowns still show the previously saved selections. 133 + 12. Log out. Navigate to `/settings`. Confirm redirect to `/login`. 134 + 135 + --- 136 + 137 + ## End-to-End: Admin Disables User Choice 138 + 139 + **Purpose:** Validate that the `allowUserChoice: false` policy flag gates the entire settings UI and POST endpoint. 140 + 141 + 1. As a forum administrator, set the theme policy to `allowUserChoice: false`. 142 + 2. Log in as a regular user. Navigate to `/settings`. 143 + 3. Verify the page shows an informational banner: "Theme selection is managed by the forum administrator". Verify no `<select>` dropdowns are present. 144 + 4. Using DevTools, manually POST to `/settings/appearance` with valid theme URIs. Verify the response is 302 to `/settings?error=not-allowed`. 145 + 5. Navigate to the forum homepage. Verify the forum default theme is applied regardless of any previously saved preference cookies. 146 + 147 + --- 148 + 149 + ## Traceability 150 + 151 + | Acceptance Criterion | Automated Test | Manual Step | 152 + |----------------------|----------------|-------------| 153 + | AC1.1: Settings link in nav | `base.test.tsx`: auth visibility + desktop/mobile rendering | Phase 1, steps 1.1–1.4 | 154 + | AC1.2: Settings page renders selects | `settings.test.tsx`: "renders form with light/dark theme selects" | Phase 2, step 2.1 | 155 + | AC1.3: Light select triggers preview | `settings.test.tsx`: "renders selects with hx-get attribute" | Phase 3, steps 3.1–3.2 — **HV1** | 156 + | AC1.4: Dark select triggers preview | `settings.test.tsx`: same hx-* attribute test | Phase 3, step 3.3 — **HV1** | 157 + | AC1.5: Unauth redirect to /login | `settings.test.tsx`: "redirects unauthenticated users to /login" | Phase 1, step 1.6 | 158 + | AC2.1: Form sets preference cookies | `settings.test.tsx`: "sets theme cookies and redirects" | Phase 4, steps 4.1–4.2 — **HV3** | 159 + | AC2.2: "Preferences saved" banner | `settings.test.tsx`: "GET /settings with ?saved=1 shows success banner" | Phase 4, step 4.1 | 160 + | AC2.3: Saved themes pre-selected | `settings.test.tsx`: "pre-selects current preference cookie" | Phase 4, step 4.3 | 161 + | AC3.1: Light cookie applied on load | `theme-resolution.test.ts`: unit + integration | Phase 5, step 5.1 — **HV3** | 162 + | AC3.2: Dark cookie applied on load | `theme-resolution.test.ts`: unit + integration | Phase 5, step 5.2 — **HV3** | 163 + | AC3.3: Stale cookie falls back | `theme-resolution.test.ts`: unit + integration | Automated only | 164 + | AC4.1: Reject URI not in policy | `settings.test.tsx`: invalid light/dark theme URI tests | Phase 8, step 8.1 | 165 + | AC4.2: Reject when allowUserChoice false | `settings.test.tsx`: "rejects when allowUserChoice: false" | E2E: Admin Disables User Choice, step 4 | 166 + | AC4.3: Reject missing/malformed body | `settings.test.tsx`: three partial-body tests | Phase 8, step 8.2 | 167 + | AC4.4: Policy fetch failure is safe | `settings.test.tsx`: "rejects when policy fetch fails" | Automated only | 168 + | AC5.1: Info banner when choice disabled | `settings.test.tsx`: "shows informational banner" | E2E: Admin Disables User Choice, steps 2–3 | 169 + | AC5.2: Resolution ignores cookie when disabled | `theme-resolution.test.ts`: unit + integration | E2E: Admin Disables User Choice, step 5 | 170 + | AC6.1: Preview returns swatch fragment | `settings.test.tsx`: valid lightThemeUri/darkThemeUri tests | Phase 6 — **HV2** | 171 + | AC6.2: Unknown URI returns empty fragment | `settings.test.tsx`: 4 edge-case tests | Automated only |