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