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 root/atb-54-add-lightdark-mode-toggle 189 lines 7.1 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 matchingTheme = policy.availableThemes.find((t) => t.uri === defaultUri); 127 if (!matchingTheme) { 128 logger.warn("Theme URI not in availableThemes — skipping CID check", { 129 operation: "resolveTheme", 130 themeUri: defaultUri, 131 }); 132 } 133 // cid may be absent for live refs (e.g. canonical atbb.space presets) — that is expected 134 const expectedCid = matchingTheme?.cid ?? null; 135 136 // ── Step 4: Fetch theme ──────────────────────────────────────────────────── 137 let themeRes: Response; 138 try { 139 themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); 140 if (!themeRes.ok) { 141 logger.warn("Theme fetch returned non-ok status — using fallback", { 142 operation: "resolveTheme", 143 status: themeRes.status, 144 rkey, 145 themeUri: defaultUri, 146 }); 147 return { ...FALLBACK_THEME, colorScheme }; 148 } 149 } catch (error) { 150 if (isProgrammingError(error)) throw error; 151 logger.error("Theme fetch failed — using fallback", { 152 operation: "resolveTheme", 153 rkey, 154 error: error instanceof Error ? error.message : String(error), 155 }); 156 return { ...FALLBACK_THEME, colorScheme }; 157 } 158 159 // ── Step 5: Parse theme JSON ─────────────────────────────────────────────── 160 let theme: ThemeResponse; 161 try { 162 theme = (await themeRes.json()) as ThemeResponse; 163 } catch { 164 logger.error("Theme response contained invalid JSON — using fallback", { 165 operation: "resolveTheme", 166 rkey, 167 themeUri: defaultUri, 168 }); 169 return { ...FALLBACK_THEME, colorScheme }; 170 } 171 172 // ── Step 6: CID integrity check ──────────────────────────────────────────── 173 if (expectedCid && theme.cid !== expectedCid) { 174 logger.warn("Theme CID mismatch — using hardcoded fallback", { 175 operation: "resolveTheme", 176 expectedCid, 177 actualCid: theme.cid, 178 themeUri: defaultUri, 179 }); 180 return { ...FALLBACK_THEME, colorScheme }; 181 } 182 183 return { 184 tokens: theme.tokens as Record<string, string>, 185 cssOverrides: theme.cssOverrides ?? null, 186 fontUrls: theme.fontUrls ?? null, 187 colorScheme, 188 }; 189}