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

docs: add implementation plan for ATB-53 theme resolution and server-side token injection

+1089
+1089
docs/plans/2026-03-04-atb53-theme-resolution-implementation.md
··· 1 + # ATB-53: Theme Resolution and Server-Side Token Injection — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Wire the resolved theme into every server-rendered HTML response — fetching the active theme from the AppView, applying the waterfall (user pref → color scheme → forum default → hardcoded fallback), and injecting the winning tokens as a `<style>:root { ... }</style>` block in `BaseLayout`. 6 + 7 + **Architecture:** A Hono middleware runs before all page routes, calls `resolveTheme()`, and sets the result on a typed Hono context variable. `BaseLayout` accepts `resolvedTheme` as a required prop and renders tokens, font URLs, and optional CSS overrides dynamically. The AppView's `GET /api/themes/:rkey` gains a `cid` field to enable the CID integrity check. 8 + 9 + **Tech Stack:** Hono (middleware + typed Variables), Vitest (vi.stubGlobal for fetch mocking, vi.mock for module mocking), TypeScript, existing `tokensToCss()` utility, existing `neobrutal-light.json` preset. 10 + 11 + --- 12 + 13 + ## Context 14 + 15 + All dependencies are done: theme lexicons (ATB-51), CSS token system (ATB-52), theme read API (ATB-55), theme write API (ATB-57), theme list page (ATB-58), token editor (ATB-59). 16 + 17 + **What already exists:** 18 + - `apps/web/src/lib/theme.ts` — `tokensToCss(tokens)` utility 19 + - `apps/web/src/styles/presets/neobrutal-light.json` and `neobrutal-dark.json` 20 + - `apps/appview/src/routes/themes.ts` — `GET /api/themes/:rkey` (missing `cid` in response) 21 + - `apps/appview/src/routes/__tests__/themes.test.ts` — existing tests (no `cid` assertion yet) 22 + - `apps/web/src/layouts/base.tsx` — currently hardcodes neobrutal-light as module-level constant 23 + - `apps/web/src/layouts/__tests__/base.test.tsx` — existing tests pass `<BaseLayout>` without `resolvedTheme` 24 + 25 + **Fetch-mocking pattern** (from `session.test.ts`): 26 + ```typescript 27 + const mockFetch = vi.fn(); 28 + beforeEach(() => { vi.stubGlobal("fetch", mockFetch); }); 29 + afterEach(() => { vi.unstubAllGlobals(); mockFetch.mockReset(); }); 30 + ``` 31 + 32 + **Test commands:** 33 + ```bash 34 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 35 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts 36 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/middleware/__tests__/theme.test.ts 37 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/layouts/__tests__/base.test.tsx 38 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run 39 + PATH=.devenv/profile/bin:$PATH pnpm test 40 + ``` 41 + 42 + --- 43 + 44 + ## Task 1: AppView — Add `cid` to `GET /api/themes/:rkey` response 45 + 46 + **Files:** 47 + - Modify: `apps/appview/src/routes/themes.ts` (`serializeThemeFull` function) 48 + - Modify: `apps/appview/src/routes/__tests__/themes.test.ts` (add `cid` assertion) 49 + 50 + ### Step 1: Add failing assertion to existing test 51 + 52 + In `apps/appview/src/routes/__tests__/themes.test.ts`, find the test "returns full theme data including tokens, cssOverrides, and fontUrls" and add one line: 53 + 54 + ```typescript 55 + // After: expect(body.indexedAt).toBeDefined(); 56 + expect(body.cid).toBe("bafyfull"); 57 + ``` 58 + 59 + ### Step 2: Run test to verify it fails 60 + 61 + ```bash 62 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 63 + ``` 64 + 65 + Expected: FAIL — `expect(received).toBe(expected)` with `received: undefined`. 66 + 67 + ### Step 3: Add `cid` to `serializeThemeFull` 68 + 69 + In `apps/appview/src/routes/themes.ts`, update `serializeThemeFull`: 70 + 71 + ```typescript 72 + function serializeThemeFull(theme: ThemeRow) { 73 + return { 74 + id: serializeBigInt(theme.id), 75 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 76 + cid: theme.cid, // ← add this line 77 + name: theme.name, 78 + colorScheme: theme.colorScheme, 79 + tokens: theme.tokens, 80 + cssOverrides: theme.cssOverrides ?? null, 81 + fontUrls: (theme.fontUrls as string[] | null) ?? null, 82 + createdAt: serializeDate(theme.createdAt), 83 + indexedAt: serializeDate(theme.indexedAt), 84 + }; 85 + } 86 + ``` 87 + 88 + ### Step 4: Run tests to verify they pass 89 + 90 + ```bash 91 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 92 + ``` 93 + 94 + Expected: all PASS. 95 + 96 + ### Step 5: Commit 97 + 98 + ```bash 99 + git add apps/appview/src/routes/themes.ts apps/appview/src/routes/__tests__/themes.test.ts 100 + git commit -m "feat(appview): include cid in GET /api/themes/:rkey response (ATB-53)" 101 + ``` 102 + 103 + --- 104 + 105 + ## Task 2: Web — Core types, helpers, and FALLBACK_THEME 106 + 107 + **Files:** 108 + - Create: `apps/web/src/lib/theme-resolution.ts` 109 + - Create: `apps/web/src/lib/__tests__/theme-resolution.test.ts` 110 + 111 + ### Step 1: Write failing tests for `detectColorScheme` and `parseRkeyFromUri` 112 + 113 + Create `apps/web/src/lib/__tests__/theme-resolution.test.ts`: 114 + 115 + ```typescript 116 + import { describe, it, expect } from "vitest"; 117 + import { 118 + detectColorScheme, 119 + parseRkeyFromUri, 120 + FALLBACK_THEME, 121 + } from "../theme-resolution.js"; 122 + 123 + describe("detectColorScheme", () => { 124 + it("returns 'light' by default when no cookie or hint", () => { 125 + expect(detectColorScheme(undefined, undefined)).toBe("light"); 126 + }); 127 + 128 + it("reads atbb-color-scheme=dark from cookie", () => { 129 + expect(detectColorScheme("atbb-color-scheme=dark; other=1", undefined)).toBe("dark"); 130 + }); 131 + 132 + it("reads atbb-color-scheme=light from cookie", () => { 133 + expect(detectColorScheme("atbb-color-scheme=light", undefined)).toBe("light"); 134 + }); 135 + 136 + it("prefers cookie over client hint", () => { 137 + expect(detectColorScheme("atbb-color-scheme=light", "dark")).toBe("light"); 138 + }); 139 + 140 + it("falls back to client hint when no cookie", () => { 141 + expect(detectColorScheme(undefined, "dark")).toBe("dark"); 142 + }); 143 + 144 + it("ignores unrecognized hint values and returns 'light'", () => { 145 + expect(detectColorScheme(undefined, "no-preference")).toBe("light"); 146 + }); 147 + }); 148 + 149 + describe("parseRkeyFromUri", () => { 150 + it("extracts rkey from valid AT URI", () => { 151 + expect( 152 + parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme/3lblthemeabc") 153 + ).toBe("3lblthemeabc"); 154 + }); 155 + 156 + it("returns null for URI with no rkey segment", () => { 157 + expect(parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme")).toBeNull(); 158 + }); 159 + 160 + it("returns null for malformed URI", () => { 161 + expect(parseRkeyFromUri("not-a-uri")).toBeNull(); 162 + }); 163 + 164 + it("returns null for empty string", () => { 165 + expect(parseRkeyFromUri("")).toBeNull(); 166 + }); 167 + }); 168 + 169 + describe("FALLBACK_THEME", () => { 170 + it("uses neobrutal-light tokens", () => { 171 + expect(FALLBACK_THEME.tokens["color-bg"]).toBe("#f5f0e8"); 172 + expect(FALLBACK_THEME.tokens["color-primary"]).toBe("#ff5c00"); 173 + }); 174 + 175 + it("has light colorScheme", () => { 176 + expect(FALLBACK_THEME.colorScheme).toBe("light"); 177 + }); 178 + 179 + it("includes Google Fonts URL for Space Grotesk", () => { 180 + expect(FALLBACK_THEME.fontUrls).toEqual( 181 + expect.arrayContaining([expect.stringContaining("Space+Grotesk")]) 182 + ); 183 + }); 184 + 185 + it("has null cssOverrides", () => { 186 + expect(FALLBACK_THEME.cssOverrides).toBeNull(); 187 + }); 188 + }); 189 + ``` 190 + 191 + ### Step 2: Run to verify they fail 192 + 193 + ```bash 194 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts 195 + ``` 196 + 197 + Expected: FAIL — module not found. 198 + 199 + ### Step 3: Create `apps/web/src/lib/theme-resolution.ts` with types, helpers, and FALLBACK_THEME 200 + 201 + ```typescript 202 + import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 203 + 204 + export type ResolvedTheme = { 205 + tokens: Record<string, string>; 206 + cssOverrides: string | null; 207 + fontUrls: string[] | null; 208 + colorScheme: "light" | "dark"; 209 + }; 210 + 211 + /** Hono app environment type — used by middleware and all route factories. */ 212 + export type WebAppEnv = { 213 + Variables: { theme: ResolvedTheme }; 214 + }; 215 + 216 + /** Hardcoded fallback used when theme policy is missing or resolution fails. */ 217 + export const FALLBACK_THEME: ResolvedTheme = { 218 + tokens: neobrutalLight as Record<string, string>, 219 + cssOverrides: null, 220 + fontUrls: [ 221 + "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", 222 + ], 223 + colorScheme: "light", 224 + }; 225 + 226 + /** 227 + * Detects the user's preferred color scheme. 228 + * Priority: atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light". 229 + */ 230 + export function detectColorScheme( 231 + cookieHeader: string | undefined, 232 + hint: string | undefined 233 + ): "light" | "dark" { 234 + const match = cookieHeader?.match(/atbb-color-scheme=(light|dark)/); 235 + if (match) return match[1] as "light" | "dark"; 236 + if (hint === "dark") return "dark"; 237 + return "light"; 238 + } 239 + 240 + /** 241 + * Extracts the rkey segment from an AT URI. 242 + * Example: "at://did:plc:abc/space.atbb.forum.theme/rkey123" → "rkey123" 243 + */ 244 + export function parseRkeyFromUri(atUri: string): string | null { 245 + // Format: at://<did>/<collection>/<rkey> 246 + // Split gives: ["at:", "", "<did>", "<collection>", "<rkey>"] 247 + const parts = atUri.split("/"); 248 + if (parts.length < 5) return null; 249 + return parts[4] ?? null; 250 + } 251 + ``` 252 + 253 + ### Step 4: Run tests to verify they pass 254 + 255 + ```bash 256 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts 257 + ``` 258 + 259 + Expected: all PASS. 260 + 261 + ### Step 5: Commit 262 + 263 + ```bash 264 + git add apps/web/src/lib/theme-resolution.ts apps/web/src/lib/__tests__/theme-resolution.test.ts 265 + git commit -m "feat(web): add ResolvedTheme types, FALLBACK_THEME, and color scheme helpers (ATB-53)" 266 + ``` 267 + 268 + --- 269 + 270 + ## Task 3: Web — `resolveTheme()` waterfall 271 + 272 + **Files:** 273 + - Modify: `apps/web/src/lib/theme-resolution.ts` 274 + - Modify: `apps/web/src/lib/__tests__/theme-resolution.test.ts` 275 + 276 + ### Step 1: Write failing tests for all waterfall branches 277 + 278 + Add a new `describe("resolveTheme", ...)` block to the end of `apps/web/src/lib/__tests__/theme-resolution.test.ts`: 279 + 280 + ```typescript 281 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 282 + import { 283 + detectColorScheme, 284 + parseRkeyFromUri, 285 + resolveTheme, 286 + FALLBACK_THEME, 287 + } from "../theme-resolution.js"; 288 + import { logger } from "../logger.js"; 289 + 290 + vi.mock("../logger.js", () => ({ 291 + logger: { 292 + debug: vi.fn(), 293 + info: vi.fn(), 294 + warn: vi.fn(), 295 + error: vi.fn(), 296 + fatal: vi.fn(), 297 + }, 298 + })); 299 + 300 + // ... (keep existing describe blocks above, add this new one:) 301 + 302 + describe("resolveTheme", () => { 303 + const mockFetch = vi.fn(); 304 + const APPVIEW = "http://localhost:3001"; 305 + 306 + beforeEach(() => { 307 + vi.stubGlobal("fetch", mockFetch); 308 + vi.mocked(logger.warn).mockClear(); 309 + vi.mocked(logger.error).mockClear(); 310 + }); 311 + 312 + afterEach(() => { 313 + vi.unstubAllGlobals(); 314 + mockFetch.mockReset(); 315 + }); 316 + 317 + function policyResponse(overrides: object = {}) { 318 + return { 319 + ok: true, 320 + json: () => 321 + Promise.resolve({ 322 + defaultLightThemeUri: 323 + "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 324 + defaultDarkThemeUri: 325 + "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 326 + allowUserChoice: true, 327 + availableThemes: [ 328 + { 329 + uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 330 + cid: "bafylight", 331 + }, 332 + { 333 + uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 334 + cid: "bafydark", 335 + }, 336 + ], 337 + ...overrides, 338 + }), 339 + }; 340 + } 341 + 342 + function themeResponse(colorScheme: "light" | "dark", cid: string) { 343 + return { 344 + ok: true, 345 + json: () => 346 + Promise.resolve({ 347 + cid, 348 + tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" }, 349 + cssOverrides: null, 350 + fontUrls: null, 351 + colorScheme, 352 + }), 353 + }; 354 + } 355 + 356 + it("returns FALLBACK_THEME with detected colorScheme when policy fetch fails (non-ok)", async () => { 357 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 358 + 359 + const result = await resolveTheme(APPVIEW, undefined, undefined); 360 + 361 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 362 + expect(result.colorScheme).toBe("light"); 363 + }); 364 + 365 + it("returns FALLBACK_THEME with dark colorScheme when policy fails and dark cookie set", async () => { 366 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); 367 + 368 + const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 369 + 370 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 371 + expect(result.colorScheme).toBe("dark"); 372 + }); 373 + 374 + it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => { 375 + mockFetch.mockResolvedValueOnce( 376 + policyResponse({ defaultLightThemeUri: null }) 377 + ); 378 + 379 + const result = await resolveTheme(APPVIEW, undefined, undefined); 380 + 381 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 382 + }); 383 + 384 + it("returns FALLBACK_THEME when theme fetch fails", async () => { 385 + mockFetch 386 + .mockResolvedValueOnce(policyResponse()) 387 + .mockResolvedValueOnce({ ok: false, status: 404 }); 388 + 389 + const result = await resolveTheme(APPVIEW, undefined, undefined); 390 + 391 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 392 + }); 393 + 394 + it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => { 395 + mockFetch 396 + .mockResolvedValueOnce(policyResponse()) 397 + .mockResolvedValueOnce(themeResponse("light", "WRONG_CID")); 398 + 399 + const result = await resolveTheme(APPVIEW, undefined, undefined); 400 + 401 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 402 + expect(logger.warn).toHaveBeenCalledWith( 403 + expect.stringContaining("CID mismatch"), 404 + expect.objectContaining({ expectedCid: "bafylight", actualCid: "WRONG_CID" }) 405 + ); 406 + }); 407 + 408 + it("resolves the light theme on happy path (no cookie)", async () => { 409 + mockFetch 410 + .mockResolvedValueOnce(policyResponse()) 411 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 412 + 413 + const result = await resolveTheme(APPVIEW, undefined, undefined); 414 + 415 + expect(result.tokens["color-bg"]).toBe("#fff"); 416 + expect(result.colorScheme).toBe("light"); 417 + expect(result.cssOverrides).toBeNull(); 418 + expect(result.fontUrls).toBeNull(); 419 + }); 420 + 421 + it("resolves the dark theme when atbb-color-scheme=dark cookie is set", async () => { 422 + mockFetch 423 + .mockResolvedValueOnce(policyResponse()) 424 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 425 + 426 + const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 427 + 428 + expect(result.tokens["color-bg"]).toBe("#111"); 429 + expect(result.colorScheme).toBe("dark"); 430 + // Verify the dark theme URI was fetched (3lbldark, not 3lbllight) 431 + expect(mockFetch).toHaveBeenCalledWith( 432 + expect.stringContaining("3lbldark") 433 + ); 434 + }); 435 + 436 + it("resolves dark theme from Sec-CH-Prefers-Color-Scheme hint when no cookie", async () => { 437 + mockFetch 438 + .mockResolvedValueOnce(policyResponse()) 439 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 440 + 441 + const result = await resolveTheme(APPVIEW, undefined, "dark"); 442 + 443 + expect(result.colorScheme).toBe("dark"); 444 + }); 445 + 446 + it("returns FALLBACK_THEME and logs error on network exception", async () => { 447 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 448 + 449 + const result = await resolveTheme(APPVIEW, undefined, undefined); 450 + 451 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 452 + expect(logger.error).toHaveBeenCalledWith( 453 + expect.stringContaining("Theme resolution failed"), 454 + expect.objectContaining({ operation: "resolveTheme" }) 455 + ); 456 + }); 457 + 458 + it("passes cssOverrides and fontUrls through from theme response", async () => { 459 + mockFetch 460 + .mockResolvedValueOnce(policyResponse()) 461 + .mockResolvedValueOnce({ 462 + ok: true, 463 + json: () => 464 + Promise.resolve({ 465 + cid: "bafylight", 466 + tokens: { "color-bg": "#fff" }, 467 + cssOverrides: ".btn { font-weight: 700; }", 468 + fontUrls: ["https://fonts.example.com/font.css"], 469 + colorScheme: "light", 470 + }), 471 + }); 472 + 473 + const result = await resolveTheme(APPVIEW, undefined, undefined); 474 + 475 + expect(result.cssOverrides).toBe(".btn { font-weight: 700; }"); 476 + expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]); 477 + }); 478 + }); 479 + ``` 480 + 481 + **Note:** Also add the `vi.mock("../logger.js", ...)` block and update the `import` line at the top of the test file. 482 + 483 + ### Step 2: Run to verify they fail 484 + 485 + ```bash 486 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts 487 + ``` 488 + 489 + Expected: FAIL — `resolveTheme` is not exported. 490 + 491 + ### Step 3: Add `resolveTheme` to `apps/web/src/lib/theme-resolution.ts` 492 + 493 + Add after the existing helpers. Also add the internal type interfaces and the logger import: 494 + 495 + ```typescript 496 + import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 497 + import { logger } from "./logger.js"; 498 + 499 + // ... (existing exports: ResolvedTheme, WebAppEnv, FALLBACK_THEME, detectColorScheme, parseRkeyFromUri) 500 + 501 + interface ThemePolicyResponse { 502 + defaultLightThemeUri: string | null; 503 + defaultDarkThemeUri: string | null; 504 + allowUserChoice: boolean; 505 + availableThemes: Array<{ uri: string; cid: string }>; 506 + } 507 + 508 + interface ThemeResponse { 509 + cid: string; 510 + tokens: Record<string, unknown>; 511 + cssOverrides: string | null; 512 + fontUrls: string[] | null; 513 + } 514 + 515 + /** 516 + * Resolves which theme to render for a request using the waterfall: 517 + * 1. User preference — not yet implemented (TODO: Theme Phase 4) 518 + * 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint 519 + * 3. Forum default — fetched from GET /api/theme-policy 520 + * 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light) 521 + * 522 + * Never throws — always returns a usable theme. 523 + */ 524 + export async function resolveTheme( 525 + appviewUrl: string, 526 + cookieHeader: string | undefined, 527 + colorSchemeHint: string | undefined 528 + ): Promise<ResolvedTheme> { 529 + const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); 530 + 531 + // Step 1: User preference 532 + // TODO: implement when space.atbb.membership.preferredTheme is added (Theme Phase 4) 533 + 534 + // Steps 2-3: Forum default via theme policy 535 + try { 536 + const policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 537 + if (!policyRes.ok) { 538 + return { ...FALLBACK_THEME, colorScheme }; 539 + } 540 + 541 + const policy = (await policyRes.json()) as ThemePolicyResponse; 542 + 543 + const defaultUri = 544 + colorScheme === "dark" 545 + ? policy.defaultDarkThemeUri 546 + : policy.defaultLightThemeUri; 547 + 548 + if (!defaultUri) { 549 + return { ...FALLBACK_THEME, colorScheme }; 550 + } 551 + 552 + const rkey = parseRkeyFromUri(defaultUri); 553 + if (!rkey) { 554 + return { ...FALLBACK_THEME, colorScheme }; 555 + } 556 + 557 + const expectedCid = 558 + policy.availableThemes.find((t) => t.uri === defaultUri)?.cid ?? null; 559 + 560 + const themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); 561 + if (!themeRes.ok) { 562 + return { ...FALLBACK_THEME, colorScheme }; 563 + } 564 + 565 + const theme = (await themeRes.json()) as ThemeResponse; 566 + 567 + if (expectedCid && theme.cid !== expectedCid) { 568 + logger.warn("Theme CID mismatch — using hardcoded fallback", { 569 + operation: "resolveTheme", 570 + expectedCid, 571 + actualCid: theme.cid, 572 + themeUri: defaultUri, 573 + }); 574 + return { ...FALLBACK_THEME, colorScheme }; 575 + } 576 + 577 + return { 578 + tokens: theme.tokens as Record<string, string>, 579 + cssOverrides: theme.cssOverrides ?? null, 580 + fontUrls: theme.fontUrls ?? null, 581 + colorScheme, 582 + }; 583 + } catch (error) { 584 + // Intentionally don't re-throw: a broken theme system should serve the 585 + // fallback and log the error, rather than crash every page request. 586 + logger.error("Theme resolution failed — using hardcoded fallback", { 587 + operation: "resolveTheme", 588 + error: error instanceof Error ? error.message : String(error), 589 + }); 590 + return { ...FALLBACK_THEME, colorScheme }; 591 + } 592 + } 593 + ``` 594 + 595 + ### Step 4: Run tests to verify they pass 596 + 597 + ```bash 598 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts 599 + ``` 600 + 601 + Expected: all PASS. 602 + 603 + ### Step 5: Commit 604 + 605 + ```bash 606 + git add apps/web/src/lib/theme-resolution.ts apps/web/src/lib/__tests__/theme-resolution.test.ts 607 + git commit -m "feat(web): implement resolveTheme waterfall with CID integrity check (ATB-53)" 608 + ``` 609 + 610 + --- 611 + 612 + ## Task 4: Web — Theme middleware 613 + 614 + **Files:** 615 + - Create: `apps/web/src/middleware/theme.ts` 616 + - Create: `apps/web/src/middleware/__tests__/theme.test.ts` 617 + 618 + ### Step 1: Write failing test 619 + 620 + Create `apps/web/src/middleware/__tests__/theme.test.ts`: 621 + 622 + ```typescript 623 + import { describe, it, expect, vi, beforeEach } from "vitest"; 624 + import { Hono } from "hono"; 625 + import { createThemeMiddleware } from "../theme.js"; 626 + import { FALLBACK_THEME } from "../../lib/theme-resolution.js"; 627 + import type { WebAppEnv } from "../../lib/theme-resolution.js"; 628 + 629 + vi.mock("../../lib/theme-resolution.js", async (importOriginal) => { 630 + const actual = 631 + await importOriginal<typeof import("../../lib/theme-resolution.js")>(); 632 + return { 633 + ...actual, 634 + resolveTheme: vi.fn().mockResolvedValue(actual.FALLBACK_THEME), 635 + }; 636 + }); 637 + 638 + const { resolveTheme } = await import("../../lib/theme-resolution.js"); 639 + 640 + describe("createThemeMiddleware", () => { 641 + const APPVIEW = "http://localhost:3001"; 642 + 643 + beforeEach(() => { 644 + vi.mocked(resolveTheme).mockClear(); 645 + }); 646 + 647 + function makeApp() { 648 + return new Hono<WebAppEnv>() 649 + .use("*", createThemeMiddleware(APPVIEW)) 650 + .get("/test", (c) => { 651 + const theme = c.get("theme"); 652 + return c.json({ colorScheme: theme.colorScheme }); 653 + }); 654 + } 655 + 656 + it("sets resolved theme on context so handlers can access it", async () => { 657 + const app = makeApp(); 658 + const res = await app.request("/test"); 659 + expect(res.status).toBe(200); 660 + const body = await res.json(); 661 + expect(body.colorScheme).toBe("light"); 662 + }); 663 + 664 + it("forwards cookie header to resolveTheme", async () => { 665 + const app = makeApp(); 666 + await app.request("/test", { 667 + headers: { cookie: "atbb-color-scheme=dark" }, 668 + }); 669 + expect(resolveTheme).toHaveBeenCalledWith( 670 + APPVIEW, 671 + "atbb-color-scheme=dark", 672 + undefined 673 + ); 674 + }); 675 + 676 + it("forwards Sec-CH-Prefers-Color-Scheme header to resolveTheme", async () => { 677 + const app = makeApp(); 678 + await app.request("/test", { 679 + headers: { "sec-ch-prefers-color-scheme": "dark" }, 680 + }); 681 + expect(resolveTheme).toHaveBeenCalledWith( 682 + APPVIEW, 683 + undefined, 684 + "dark" 685 + ); 686 + }); 687 + 688 + it("calls next() so the route handler executes", async () => { 689 + const app = makeApp(); 690 + const res = await app.request("/test"); 691 + expect(res.status).toBe(200); // not 404 — next() was called 692 + }); 693 + }); 694 + ``` 695 + 696 + ### Step 2: Run to verify it fails 697 + 698 + ```bash 699 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/middleware/__tests__/theme.test.ts 700 + ``` 701 + 702 + Expected: FAIL — module not found. 703 + 704 + ### Step 3: Create `apps/web/src/middleware/theme.ts` 705 + 706 + ```typescript 707 + import type { MiddlewareHandler } from "hono"; 708 + import { resolveTheme } from "../lib/theme-resolution.js"; 709 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 710 + 711 + export function createThemeMiddleware( 712 + appviewUrl: string 713 + ): MiddlewareHandler<WebAppEnv> { 714 + return async (c, next) => { 715 + const resolvedTheme = await resolveTheme( 716 + appviewUrl, 717 + c.req.header("cookie"), 718 + c.req.header("Sec-CH-Prefers-Color-Scheme") 719 + ); 720 + c.set("theme", resolvedTheme); 721 + await next(); 722 + }; 723 + } 724 + ``` 725 + 726 + ### Step 4: Run tests to verify they pass 727 + 728 + ```bash 729 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/middleware/__tests__/theme.test.ts 730 + ``` 731 + 732 + Expected: all PASS. 733 + 734 + ### Step 5: Commit 735 + 736 + ```bash 737 + git add apps/web/src/middleware/theme.ts apps/web/src/middleware/__tests__/theme.test.ts 738 + git commit -m "feat(web): add theme resolution middleware (ATB-53)" 739 + ``` 740 + 741 + --- 742 + 743 + ## Task 5: Web — Wire middleware into `routes/index.ts` 744 + 745 + **Files:** 746 + - Modify: `apps/web/src/routes/index.ts` 747 + 748 + ### Step 1: Update `routes/index.ts` 749 + 750 + ```typescript 751 + import { Hono } from "hono"; 752 + import { loadConfig } from "../lib/config.js"; 753 + import { createThemeMiddleware } from "../middleware/theme.js"; 754 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 755 + import { createHomeRoutes } from "./home.js"; 756 + import { createBoardsRoutes } from "./boards.js"; 757 + import { createTopicsRoutes } from "./topics.js"; 758 + import { createLoginRoutes } from "./login.js"; 759 + import { createNewTopicRoutes } from "./new-topic.js"; 760 + import { createAuthRoutes } from "./auth.js"; 761 + import { createModActionRoute } from "./mod.js"; 762 + import { createAdminRoutes } from "./admin.js"; 763 + import { createNotFoundRoute } from "./not-found.js"; 764 + 765 + const config = loadConfig(); 766 + 767 + export const webRoutes = new Hono<WebAppEnv>() 768 + .use("*", createThemeMiddleware(config.appviewUrl)) 769 + .route("/", createHomeRoutes(config.appviewUrl)) 770 + .route("/", createBoardsRoutes(config.appviewUrl)) 771 + .route("/", createTopicsRoutes(config.appviewUrl)) 772 + .route("/", createLoginRoutes(config.appviewUrl)) 773 + .route("/", createNewTopicRoutes(config.appviewUrl)) 774 + .route("/", createAuthRoutes(config.appviewUrl)) 775 + .route("/", createModActionRoute(config.appviewUrl)) 776 + .route("/", createAdminRoutes(config.appviewUrl)) 777 + .route("/", createNotFoundRoute(config.appviewUrl)); 778 + ``` 779 + 780 + ### Step 2: Run TypeScript check 781 + 782 + ```bash 783 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec tsc --noEmit 784 + ``` 785 + 786 + Expected: TypeScript errors about `new Hono()` in route factories not having `WebAppEnv`. These will be fixed in Task 7. 787 + 788 + ### Step 3: Commit (even with TS errors — the plan fixes them in Task 7) 789 + 790 + ```bash 791 + git add apps/web/src/routes/index.ts 792 + git commit -m "feat(web): apply theme middleware to webRoutes (ATB-53)" 793 + ``` 794 + 795 + --- 796 + 797 + ## Task 6: Web — Update `BaseLayout` 798 + 799 + **Files:** 800 + - Modify: `apps/web/src/layouts/base.tsx` 801 + - Modify: `apps/web/src/layouts/__tests__/base.test.tsx` 802 + 803 + ### Step 1: Update tests 804 + 805 + Replace the top of `apps/web/src/layouts/__tests__/base.test.tsx` to import `FALLBACK_THEME` and pass it everywhere `<BaseLayout>` is used: 806 + 807 + ```typescript 808 + import { describe, it, expect } from "vitest"; 809 + import { Hono } from "hono"; 810 + import { BaseLayout } from "../base.js"; 811 + import type { WebSession } from "../../lib/session.js"; 812 + import { FALLBACK_THEME } from "../../lib/theme-resolution.js"; 813 + 814 + // Default test app — all tests use FALLBACK_THEME unless they need specific theme values 815 + const app = new Hono().get("/", (c) => 816 + c.html( 817 + <BaseLayout title="Test Page" resolvedTheme={FALLBACK_THEME}> 818 + Page content 819 + </BaseLayout> 820 + ) 821 + ); 822 + ``` 823 + 824 + Then update every other `new Hono().get(...)` call in the file to also pass `resolvedTheme={FALLBACK_THEME}`. 825 + 826 + Add these new tests at the end of the file: 827 + 828 + ```typescript 829 + describe("theme injection", () => { 830 + it("renders Accept-CH meta tag for Sec-CH-Prefers-Color-Scheme client hint", async () => { 831 + const res = await app.request("/"); 832 + const html = await res.text(); 833 + expect(html).toContain('http-equiv="Accept-CH"'); 834 + expect(html).toContain("Sec-CH-Prefers-Color-Scheme"); 835 + }); 836 + 837 + it("injects resolved tokens as :root CSS custom properties", async () => { 838 + const res = await app.request("/"); 839 + const html = await res.text(); 840 + expect(html).toContain(":root {"); 841 + expect(html).toContain("--color-bg:"); 842 + expect(html).toContain("--color-primary:"); 843 + }); 844 + 845 + it("renders fontUrls from resolvedTheme as stylesheet links", async () => { 846 + const theme = { 847 + ...FALLBACK_THEME, 848 + fontUrls: ["https://fonts.example.com/custom.css"], 849 + }; 850 + const fontApp = new Hono().get("/", (c) => 851 + c.html(<BaseLayout resolvedTheme={theme}>content</BaseLayout>) 852 + ); 853 + const res = await fontApp.request("/"); 854 + const html = await res.text(); 855 + expect(html).toContain('href="https://fonts.example.com/custom.css"'); 856 + expect(html).toContain('rel="preconnect"'); 857 + }); 858 + 859 + it("renders cssOverrides as an additional style block when present", async () => { 860 + const theme = { 861 + ...FALLBACK_THEME, 862 + cssOverrides: ".btn { font-weight: 900; }", 863 + }; 864 + const overrideApp = new Hono().get("/", (c) => 865 + c.html(<BaseLayout resolvedTheme={theme}>content</BaseLayout>) 866 + ); 867 + const res = await overrideApp.request("/"); 868 + const html = await res.text(); 869 + expect(html).toContain(".btn { font-weight: 900; }"); 870 + }); 871 + 872 + it("does not render a cssOverrides style block when null", async () => { 873 + const res = await app.request("/"); 874 + const html = await res.text(); 875 + // Only the :root tokens block should be present, not a second style block 876 + const styleBlocks = html.match(/<style>/g) ?? []; 877 + expect(styleBlocks.length).toBe(1); 878 + }); 879 + }); 880 + ``` 881 + 882 + ### Step 2: Run tests to verify they fail 883 + 884 + ```bash 885 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/layouts/__tests__/base.test.tsx 886 + ``` 887 + 888 + Expected: FAIL — `resolvedTheme` prop not accepted by `BaseLayout`. 889 + 890 + ### Step 3: Rewrite `apps/web/src/layouts/base.tsx` 891 + 892 + ```typescript 893 + import type { FC, PropsWithChildren } from "hono/jsx"; 894 + import { tokensToCss } from "../lib/theme.js"; 895 + import type { WebSession } from "../lib/session.js"; 896 + import type { ResolvedTheme } from "../lib/theme-resolution.js"; 897 + 898 + const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => ( 899 + <> 900 + {auth?.authenticated ? ( 901 + <> 902 + <span class="site-header__handle">{auth.handle}</span> 903 + <form action="/logout" method="post" class="site-header__logout-form"> 904 + <button type="submit" class="site-header__logout-btn"> 905 + Log out 906 + </button> 907 + </form> 908 + </> 909 + ) : ( 910 + <a href="/login" class="site-header__login-link"> 911 + Log in 912 + </a> 913 + )} 914 + </> 915 + ); 916 + 917 + export const BaseLayout: FC< 918 + PropsWithChildren<{ 919 + title?: string; 920 + auth?: WebSession; 921 + resolvedTheme: ResolvedTheme; 922 + }> 923 + > = (props) => { 924 + const { auth, resolvedTheme } = props; 925 + const rootCss = `:root { ${tokensToCss(resolvedTheme.tokens)} }`; 926 + const hasFontUrls = 927 + resolvedTheme.fontUrls !== null && resolvedTheme.fontUrls.length > 0; 928 + 929 + return ( 930 + <html lang="en"> 931 + <head> 932 + <meta charset="UTF-8" /> 933 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 934 + <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" /> 935 + <title>{props.title ?? "atBB Forum"}</title> 936 + <style>{rootCss}</style> 937 + {hasFontUrls && ( 938 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 939 + )} 940 + {resolvedTheme.fontUrls?.map((url) => ( 941 + <link rel="stylesheet" href={url} /> 942 + ))} 943 + <link rel="stylesheet" href="/static/css/reset.css" /> 944 + <link rel="stylesheet" href="/static/css/theme.css" /> 945 + {resolvedTheme.cssOverrides && ( 946 + <style>{resolvedTheme.cssOverrides}</style> 947 + )} 948 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> 949 + <script src="https://unpkg.com/htmx.org@2.0.4" defer /> 950 + </head> 951 + <body> 952 + <a href="#main-content" class="skip-link"> 953 + Skip to main content 954 + </a> 955 + <header class="site-header"> 956 + <div class="site-header__inner"> 957 + <a href="/" class="site-header__title"> 958 + atBB Forum 959 + </a> 960 + <nav class="desktop-nav" aria-label="Main navigation"> 961 + <NavContent auth={auth} /> 962 + </nav> 963 + <details class="mobile-nav"> 964 + <summary class="mobile-nav__toggle" aria-label="Menu"> 965 + &#9776; 966 + </summary> 967 + <nav class="mobile-nav__menu" aria-label="Mobile navigation"> 968 + <NavContent auth={auth} /> 969 + </nav> 970 + </details> 971 + </div> 972 + </header> 973 + <main id="main-content" class="content-container"> 974 + {props.children} 975 + </main> 976 + <footer class="site-footer"> 977 + <p>Powered by atBB on the ATmosphere</p> 978 + </footer> 979 + </body> 980 + </html> 981 + ); 982 + }; 983 + ``` 984 + 985 + ### Step 4: Run tests to verify they pass 986 + 987 + ```bash 988 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/layouts/__tests__/base.test.tsx 989 + ``` 990 + 991 + Expected: all PASS. 992 + 993 + ### Step 5: Commit 994 + 995 + ```bash 996 + git add apps/web/src/layouts/base.tsx apps/web/src/layouts/__tests__/base.test.tsx 997 + git commit -m "feat(web): BaseLayout accepts resolvedTheme prop, adds Accept-CH meta and dynamic font URLs (ATB-53)" 998 + ``` 999 + 1000 + --- 1001 + 1002 + ## Task 7: Web — Update all HTML-rendering route factories 1003 + 1004 + **Files (all in `apps/web/src/routes/`):** 1005 + - Modify: `home.tsx`, `boards.tsx`, `topics.tsx`, `login.tsx`, `new-topic.tsx`, `admin.tsx`, `not-found.tsx` — update Hono type + read theme + pass to BaseLayout 1006 + - Modify: `auth.ts`, `mod.ts` — update Hono type only (no BaseLayout calls) 1007 + 1008 + ### Step 1: Apply the pattern to each HTML-rendering route 1009 + 1010 + For each file (`home.tsx`, `boards.tsx`, `topics.tsx`, `login.tsx`, `new-topic.tsx`, `admin.tsx`, `not-found.tsx`): 1011 + 1012 + **a) Add import at the top:** 1013 + ```typescript 1014 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 1015 + ``` 1016 + 1017 + **b) Change the Hono instance:** 1018 + ```typescript 1019 + // Before: 1020 + return new Hono().get(...) 1021 + // After: 1022 + return new Hono<WebAppEnv>().get(...) 1023 + ``` 1024 + 1025 + **c) At the top of the route handler function body, add:** 1026 + ```typescript 1027 + const theme = c.get("theme"); 1028 + ``` 1029 + 1030 + **d) Add `resolvedTheme={theme}` to every `<BaseLayout>` call in that handler** (including error-path renders — `theme` is always available since it's set before any async calls). 1031 + 1032 + For `auth.ts` and `mod.ts` (no BaseLayout, just update the Hono type): 1033 + ```typescript 1034 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 1035 + // Change: new Hono() → new Hono<WebAppEnv>() 1036 + ``` 1037 + 1038 + ### Step 2: Run all web tests 1039 + 1040 + ```bash 1041 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run 1042 + ``` 1043 + 1044 + Expected: all PASS. TypeScript `--noEmit` should now pass too: 1045 + 1046 + ```bash 1047 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec tsc --noEmit 1048 + ``` 1049 + 1050 + ### Step 3: Commit 1051 + 1052 + ```bash 1053 + git add apps/web/src/routes/ 1054 + git commit -m "feat(web): update all route factories to use WebAppEnv and pass resolvedTheme to BaseLayout (ATB-53)" 1055 + ``` 1056 + 1057 + --- 1058 + 1059 + ## Task 8: Final verification and Bruno collection update 1060 + 1061 + ### Step 1: Run full test suite 1062 + 1063 + ```bash 1064 + PATH=.devenv/profile/bin:$PATH pnpm test 1065 + ``` 1066 + 1067 + Expected: all PASS across all packages. 1068 + 1069 + ### Step 2: Run lint fix 1070 + 1071 + ```bash 1072 + PATH=.devenv/profile/bin:$PATH pnpm turbo lint:fix 1073 + ``` 1074 + 1075 + ### Step 3: Check Bruno collections 1076 + 1077 + Open `bruno/` directory. The ATB-53 changes are server-side rendering only — no new API endpoints are added (the AppView `GET /api/themes/:rkey` now returns `cid`, which should be documented). Update the `GET /api/themes/:rkey` Bruno request to show `cid` in the example response body if there is one. 1078 + 1079 + ### Step 4: Update Linear and plan doc 1080 + 1081 + Mark ATB-53 as Done in Linear with a comment summarising the implementation. Move `docs/plans/2026-03-04-atb53-theme-resolution-design.md` and this file to `docs/plans/complete/`. 1082 + 1083 + ```bash 1084 + mkdir -p docs/plans/complete 1085 + git mv docs/plans/2026-03-04-atb53-theme-resolution-design.md docs/plans/complete/ 1086 + git mv docs/plans/2026-03-04-atb53-theme-resolution-implementation.md docs/plans/complete/ 1087 + git add docs/plans/complete/ 1088 + git commit -m "docs: move ATB-53 plan docs to complete/ (ATB-53)" 1089 + ```