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 design doc for ATB-53 theme resolution and server-side token injection

+115
+115
docs/plans/2026-03-04-atb53-theme-resolution-design.md
···
··· 1 + # ATB-53: Theme Resolution and Server-Side Token Injection — Design 2 + 3 + **Status:** Approved 4 + **Linear:** [ATB-53](https://linear.app/atbb/issue/ATB-53) 5 + **Branch:** `atb-59-theme-editor` (active) → target `main` 6 + 7 + --- 8 + 9 + ## Context 10 + 11 + Dependencies are complete: 12 + - ATB-51 (lexicons): Done 13 + - ATB-52 (CSS token system): Done 14 + - ATB-55 (theme read API): Done 15 + - ATB-57 (theme write API): Done 16 + 17 + `tokensToCss()` and the two preset JSONs (`neobrutal-light.json`, `neobrutal-dark.json`) already exist in `apps/web/src/`. `BaseLayout` currently hardcodes neobrutal-light as a static module-level constant — this ticket makes it dynamic. 18 + 19 + --- 20 + 21 + ## Architecture 22 + 23 + ### Middleware approach 24 + 25 + Hono middleware applied to `webRoutes` before all page routes. Uses Hono's typed Variables system (`Hono<{ Variables: { theme: ResolvedTheme } }>`). All route factories update to `new Hono<WebAppEnv>()` for end-to-end type safety. 26 + 27 + ### Resolution waterfall 28 + 29 + ``` 30 + 1. User preference — always null for now (TODO: populate when 31 + space.atbb.membership.preferredTheme is added in Theme Phase 4) 32 + 2. Color scheme detection — atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light" 33 + 3. Forum default — fetch GET /api/theme-policy, pick defaultLightThemeUri or 34 + defaultDarkThemeUri, fetch full theme, CID integrity check 35 + 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light tokens + Google Fonts URL) 36 + ``` 37 + 38 + Any error at any step catches, logs, and returns `FALLBACK_THEME` with the detected color scheme. 39 + 40 + ### CID integrity check 41 + 42 + The theme policy stores an expected CID per available theme. When the resolution waterfall fetches a theme by rkey, it compares `theme.cid` (returned by `GET /api/themes/:rkey`) against the CID stored in `policy.availableThemes`. A mismatch means the theme record changed after the policy was last updated — fall back to hardcoded and log a warning. Requires adding `cid` to `serializeThemeFull` in the AppView (small change, column already exists). 43 + 44 + ### Caching 45 + 46 + Not in scope for ATB-53. ATB-56 adds the in-memory caching layer with cache invalidation on theme writes. When ATB-56 is implemented, `Vary: Cookie` headers should be added to HTML responses to prevent CDN caching from serving the wrong theme to users. 47 + 48 + --- 49 + 50 + ## Types 51 + 52 + ```typescript 53 + // apps/web/src/lib/theme-resolution.ts 54 + 55 + export type ResolvedTheme = { 56 + tokens: Record<string, string>; 57 + cssOverrides: string | null; 58 + fontUrls: string[] | null; 59 + colorScheme: "light" | "dark"; 60 + }; 61 + 62 + export type WebAppEnv = { 63 + Variables: { theme: ResolvedTheme }; 64 + }; 65 + 66 + export const FALLBACK_THEME: ResolvedTheme = { 67 + tokens: neobrutalLight, 68 + cssOverrides: null, 69 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap"], 70 + colorScheme: "light", 71 + }; 72 + ``` 73 + 74 + --- 75 + 76 + ## BaseLayout changes 77 + 78 + `resolvedTheme: ResolvedTheme` becomes a required prop. Removes: 79 + - Module-level `ROOT_CSS` constant and `neobrutal-light` import 80 + - Hardcoded Google Fonts `<link>` tags (moved to `FALLBACK_THEME.fontUrls`) 81 + 82 + Adds: 83 + - `<meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" />` 84 + - Dynamic `:root { ... }` `<style>` block from `tokensToCss(resolvedTheme.tokens)` 85 + - `resolvedTheme.fontUrls` rendered as `<link rel="stylesheet">` tags (with `<link rel="preconnect">` to googleapis if any font URLs are present) 86 + - `resolvedTheme.cssOverrides` rendered as an additional `<style>` block if non-null 87 + 88 + --- 89 + 90 + ## Files Changed 91 + 92 + | File | Change | 93 + |------|--------| 94 + | `apps/appview/src/routes/themes.ts` | Add `cid` to `serializeThemeFull` | 95 + | `apps/web/src/lib/theme-resolution.ts` | **New** — `ResolvedTheme`, `WebAppEnv`, `FALLBACK_THEME`, `detectColorScheme`, `parseRkeyFromUri`, `resolveTheme` | 96 + | `apps/web/src/middleware/theme.ts` | **New** — Hono middleware wrapping `resolveTheme` | 97 + | `apps/web/src/routes/index.ts` | Add `WebAppEnv` type + `createThemeMiddleware` before routes | 98 + | `apps/web/src/layouts/base.tsx` | Dynamic theme prop, `Accept-CH`, font URLs, cssOverrides | 99 + | `apps/web/src/routes/home.tsx` | `new Hono<WebAppEnv>()`, pass theme to BaseLayout | 100 + | `apps/web/src/routes/boards.tsx` | Same | 101 + | `apps/web/src/routes/topics.tsx` | Same | 102 + | `apps/web/src/routes/login.tsx` | Same | 103 + | `apps/web/src/routes/new-topic.tsx` | Same | 104 + | `apps/web/src/routes/admin.tsx` | Same | 105 + | `apps/web/src/routes/not-found.tsx` | Same | 106 + | `apps/web/src/routes/auth.ts` | `new Hono<WebAppEnv>()` only (no HTML rendering) | 107 + | `apps/web/src/routes/mod.ts` | Same | 108 + 109 + --- 110 + 111 + ## Tests 112 + 113 + - `apps/web/src/lib/__tests__/theme-resolution.test.ts` (new): all waterfall branches — policy fetch failure, missing default URI, CID mismatch, happy path light, happy path dark, network error fallback; `detectColorScheme` priority order 114 + - `apps/web/src/layouts/__tests__/base.test.tsx`: update to pass `resolvedTheme` prop; verify `Accept-CH` meta; verify `cssOverrides` block renders when present 115 + - `apps/appview/src/routes/__tests__/themes.test.ts`: assert `cid` field in `GET /api/themes/:rkey` response