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
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}