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

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.

+1290
+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
+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 |