ATB-53: Theme Resolution and Server-Side Token Injection — Design#
Status: Approved
Linear: ATB-53
Branch: atb-59-theme-editor (active) → target main
Context#
Dependencies are complete:
- ATB-51 (lexicons): Done
- ATB-52 (CSS token system): Done
- ATB-55 (theme read API): Done
- ATB-57 (theme write API): Done
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.
Architecture#
Middleware approach#
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.
Resolution waterfall#
1. User preference — always null for now (TODO: populate when
space.atbb.membership.preferredTheme is added in Theme Phase 4)
2. Color scheme detection — atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light"
3. Forum default — fetch GET /api/theme-policy, pick defaultLightThemeUri or
defaultDarkThemeUri, fetch full theme, CID integrity check
4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light tokens + Google Fonts URL)
Any error at any step catches, logs, and returns FALLBACK_THEME with the detected color scheme.
CID integrity check#
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).
Caching#
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.
Types#
// apps/web/src/lib/theme-resolution.ts
export type ResolvedTheme = {
tokens: Record<string, string>;
cssOverrides: string | null;
fontUrls: string[] | null;
colorScheme: "light" | "dark";
};
export type WebAppEnv = {
Variables: { theme: ResolvedTheme };
};
export const FALLBACK_THEME: ResolvedTheme = {
tokens: neobrutalLight,
cssOverrides: null,
fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap"],
colorScheme: "light",
};
BaseLayout changes#
resolvedTheme: ResolvedTheme becomes a required prop. Removes:
- Module-level
ROOT_CSSconstant andneobrutal-lightimport - Hardcoded Google Fonts
<link>tags (moved toFALLBACK_THEME.fontUrls)
Adds:
<meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" />- Dynamic
:root { ... }<style>block fromtokensToCss(resolvedTheme.tokens) resolvedTheme.fontUrlsrendered as<link rel="stylesheet">tags (with<link rel="preconnect">to googleapis if any font URLs are present)resolvedTheme.cssOverridesrendered as an additional<style>block if non-null
Files Changed#
| File | Change |
|---|---|
apps/appview/src/routes/themes.ts |
Add cid to serializeThemeFull |
apps/web/src/lib/theme-resolution.ts |
New — ResolvedTheme, WebAppEnv, FALLBACK_THEME, detectColorScheme, parseRkeyFromUri, resolveTheme |
apps/web/src/middleware/theme.ts |
New — Hono middleware wrapping resolveTheme |
apps/web/src/routes/index.ts |
Add WebAppEnv type + createThemeMiddleware before routes |
apps/web/src/layouts/base.tsx |
Dynamic theme prop, Accept-CH, font URLs, cssOverrides |
apps/web/src/routes/home.tsx |
new Hono<WebAppEnv>(), pass theme to BaseLayout |
apps/web/src/routes/boards.tsx |
Same |
apps/web/src/routes/topics.tsx |
Same |
apps/web/src/routes/login.tsx |
Same |
apps/web/src/routes/new-topic.tsx |
Same |
apps/web/src/routes/admin.tsx |
Same |
apps/web/src/routes/not-found.tsx |
Same |
apps/web/src/routes/auth.ts |
new Hono<WebAppEnv>() only (no HTML rendering) |
apps/web/src/routes/mod.ts |
Same |
Tests#
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;detectColorSchemepriority orderapps/web/src/layouts/__tests__/base.test.tsx: update to passresolvedThemeprop; verifyAccept-CHmeta; verifycssOverridesblock renders when presentapps/appview/src/routes/__tests__/themes.test.ts: assertcidfield inGET /api/themes/:rkeyresponse