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
at atb-53-theme-resolution 188 lines 7.0 kB view raw
1import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 2import { isProgrammingError } from "./errors.js"; 3import { logger } from "./logger.js"; 4 5export type ResolvedTheme = { 6 tokens: Record<string, string>; 7 cssOverrides: string | null; 8 fontUrls: string[] | null; 9 colorScheme: "light" | "dark"; 10}; 11 12/** Hono app environment type — used by middleware and all route factories. */ 13export type WebAppEnv = { 14 Variables: { theme: ResolvedTheme }; 15}; 16 17/** Hardcoded fallback used when theme policy is missing or resolution fails. */ 18export const FALLBACK_THEME: ResolvedTheme = Object.freeze({ 19 tokens: neobrutalLight as Record<string, string>, 20 cssOverrides: null, 21 fontUrls: Object.freeze([ 22 "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", 23 ]), 24 colorScheme: "light", 25} as const) as ResolvedTheme; 26 27/** 28 * Detects the user's preferred color scheme. 29 * Priority: atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light". 30 */ 31export function detectColorScheme( 32 cookieHeader: string | undefined, 33 hint: string | undefined 34): "light" | "dark" { 35 const match = cookieHeader?.match(/(?:^|;\s*)atbb-color-scheme=(light|dark)/); 36 if (match) return match[1] as "light" | "dark"; 37 if (hint === "dark") return "dark"; 38 return "light"; 39} 40 41/** 42 * Extracts the rkey segment from an AT URI. 43 * Example: "at://did:plc:abc/space.atbb.forum.theme/rkey123" → "rkey123" 44 */ 45export function parseRkeyFromUri(atUri: string): string | null { 46 // Format: at://<did>/<collection>/<rkey> 47 // Split gives: ["at:", "", "<did>", "<collection>", "<rkey>"] 48 const parts = atUri.split("/"); 49 if (parts.length < 5) return null; 50 return parts[4] ?? null; 51} 52 53interface ThemePolicyResponse { 54 defaultLightThemeUri: string | null; 55 defaultDarkThemeUri: string | null; 56 allowUserChoice: boolean; 57 availableThemes: Array<{ uri: string; cid: string }>; 58} 59 60interface ThemeResponse { 61 cid: string; 62 tokens: Record<string, unknown>; 63 cssOverrides: string | null; 64 fontUrls: string[] | null; 65} 66 67/** 68 * Resolves which theme to render for a request using the waterfall: 69 * 1. User preference — not yet implemented (TODO: Theme Phase 4) 70 * 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint 71 * 3. Forum default — fetched from GET /api/theme-policy 72 * 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light) 73 * 74 * Never throws — always returns a usable theme. 75 */ 76export async function resolveTheme( 77 appviewUrl: string, 78 cookieHeader: string | undefined, 79 colorSchemeHint: string | undefined 80): Promise<ResolvedTheme> { 81 const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); 82 // TODO: user preference (Theme Phase 4) 83 84 // ── Step 1: Fetch theme policy ───────────────────────────────────────────── 85 let policyRes: Response; 86 try { 87 policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 88 if (!policyRes.ok) { 89 logger.warn("Theme policy fetch returned non-ok status — using fallback", { 90 operation: "resolveTheme", 91 status: policyRes.status, 92 url: `${appviewUrl}/api/theme-policy`, 93 }); 94 return { ...FALLBACK_THEME, colorScheme }; 95 } 96 } catch (error) { 97 if (isProgrammingError(error)) throw error; 98 logger.error("Theme policy fetch failed — using fallback", { 99 operation: "resolveTheme", 100 error: error instanceof Error ? error.message : String(error), 101 }); 102 return { ...FALLBACK_THEME, colorScheme }; 103 } 104 105 // ── Step 2: Parse policy JSON ────────────────────────────────────────────── 106 let policy: ThemePolicyResponse; 107 try { 108 policy = (await policyRes.json()) as ThemePolicyResponse; 109 } catch { 110 // SyntaxError from Response.json() is a data error, not a code bug — do not re-throw 111 logger.error("Theme policy response contained invalid JSON — using fallback", { 112 operation: "resolveTheme", 113 url: `${appviewUrl}/api/theme-policy`, 114 }); 115 return { ...FALLBACK_THEME, colorScheme }; 116 } 117 118 // ── Step 3: Extract default theme URI and rkey ───────────────────────────── 119 const defaultUri = 120 colorScheme === "dark" ? policy.defaultDarkThemeUri : policy.defaultLightThemeUri; 121 if (!defaultUri) return { ...FALLBACK_THEME, colorScheme }; 122 123 const rkey = parseRkeyFromUri(defaultUri); 124 if (!rkey || !/^[a-z0-9]+$/i.test(rkey)) return { ...FALLBACK_THEME, colorScheme }; 125 126 const expectedCid = 127 policy.availableThemes.find((t: { uri: string; cid: string }) => t.uri === defaultUri)?.cid ?? null; 128 if (expectedCid === null) { 129 logger.warn("Theme URI not in availableThemes — skipping CID check", { 130 operation: "resolveTheme", 131 themeUri: defaultUri, 132 }); 133 } 134 135 // ── Step 4: Fetch theme ──────────────────────────────────────────────────── 136 let themeRes: Response; 137 try { 138 themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); 139 if (!themeRes.ok) { 140 logger.warn("Theme fetch returned non-ok status — using fallback", { 141 operation: "resolveTheme", 142 status: themeRes.status, 143 rkey, 144 themeUri: defaultUri, 145 }); 146 return { ...FALLBACK_THEME, colorScheme }; 147 } 148 } catch (error) { 149 if (isProgrammingError(error)) throw error; 150 logger.error("Theme fetch failed — using fallback", { 151 operation: "resolveTheme", 152 rkey, 153 error: error instanceof Error ? error.message : String(error), 154 }); 155 return { ...FALLBACK_THEME, colorScheme }; 156 } 157 158 // ── Step 5: Parse theme JSON ─────────────────────────────────────────────── 159 let theme: ThemeResponse; 160 try { 161 theme = (await themeRes.json()) as ThemeResponse; 162 } catch { 163 logger.error("Theme response contained invalid JSON — using fallback", { 164 operation: "resolveTheme", 165 rkey, 166 themeUri: defaultUri, 167 }); 168 return { ...FALLBACK_THEME, colorScheme }; 169 } 170 171 // ── Step 6: CID integrity check ──────────────────────────────────────────── 172 if (expectedCid && theme.cid !== expectedCid) { 173 logger.warn("Theme CID mismatch — using hardcoded fallback", { 174 operation: "resolveTheme", 175 expectedCid, 176 actualCid: theme.cid, 177 themeUri: defaultUri, 178 }); 179 return { ...FALLBACK_THEME, colorScheme }; 180 } 181 182 return { 183 tokens: theme.tokens as Record<string, string>, 184 cssOverrides: theme.cssOverrides ?? null, 185 fontUrls: theme.fontUrls ?? null, 186 colorScheme, 187 }; 188}