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

feat(web+appview): theme resolution and server-side token injection (ATB-53) (#91)

* docs: add design doc for ATB-59 admin theme token editor

Covers file structure (extract to admin-themes.tsx), editor page layout,
HTMX preview endpoint, save/reset flows, error handling, and test plan.

* docs: add implementation plan for ATB-59 theme token editor

Covers extract-to-admin-themes.tsx, TDD for GET /admin/themes/:rkey,
preview endpoint, save, and reset-to-preset handlers.

* docs: add design doc for ATB-53 theme resolution and server-side token injection

* docs: add implementation plan for ATB-53 theme resolution and server-side token injection

* feat(appview): include cid in GET /api/themes/:rkey response (ATB-53)

* feat(web): add ResolvedTheme types, FALLBACK_THEME, and color scheme helpers (ATB-53)

* feat(web): implement resolveTheme waterfall with CID integrity check (ATB-53)

* test(web): add missing resolveTheme branch test for malformed theme URI (ATB-53)

* fix(web): re-throw programming errors in resolveTheme catch block (ATB-53)

* feat(web): add createThemeMiddleware Hono middleware (ATB-53)

* feat(web): register createThemeMiddleware on webRoutes (ATB-53)

* feat(web): BaseLayout accepts resolvedTheme prop, adds Accept-CH meta (ATB-53)

Update BaseLayout to take a required resolvedTheme prop that drives dynamic :root CSS token injection, font URL rendering, and optional cssOverrides. Remove hardcoded neobrutal-light preset import and static ROOT_CSS constant. Add Accept-CH meta tag for color scheme client hint. Update all route factories to read theme from context (falling back to FALLBACK_THEME when middleware is absent, e.g. in tests).

* fix(web): sanitize cssOverrides before injection, add null branch tests (ATB-53)

* feat(web): type auth and mod route factories with WebAppEnv (ATB-53)

* docs: move ATB-53 plan docs to complete/

* docs(bruno): update GET /api/themes/:rkey to document cid field (ATB-53)

* feat(web): thread resolvedTheme through admin-themes route factory (ATB-53)

* fix(web): address PR review — sanitize tokens, split try blocks, add logs, rkey validation (ATB-53)

- Change `import { WebAppEnv }` to `import type` in routes/index.ts (type-only import)
- Freeze FALLBACK_THEME and its fontUrls array to prevent mutation across callers
- Split single giant try block in resolveTheme into 6 focused blocks (policy fetch, policy parse, URI/rkey extraction, theme fetch, theme parse, CID check) with per-operation error messages
- Add rkey validation against /^[a-z0-9]+$/i before using in fetch URL (path traversal prevention)
- Log warning when theme URI is absent from availableThemes (CID check bypassed)
- Log warn with status+url on non-ok policy/theme responses instead of silent fallback
- SyntaxError from Response.json() is now caught as a data error and not re-thrown
- Fix detectColorScheme cookie regex to use (?:^|;\s*) prefix anchor (prevents x-atbb-color-scheme=dark from matching)
- Wrap :root token block in sanitizeCss() in base.tsx
- Filter fontUrls to https:// only before rendering link tags in base.tsx
- Add try-catch error boundary in createThemeMiddleware so unexpected throws use FALLBACK_THEME
- Add tests: invalid JSON in policy/theme responses, CID bypass warning, invalid rkey, cookie regex prefix fix, middleware error boundary, non-https font URL filtering

authored by

Malpercio and committed by
GitHub
6cbe2a4d a7fd628b

+3924 -77
+1
apps/appview/src/routes/__tests__/themes.test.ts
··· 189 189 expect(body.fontUrls).toEqual(["https://fonts.googleapis.com/css2?family=Space+Grotesk"]); 190 190 expect(body.uri).toContain("/space.atbb.forum.theme/3lblfulltest"); 191 191 expect(body.indexedAt).toBeDefined(); 192 + expect(body.cid).toBe("bafyfull"); 192 193 }); 193 194 194 195 it("returns null for optional fields when not set", async () => {
+1
apps/appview/src/routes/themes.ts
··· 22 22 return { 23 23 id: serializeBigInt(theme.id), 24 24 uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 25 + cid: theme.cid, 25 26 name: theme.name, 26 27 colorScheme: theme.colorScheme, 27 28 tokens: theme.tokens,
+97 -7
apps/web/src/layouts/__tests__/base.test.tsx
··· 2 2 import { Hono } from "hono"; 3 3 import { BaseLayout } from "../base.js"; 4 4 import type { WebSession } from "../../lib/session.js"; 5 + import { FALLBACK_THEME } from "../../lib/theme-resolution.js"; 5 6 6 7 const app = new Hono().get("/", (c) => 7 - c.html(<BaseLayout title="Test Page">Page content</BaseLayout>) 8 + c.html( 9 + <BaseLayout title="Test Page" resolvedTheme={FALLBACK_THEME}> 10 + Page content 11 + </BaseLayout> 12 + ) 8 13 ); 9 14 10 15 describe("BaseLayout", () => { ··· 46 51 47 52 it("falls back to default title when none provided", async () => { 48 53 const defaultApp = new Hono().get("/", (c) => 49 - c.html(<BaseLayout>content</BaseLayout>) 54 + c.html( 55 + <BaseLayout resolvedTheme={FALLBACK_THEME}>content</BaseLayout> 56 + ) 50 57 ); 51 58 const res = await defaultApp.request("/"); 52 59 const html = await res.text(); ··· 66 73 expect(html).toContain('class="site-header__title"'); 67 74 }); 68 75 76 + it("includes Accept-CH meta tag for color scheme hint", async () => { 77 + const res = await app.request("/"); 78 + const html = await res.text(); 79 + expect(html).toContain('http-equiv="Accept-CH"'); 80 + expect(html).toContain('content="Sec-CH-Prefers-Color-Scheme"'); 81 + }); 82 + 83 + it("renders cssOverrides in a style tag when non-null", async () => { 84 + const themeWithOverrides = { 85 + ...FALLBACK_THEME, 86 + cssOverrides: ".card { border: 2px solid black; }", 87 + }; 88 + const overridesApp = new Hono().get("/", (c) => 89 + c.html( 90 + <BaseLayout resolvedTheme={themeWithOverrides}>content</BaseLayout> 91 + ) 92 + ); 93 + const res = await overridesApp.request("/"); 94 + const html = await res.text(); 95 + expect(html).toContain(".card { border: 2px solid black; }"); 96 + }); 97 + 98 + it("does not render Google Fonts preconnect tags when fontUrls is null", async () => { 99 + const themeNoFonts = { ...FALLBACK_THEME, fontUrls: null }; 100 + const noFontsApp = new Hono().get("/", (c) => 101 + c.html( 102 + <BaseLayout resolvedTheme={themeNoFonts}>content</BaseLayout> 103 + ) 104 + ); 105 + const res = await noFontsApp.request("/"); 106 + const html = await res.text(); 107 + expect(html).not.toContain("fonts.googleapis.com"); 108 + }); 109 + 110 + it("filters out non-https font URLs and does not render them", async () => { 111 + const themeWithUnsafeFontUrl = { 112 + ...FALLBACK_THEME, 113 + fontUrls: ["http://evil.com/style.css", "https://fonts.example.com/safe.css"], 114 + }; 115 + const unsafeFontApp = new Hono().get("/", (c) => 116 + c.html( 117 + <BaseLayout resolvedTheme={themeWithUnsafeFontUrl}>content</BaseLayout> 118 + ) 119 + ); 120 + const res = await unsafeFontApp.request("/"); 121 + const html = await res.text(); 122 + expect(html).not.toContain("http://evil.com/style.css"); 123 + expect(html).toContain("https://fonts.example.com/safe.css"); 124 + }); 125 + 126 + it("does not render cssOverrides style tag when cssOverrides is null", async () => { 127 + const themeNoOverrides = { ...FALLBACK_THEME, cssOverrides: null }; 128 + const noOverridesApp = new Hono().get("/", (c) => 129 + c.html( 130 + <BaseLayout resolvedTheme={themeNoOverrides}>content</BaseLayout> 131 + ) 132 + ); 133 + const res = await noOverridesApp.request("/"); 134 + const html = await res.text(); 135 + // The only <style> tag should be the :root block — no second style tag for overrides 136 + const styleTagMatches = html.match(/<style/g); 137 + expect(styleTagMatches).toHaveLength(1); 138 + expect(html).toContain(":root {"); 139 + }); 140 + 69 141 describe("auth-aware navigation", () => { 70 142 it("shows Log in link when auth is not provided (default unauthenticated)", async () => { 71 143 const unauthApp = new Hono().get("/", (c) => 72 - c.html(<BaseLayout>content</BaseLayout>) 144 + c.html( 145 + <BaseLayout resolvedTheme={FALLBACK_THEME}>content</BaseLayout> 146 + ) 73 147 ); 74 148 const res = await unauthApp.request("/"); 75 149 const html = await res.text(); ··· 80 154 it("shows Log in link when auth is explicitly unauthenticated", async () => { 81 155 const auth: WebSession = { authenticated: false }; 82 156 const unauthApp = new Hono().get("/", (c) => 83 - c.html(<BaseLayout auth={auth}>content</BaseLayout>) 157 + c.html( 158 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 159 + content 160 + </BaseLayout> 161 + ) 84 162 ); 85 163 const res = await unauthApp.request("/"); 86 164 const html = await res.text(); ··· 96 174 handle: "alice.bsky.social", 97 175 }; 98 176 const authApp = new Hono().get("/", (c) => 99 - c.html(<BaseLayout auth={auth}>content</BaseLayout>) 177 + c.html( 178 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 179 + content 180 + </BaseLayout> 181 + ) 100 182 ); 101 183 const res = await authApp.request("/"); 102 184 const html = await res.text(); ··· 112 194 handle: "alice.bsky.social", 113 195 }; 114 196 const authApp = new Hono().get("/", (c) => 115 - c.html(<BaseLayout auth={auth}>content</BaseLayout>) 197 + c.html( 198 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 199 + content 200 + </BaseLayout> 201 + ) 116 202 ); 117 203 const res = await authApp.request("/"); 118 204 const html = await res.text(); ··· 199 285 handle: "alice.bsky.social", 200 286 }; 201 287 const authApp = new Hono().get("/", (c) => 202 - c.html(<BaseLayout auth={auth}>content</BaseLayout>) 288 + c.html( 289 + <BaseLayout auth={auth} resolvedTheme={FALLBACK_THEME}> 290 + content 291 + </BaseLayout> 292 + ) 203 293 ); 204 294 const res = await authApp.request("/"); 205 295 const html = await res.text();
+36 -9
apps/web/src/layouts/base.tsx
··· 1 1 import type { FC, PropsWithChildren } from "hono/jsx"; 2 2 import { tokensToCss } from "../lib/theme.js"; 3 - import neobrutalLight from "../styles/presets/neobrutal-light.json"; 3 + import type { ResolvedTheme } from "../lib/theme-resolution.js"; 4 4 import type { WebSession } from "../lib/session.js"; 5 5 6 - const ROOT_CSS = `:root { ${tokensToCss(neobrutalLight as Record<string, string>)} }`; 6 + function sanitizeCss(css: string): string { 7 + return css.replace(/<\/style/gi, ""); 8 + } 7 9 8 10 const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => ( 9 11 <> ··· 25 27 ); 26 28 27 29 export const BaseLayout: FC< 28 - PropsWithChildren<{ title?: string; auth?: WebSession }> 30 + PropsWithChildren<{ 31 + title?: string; 32 + auth?: WebSession; 33 + resolvedTheme: ResolvedTheme; 34 + }> 29 35 > = (props) => { 30 - const { auth } = props; 36 + const { auth, resolvedTheme } = props; 31 37 return ( 32 38 <html lang="en"> 33 39 <head> 34 40 <meta charset="UTF-8" /> 35 41 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 42 + <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" /> 36 43 <title>{props.title ?? "atBB Forum"}</title> 37 - <style>{ROOT_CSS}</style> 38 - <link rel="preconnect" href="https://fonts.googleapis.com" /> 39 - <link 40 - rel="stylesheet" 41 - href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap" 44 + <style 45 + dangerouslySetInnerHTML={{ 46 + __html: sanitizeCss(`:root { ${tokensToCss(resolvedTheme.tokens)} }`), 47 + }} 42 48 /> 49 + {resolvedTheme.cssOverrides && ( 50 + <style 51 + dangerouslySetInnerHTML={{ __html: sanitizeCss(resolvedTheme.cssOverrides) }} 52 + /> 53 + )} 54 + {resolvedTheme.fontUrls && resolvedTheme.fontUrls.length > 0 && (() => { 55 + const safeFontUrls = resolvedTheme.fontUrls!.filter((url) => url.startsWith("https://")); 56 + return safeFontUrls.length > 0 ? ( 57 + <> 58 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 59 + <link 60 + rel="preconnect" 61 + href="https://fonts.gstatic.com" 62 + crossorigin="anonymous" 63 + /> 64 + {safeFontUrls.map((url) => ( 65 + <link rel="stylesheet" href={url} /> 66 + ))} 67 + </> 68 + ) : null; 69 + })()} 43 70 <link rel="stylesheet" href="/static/css/reset.css" /> 44 71 <link rel="stylesheet" href="/static/css/theme.css" /> 45 72 <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" />
+320
apps/web/src/lib/__tests__/theme-resolution.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + import { 3 + detectColorScheme, 4 + parseRkeyFromUri, 5 + FALLBACK_THEME, 6 + resolveTheme, 7 + } from "../theme-resolution.js"; 8 + import { logger } from "../logger.js"; 9 + 10 + vi.mock("../logger.js", () => ({ 11 + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() }, 12 + })); 13 + 14 + describe("detectColorScheme", () => { 15 + it("returns 'light' by default when no cookie or hint", () => { 16 + expect(detectColorScheme(undefined, undefined)).toBe("light"); 17 + }); 18 + 19 + it("reads atbb-color-scheme=dark from cookie", () => { 20 + expect(detectColorScheme("atbb-color-scheme=dark; other=1", undefined)).toBe("dark"); 21 + }); 22 + 23 + it("reads atbb-color-scheme=light from cookie", () => { 24 + expect(detectColorScheme("atbb-color-scheme=light", undefined)).toBe("light"); 25 + }); 26 + 27 + it("prefers cookie over client hint", () => { 28 + expect(detectColorScheme("atbb-color-scheme=light", "dark")).toBe("light"); 29 + }); 30 + 31 + it("falls back to client hint when no cookie", () => { 32 + expect(detectColorScheme(undefined, "dark")).toBe("dark"); 33 + }); 34 + 35 + it("ignores unrecognized hint values and returns 'light'", () => { 36 + expect(detectColorScheme(undefined, "no-preference")).toBe("light"); 37 + }); 38 + 39 + it("does not match x-atbb-color-scheme=dark as a cookie prefix", () => { 40 + // Before the regex fix, 'x-atbb-color-scheme=dark' would have matched. 41 + // The (?:^|;\s*) anchor ensures only cookie-boundary matches are accepted. 42 + expect(detectColorScheme("x-atbb-color-scheme=dark", undefined)).toBe("light"); 43 + }); 44 + }); 45 + 46 + describe("parseRkeyFromUri", () => { 47 + it("extracts rkey from valid AT URI", () => { 48 + expect( 49 + parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme/3lblthemeabc") 50 + ).toBe("3lblthemeabc"); 51 + }); 52 + 53 + it("returns null for URI with no rkey segment", () => { 54 + expect(parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme")).toBeNull(); 55 + }); 56 + 57 + it("returns null for malformed URI", () => { 58 + expect(parseRkeyFromUri("not-a-uri")).toBeNull(); 59 + }); 60 + 61 + it("returns null for empty string", () => { 62 + expect(parseRkeyFromUri("")).toBeNull(); 63 + }); 64 + }); 65 + 66 + describe("FALLBACK_THEME", () => { 67 + it("uses neobrutal-light tokens", () => { 68 + expect(FALLBACK_THEME.tokens["color-bg"]).toBe("#f5f0e8"); 69 + expect(FALLBACK_THEME.tokens["color-primary"]).toBe("#ff5c00"); 70 + }); 71 + 72 + it("has light colorScheme", () => { 73 + expect(FALLBACK_THEME.colorScheme).toBe("light"); 74 + }); 75 + 76 + it("includes Google Fonts URL for Space Grotesk", () => { 77 + expect(FALLBACK_THEME.fontUrls).toEqual( 78 + expect.arrayContaining([expect.stringContaining("Space+Grotesk")]) 79 + ); 80 + }); 81 + 82 + it("has null cssOverrides", () => { 83 + expect(FALLBACK_THEME.cssOverrides).toBeNull(); 84 + }); 85 + }); 86 + 87 + describe("resolveTheme", () => { 88 + const mockFetch = vi.fn(); 89 + const mockLogger = vi.mocked(logger); 90 + const APPVIEW = "http://localhost:3001"; 91 + 92 + beforeEach(() => { 93 + vi.stubGlobal("fetch", mockFetch); 94 + mockLogger.warn.mockClear(); 95 + mockLogger.error.mockClear(); 96 + }); 97 + 98 + afterEach(() => { 99 + mockFetch.mockReset(); 100 + vi.unstubAllGlobals(); 101 + }); 102 + 103 + function policyResponse(overrides: object = {}) { 104 + return { 105 + ok: true, 106 + json: () => 107 + Promise.resolve({ 108 + defaultLightThemeUri: 109 + "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 110 + defaultDarkThemeUri: 111 + "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 112 + allowUserChoice: true, 113 + availableThemes: [ 114 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", cid: "bafylight" }, 115 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", cid: "bafydark" }, 116 + ], 117 + ...overrides, 118 + }), 119 + }; 120 + } 121 + 122 + function themeResponse(colorScheme: "light" | "dark", cid: string) { 123 + return { 124 + ok: true, 125 + json: () => 126 + Promise.resolve({ 127 + cid, 128 + tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" }, 129 + cssOverrides: null, 130 + fontUrls: null, 131 + colorScheme, 132 + }), 133 + }; 134 + } 135 + 136 + it("returns FALLBACK_THEME with detected colorScheme when policy fetch fails (non-ok)", async () => { 137 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 138 + const result = await resolveTheme(APPVIEW, undefined, undefined); 139 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 140 + expect(result.colorScheme).toBe("light"); 141 + expect(mockLogger.warn).toHaveBeenCalledWith( 142 + expect.stringContaining("non-ok status"), 143 + expect.objectContaining({ operation: "resolveTheme", status: 404 }) 144 + ); 145 + }); 146 + 147 + it("returns FALLBACK_THEME with dark colorScheme when policy fails and dark cookie set", async () => { 148 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); 149 + const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 150 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 151 + expect(result.colorScheme).toBe("dark"); 152 + expect(mockLogger.warn).toHaveBeenCalledWith( 153 + expect.stringContaining("non-ok status"), 154 + expect.any(Object) 155 + ); 156 + }); 157 + 158 + it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => { 159 + mockFetch.mockResolvedValueOnce(policyResponse({ defaultLightThemeUri: null })); 160 + const result = await resolveTheme(APPVIEW, undefined, undefined); 161 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 162 + }); 163 + 164 + it("returns FALLBACK_THEME when defaultLightThemeUri is malformed (parseRkeyFromUri returns null)", async () => { 165 + mockFetch.mockResolvedValueOnce( 166 + policyResponse({ defaultLightThemeUri: "malformed-uri" }) 167 + ); 168 + const result = await resolveTheme(APPVIEW, undefined, undefined); 169 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 170 + // Only one fetch should happen (policy only — no theme fetch) 171 + expect(mockFetch).toHaveBeenCalledTimes(1); 172 + }); 173 + 174 + it("returns FALLBACK_THEME when theme fetch fails (non-ok)", async () => { 175 + mockFetch 176 + .mockResolvedValueOnce(policyResponse()) 177 + .mockResolvedValueOnce({ ok: false, status: 404 }); 178 + const result = await resolveTheme(APPVIEW, undefined, undefined); 179 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 180 + expect(mockLogger.warn).toHaveBeenCalledWith( 181 + expect.stringContaining("non-ok status"), 182 + expect.objectContaining({ operation: "resolveTheme", status: 404 }) 183 + ); 184 + }); 185 + 186 + it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => { 187 + mockFetch 188 + .mockResolvedValueOnce(policyResponse()) 189 + .mockResolvedValueOnce(themeResponse("light", "WRONG_CID")); 190 + const result = await resolveTheme(APPVIEW, undefined, undefined); 191 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 192 + expect(logger.warn).toHaveBeenCalledWith( 193 + expect.stringContaining("CID mismatch"), 194 + expect.objectContaining({ expectedCid: "bafylight", actualCid: "WRONG_CID" }) 195 + ); 196 + }); 197 + 198 + it("resolves the light theme on happy path (no cookie)", async () => { 199 + mockFetch 200 + .mockResolvedValueOnce(policyResponse()) 201 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 202 + const result = await resolveTheme(APPVIEW, undefined, undefined); 203 + expect(result.tokens["color-bg"]).toBe("#fff"); 204 + expect(result.colorScheme).toBe("light"); 205 + expect(result.cssOverrides).toBeNull(); 206 + expect(result.fontUrls).toBeNull(); 207 + }); 208 + 209 + it("resolves the dark theme when atbb-color-scheme=dark cookie is set", async () => { 210 + mockFetch 211 + .mockResolvedValueOnce(policyResponse()) 212 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 213 + const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 214 + expect(result.tokens["color-bg"]).toBe("#111"); 215 + expect(result.colorScheme).toBe("dark"); 216 + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining("3lbldark")); 217 + }); 218 + 219 + it("resolves dark theme from Sec-CH-Prefers-Color-Scheme hint when no cookie", async () => { 220 + mockFetch 221 + .mockResolvedValueOnce(policyResponse()) 222 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 223 + const result = await resolveTheme(APPVIEW, undefined, "dark"); 224 + expect(result.colorScheme).toBe("dark"); 225 + }); 226 + 227 + it("returns FALLBACK_THEME and logs error on network exception", async () => { 228 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 229 + const result = await resolveTheme(APPVIEW, undefined, undefined); 230 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 231 + expect(logger.error).toHaveBeenCalledWith( 232 + expect.stringContaining("Theme policy fetch failed"), 233 + expect.objectContaining({ operation: "resolveTheme" }) 234 + ); 235 + }); 236 + 237 + it("re-throws programming errors (TypeError) rather than swallowing them", async () => { 238 + // A TypeError from a bug in the code should propagate, not be silently logged. 239 + // This TypeError comes from the fetch() mock itself (not from .json()), so it 240 + // is caught by the policy-fetch try block and re-thrown as a programming error. 241 + mockFetch.mockImplementationOnce(() => { 242 + throw new TypeError("Cannot read properties of null"); 243 + }); 244 + await expect(resolveTheme(APPVIEW, undefined, undefined)).rejects.toThrow(TypeError); 245 + }); 246 + 247 + it("passes cssOverrides and fontUrls through from theme response", async () => { 248 + mockFetch 249 + .mockResolvedValueOnce(policyResponse()) 250 + .mockResolvedValueOnce({ 251 + ok: true, 252 + json: () => 253 + Promise.resolve({ 254 + cid: "bafylight", 255 + tokens: { "color-bg": "#fff" }, 256 + cssOverrides: ".btn { font-weight: 700; }", 257 + fontUrls: ["https://fonts.example.com/font.css"], 258 + colorScheme: "light", 259 + }), 260 + }); 261 + const result = await resolveTheme(APPVIEW, undefined, undefined); 262 + expect(result.cssOverrides).toBe(".btn { font-weight: 700; }"); 263 + expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]); 264 + }); 265 + 266 + it("returns FALLBACK_THEME when policy response contains invalid JSON", async () => { 267 + mockFetch.mockResolvedValueOnce({ 268 + ok: true, 269 + json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")), 270 + }); 271 + const result = await resolveTheme(APPVIEW, undefined, undefined); 272 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 273 + expect(mockLogger.error).toHaveBeenCalledWith( 274 + expect.stringContaining("invalid JSON"), 275 + expect.objectContaining({ operation: "resolveTheme" }) 276 + ); 277 + }); 278 + 279 + it("returns FALLBACK_THEME when theme response contains invalid JSON", async () => { 280 + mockFetch 281 + .mockResolvedValueOnce(policyResponse()) 282 + .mockResolvedValueOnce({ 283 + ok: true, 284 + json: () => Promise.reject(new SyntaxError("Unexpected token < in JSON")), 285 + }); 286 + const result = await resolveTheme(APPVIEW, undefined, undefined); 287 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 288 + expect(mockLogger.error).toHaveBeenCalledWith( 289 + expect.stringContaining("invalid JSON"), 290 + expect.objectContaining({ operation: "resolveTheme" }) 291 + ); 292 + }); 293 + 294 + it("logs warning when theme URI is not in availableThemes (CID check bypassed)", async () => { 295 + mockFetch 296 + .mockResolvedValueOnce(policyResponse({ availableThemes: [] })) 297 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 298 + await resolveTheme(APPVIEW, undefined, undefined); 299 + expect(mockLogger.warn).toHaveBeenCalledWith( 300 + expect.stringContaining("not in availableThemes"), 301 + expect.objectContaining({ 302 + operation: "resolveTheme", 303 + themeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 304 + }) 305 + ); 306 + }); 307 + 308 + it("returns FALLBACK_THEME when rkey contains path traversal characters", async () => { 309 + // rkey extracted from URI would be "..%2Fsecret" after split — fails /^[a-z0-9]+$/i 310 + mockFetch.mockResolvedValueOnce( 311 + policyResponse({ 312 + defaultLightThemeUri: "at://did/col/../../secret", 313 + }) 314 + ); 315 + const result = await resolveTheme(APPVIEW, undefined, undefined); 316 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 317 + // Only the policy fetch should have been made (no theme fetch) 318 + expect(mockFetch).toHaveBeenCalledTimes(1); 319 + }); 320 + });
+188
apps/web/src/lib/theme-resolution.ts
··· 1 + import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 2 + import { isProgrammingError } from "./errors.js"; 3 + import { logger } from "./logger.js"; 4 + 5 + export 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. */ 13 + export type WebAppEnv = { 14 + Variables: { theme: ResolvedTheme }; 15 + }; 16 + 17 + /** Hardcoded fallback used when theme policy is missing or resolution fails. */ 18 + export 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 + */ 31 + export 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 + */ 45 + export 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 + 53 + interface ThemePolicyResponse { 54 + defaultLightThemeUri: string | null; 55 + defaultDarkThemeUri: string | null; 56 + allowUserChoice: boolean; 57 + availableThemes: Array<{ uri: string; cid: string }>; 58 + } 59 + 60 + interface 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 + */ 76 + export 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 + }
+146
apps/web/src/middleware/__tests__/theme.test.ts
··· 1 + import { describe, it, expect, vi, beforeEach } from "vitest"; 2 + import { Hono } from "hono"; 3 + import type { WebAppEnv, ResolvedTheme } from "../../lib/theme-resolution.js"; 4 + import { createThemeMiddleware } from "../theme.js"; 5 + 6 + vi.mock("../../lib/theme-resolution.js", () => ({ 7 + resolveTheme: vi.fn(), 8 + FALLBACK_THEME: { 9 + tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" }, 10 + cssOverrides: null, 11 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap"], 12 + colorScheme: "light", 13 + }, 14 + })); 15 + 16 + vi.mock("../../lib/logger.js", () => ({ 17 + logger: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), fatal: vi.fn() }, 18 + })); 19 + 20 + import { resolveTheme, FALLBACK_THEME } from "../../lib/theme-resolution.js"; 21 + import { logger } from "../../lib/logger.js"; 22 + 23 + const mockResolveTheme = vi.mocked(resolveTheme); 24 + const mockLogger = vi.mocked(logger); 25 + 26 + const MOCK_THEME: ResolvedTheme = { 27 + tokens: { "color-bg": "#ffffff", "color-primary": "#ff5c00" }, 28 + cssOverrides: null, 29 + fontUrls: ["https://fonts.example.com/font.css"], 30 + colorScheme: "light", 31 + }; 32 + 33 + describe("createThemeMiddleware", () => { 34 + beforeEach(() => { 35 + mockResolveTheme.mockReset(); 36 + mockResolveTheme.mockResolvedValue(MOCK_THEME); 37 + mockLogger.error.mockClear(); 38 + }); 39 + 40 + it("stores resolved theme in context for downstream handlers", async () => { 41 + let capturedTheme: ResolvedTheme | undefined; 42 + 43 + const app = new Hono<WebAppEnv>() 44 + .use("*", createThemeMiddleware("http://appview.test")) 45 + .get("/test", (c) => { 46 + capturedTheme = c.get("theme"); 47 + return c.json({ ok: true }); 48 + }); 49 + 50 + const res = await app.request("http://localhost/test"); 51 + expect(res.status).toBe(200); 52 + expect(capturedTheme).toEqual(MOCK_THEME); 53 + }); 54 + 55 + it("passes Cookie header to resolveTheme", async () => { 56 + const app = new Hono<WebAppEnv>() 57 + .use("*", createThemeMiddleware("http://appview.test")) 58 + .get("/test", (c) => c.json({ ok: true })); 59 + 60 + await app.request("http://localhost/test", { 61 + headers: { Cookie: "atbb-color-scheme=dark; session=abc" }, 62 + }); 63 + 64 + expect(mockResolveTheme).toHaveBeenCalledWith( 65 + expect.any(String), 66 + "atbb-color-scheme=dark; session=abc", 67 + undefined 68 + ); 69 + }); 70 + 71 + it("passes Sec-CH-Prefers-Color-Scheme header to resolveTheme", async () => { 72 + const app = new Hono<WebAppEnv>() 73 + .use("*", createThemeMiddleware("http://appview.test")) 74 + .get("/test", (c) => c.json({ ok: true })); 75 + 76 + await app.request("http://localhost/test", { 77 + headers: { "Sec-CH-Prefers-Color-Scheme": "dark" }, 78 + }); 79 + 80 + expect(mockResolveTheme).toHaveBeenCalledWith( 81 + expect.any(String), 82 + undefined, 83 + "dark" 84 + ); 85 + }); 86 + 87 + it("passes appviewUrl to resolveTheme", async () => { 88 + const app = new Hono<WebAppEnv>() 89 + .use("*", createThemeMiddleware("http://custom-appview.example.com")) 90 + .get("/test", (c) => c.json({ ok: true })); 91 + 92 + await app.request("http://localhost/test"); 93 + 94 + expect(mockResolveTheme).toHaveBeenCalledWith( 95 + "http://custom-appview.example.com", 96 + undefined, 97 + undefined 98 + ); 99 + }); 100 + 101 + it("calls resolveTheme with undefined when Cookie and color-scheme headers are absent", async () => { 102 + const app = new Hono<WebAppEnv>() 103 + .use("*", createThemeMiddleware("http://appview.test")) 104 + .get("/test", (c) => c.json({ ok: true })); 105 + 106 + await app.request("http://localhost/test"); 107 + 108 + expect(mockResolveTheme).toHaveBeenCalledWith( 109 + "http://appview.test", 110 + undefined, 111 + undefined 112 + ); 113 + }); 114 + 115 + it("calls next() so the downstream handler executes", async () => { 116 + const app = new Hono<WebAppEnv>() 117 + .use("*", createThemeMiddleware("http://appview.test")) 118 + .get("/test", (c) => c.json({ message: "handler ran" })); 119 + 120 + const res = await app.request("http://localhost/test"); 121 + expect(res.status).toBe(200); 122 + const body = await res.json() as { message: string }; 123 + expect(body.message).toBe("handler ran"); 124 + }); 125 + 126 + it("catches unexpected throws from resolveTheme, logs the error, and sets FALLBACK_THEME", async () => { 127 + mockResolveTheme.mockRejectedValueOnce(new TypeError("Unexpected programming error")); 128 + 129 + let capturedTheme: ResolvedTheme | undefined; 130 + const app = new Hono<WebAppEnv>() 131 + .use("*", createThemeMiddleware("http://appview.test")) 132 + .get("/test", (c) => { 133 + capturedTheme = c.get("theme"); 134 + return c.json({ ok: true }); 135 + }); 136 + 137 + // The request must NOT propagate the error — the middleware should catch it 138 + const res = await app.request("http://localhost/test"); 139 + expect(res.status).toBe(200); 140 + expect(capturedTheme).toEqual(FALLBACK_THEME); 141 + expect(mockLogger.error).toHaveBeenCalledWith( 142 + expect.stringContaining("resolveTheme threw unexpectedly"), 143 + expect.objectContaining({ operation: "createThemeMiddleware" }) 144 + ); 145 + }); 146 + });
+23
apps/web/src/middleware/theme.ts
··· 1 + import type { MiddlewareHandler } from "hono"; 2 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 3 + import { resolveTheme, FALLBACK_THEME } from "../lib/theme-resolution.js"; 4 + import { logger } from "../lib/logger.js"; 5 + 6 + export function createThemeMiddleware(appviewUrl: string): MiddlewareHandler<WebAppEnv> { 7 + return async (c, next) => { 8 + const cookieHeader = c.req.header("Cookie"); 9 + const colorSchemeHint = c.req.header("Sec-CH-Prefers-Color-Scheme"); 10 + let theme; 11 + try { 12 + theme = await resolveTheme(appviewUrl, cookieHeader, colorSchemeHint); 13 + } catch (error) { 14 + logger.error("createThemeMiddleware: resolveTheme threw unexpectedly — using fallback", { 15 + operation: "createThemeMiddleware", 16 + error: error instanceof Error ? error.message : String(error), 17 + }); 18 + theme = FALLBACK_THEME; 19 + } 20 + c.set("theme", theme); 21 + await next(); 22 + }; 23 + }
+22 -13
apps/web/src/routes/admin-themes.tsx
··· 8 8 import { isProgrammingError } from "../lib/errors.js"; 9 9 import { logger } from "../lib/logger.js"; 10 10 import { tokensToCss } from "../lib/theme.js"; 11 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 11 12 import neobrutalLight from "../styles/presets/neobrutal-light.json"; 12 13 import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 13 14 ··· 234 235 // ─── Route Factory ────────────────────────────────────────────────────────── 235 236 236 237 export function createAdminThemeRoutes(appviewUrl: string) { 237 - const app = new Hono(); 238 + const app = new Hono<WebAppEnv>(); 238 239 239 240 // ── GET /admin/themes ────────────────────────────────────────────────────── 240 241 241 242 app.get("/admin/themes", async (c) => { 243 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 242 244 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 243 245 244 246 if (!auth.authenticated) { ··· 247 249 248 250 if (!canManageThemes(auth)) { 249 251 return c.html( 250 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 252 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 251 253 <PageHeader title="Themes" /> 252 254 <p>You don&apos;t have permission to manage themes.</p> 253 255 </BaseLayout>, ··· 313 315 const darkThemes = adminThemes.filter((t) => t.colorScheme === "dark"); 314 316 315 317 return c.html( 316 - <BaseLayout title="Themes — atBB Admin" auth={auth}> 318 + <BaseLayout title="Themes — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}> 317 319 <PageHeader title="Themes" /> 318 320 319 321 {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} ··· 521 523 // ── GET /admin/themes/:rkey ──────────────────────────────────────────────── 522 524 523 525 app.get("/admin/themes/:rkey", async (c) => { 526 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 524 527 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 525 528 if (!auth.authenticated) return c.redirect("/login"); 526 529 if (!canManageThemes(auth)) { 527 530 return c.html( 528 - <BaseLayout title="Access Denied — atBB Admin" auth={auth}> 531 + <BaseLayout title="Access Denied — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}> 529 532 <PageHeader title="Access Denied" /> 530 533 <p>You don&apos;t have permission to manage themes.</p> 531 534 </BaseLayout>, ··· 544 547 const res = await fetch(`${appviewUrl}/api/themes/${themeRkey}`); 545 548 if (res.status === 404) { 546 549 return c.html( 547 - <BaseLayout title="Theme Not Found — atBB Admin" auth={auth}> 550 + <BaseLayout title="Theme Not Found — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}> 548 551 <PageHeader title="Theme Not Found" /> 549 552 <p>This theme does not exist.</p> 550 553 <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> ··· 579 582 580 583 if (!theme) { 581 584 return c.html( 582 - <BaseLayout title="Theme Unavailable — atBB Admin" auth={auth}> 585 + <BaseLayout title="Theme Unavailable — atBB Admin" auth={auth} resolvedTheme={resolvedTheme}> 583 586 <PageHeader title="Theme Unavailable" /> 584 587 <p>Unable to load theme data. Please try again.</p> 585 588 <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> ··· 597 600 const fontUrlsText = (theme.fontUrls ?? []).join("\n"); 598 601 599 602 return c.html( 600 - <BaseLayout title={`Edit Theme: ${theme.name} — atBB Admin`} auth={auth}> 603 + <BaseLayout title={`Edit Theme: ${theme.name} — atBB Admin`} auth={auth} resolvedTheme={resolvedTheme}> 601 604 <PageHeader title={`Edit Theme: ${theme.name}`} /> 602 605 603 606 {successMsg && <div class="structure-success-banner">{successMsg}</div>} ··· 750 753 // ── POST /admin/themes ──────────────────────────────────────────────────── 751 754 752 755 app.post("/admin/themes", async (c) => { 756 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 753 757 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 754 758 if (!auth.authenticated) return c.redirect("/login"); 755 759 if (!canManageThemes(auth)) { 756 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 760 + return c.html(<BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}><p>Access denied.</p></BaseLayout>, 403); 757 761 } 758 762 759 763 const cookie = c.req.header("cookie") ?? ""; ··· 815 819 // ── POST /admin/themes/:rkey/duplicate ──────────────────────────────────── 816 820 817 821 app.post("/admin/themes/:rkey/duplicate", async (c) => { 822 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 818 823 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 819 824 if (!auth.authenticated) return c.redirect("/login"); 820 825 if (!canManageThemes(auth)) { 821 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 826 + return c.html(<BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}><p>Access denied.</p></BaseLayout>, 403); 822 827 } 823 828 824 829 const cookie = c.req.header("cookie") ?? ""; ··· 854 859 // ── POST /admin/themes/:rkey/delete ────────────────────────────────────── 855 860 856 861 app.post("/admin/themes/:rkey/delete", async (c) => { 862 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 857 863 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 858 864 if (!auth.authenticated) return c.redirect("/login"); 859 865 if (!canManageThemes(auth)) { 860 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 866 + return c.html(<BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}><p>Access denied.</p></BaseLayout>, 403); 861 867 } 862 868 863 869 const cookie = c.req.header("cookie") ?? ""; ··· 899 905 // ── POST /admin/theme-policy ────────────────────────────────────────────── 900 906 901 907 app.post("/admin/theme-policy", async (c) => { 908 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 902 909 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 903 910 if (!auth.authenticated) return c.redirect("/login"); 904 911 if (!canManageThemes(auth)) { 905 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 912 + return c.html(<BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}><p>Access denied.</p></BaseLayout>, 403); 906 913 } 907 914 908 915 const cookie = c.req.header("cookie") ?? ""; ··· 1003 1010 // ── POST /admin/themes/:rkey/save ───────────────────────────────────────── 1004 1011 1005 1012 app.post("/admin/themes/:rkey/save", async (c) => { 1013 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1006 1014 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1007 1015 if (!auth.authenticated) return c.redirect("/login"); 1008 1016 if (!canManageThemes(auth)) { 1009 1017 return c.html( 1010 - <BaseLayout title="Access Denied" auth={auth}> 1018 + <BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}> 1011 1019 <p>Access denied.</p> 1012 1020 </BaseLayout>, 1013 1021 403 ··· 1087 1095 // ── POST /admin/themes/:rkey/reset-to-preset ────────────────────────────── 1088 1096 1089 1097 app.post("/admin/themes/:rkey/reset-to-preset", async (c) => { 1098 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1090 1099 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1091 1100 if (!auth.authenticated) return c.redirect("/login"); 1092 1101 if (!canManageThemes(auth)) { 1093 1102 return c.html( 1094 - <BaseLayout title="Access Denied" auth={auth}> 1103 + <BaseLayout title="Access Denied" auth={auth} resolvedTheme={resolvedTheme}> 1095 1104 <p>Access denied.</p> 1096 1105 </BaseLayout>, 1097 1106 403
+33 -22
apps/web/src/routes/admin.tsx
··· 13 13 import { isProgrammingError } from "../lib/errors.js"; 14 14 import { logger } from "../lib/logger.js"; 15 15 import { createAdminThemeRoutes } from "./admin-themes.js"; 16 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 16 17 17 18 // ─── Types ───────────────────────────────────────────────────────────────── 18 19 ··· 206 207 // ─── Routes ──────────────────────────────────────────────────────────────── 207 208 208 209 export function createAdminRoutes(appviewUrl: string) { 209 - const app = new Hono(); 210 + const app = new Hono<WebAppEnv>(); 210 211 211 212 // ─── Structure Page Components ────────────────────────────────────────── 212 213 ··· 364 365 // ── GET /admin ──────────────────────────────────────────────────────────── 365 366 366 367 app.get("/admin", async (c) => { 368 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 367 369 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 368 370 369 371 if (!auth.authenticated) { ··· 372 374 373 375 if (!hasAnyAdminPermission(auth)) { 374 376 return c.html( 375 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 377 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 376 378 <PageHeader title="Access Denied" /> 377 379 <p>You don&apos;t have permission to access the admin panel.</p> 378 380 </BaseLayout>, ··· 386 388 const showThemes = canManageThemes(auth); 387 389 388 390 return c.html( 389 - <BaseLayout title="Admin Panel — atBB Forum" auth={auth}> 391 + <BaseLayout title="Admin Panel — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 390 392 <PageHeader title="Admin Panel" /> 391 393 <div class="admin-nav-grid"> 392 394 {showMembers && ( ··· 435 437 // ── GET /admin/members ──────────────────────────────────────────────────── 436 438 437 439 app.get("/admin/members", async (c) => { 440 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 438 441 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 439 442 440 443 if (!auth.authenticated) { ··· 443 446 444 447 if (!canManageMembers(auth)) { 445 448 return c.html( 446 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 449 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 447 450 <PageHeader title="Members" /> 448 451 <p>You don&apos;t have permission to manage members.</p> 449 452 </BaseLayout>, ··· 471 474 error: error instanceof Error ? error.message : String(error), 472 475 }); 473 476 return c.html( 474 - <BaseLayout title="Members — atBB Forum" auth={auth}> 477 + <BaseLayout title="Members — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 475 478 <PageHeader title="Members" /> 476 479 <ErrorDisplay 477 480 message="Unable to load members" ··· 491 494 status: membersRes.status, 492 495 }); 493 496 return c.html( 494 - <BaseLayout title="Members — atBB Forum" auth={auth}> 497 + <BaseLayout title="Members — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 495 498 <PageHeader title="Members" /> 496 499 <ErrorDisplay 497 500 message="Something went wrong" ··· 529 532 const title = `Members (${members.length}${isTruncated ? "+" : ""})`; 530 533 531 534 return c.html( 532 - <BaseLayout title="Members — atBB Forum" auth={auth}> 535 + <BaseLayout title="Members — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 533 536 <PageHeader title={title} /> 534 537 {members.length === 0 ? ( 535 538 <EmptyState message="No members yet" /> ··· 744 747 // ── GET /admin/structure ───────────────────────────────────────────────── 745 748 746 749 app.get("/admin/structure", async (c) => { 750 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 747 751 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 748 752 749 753 if (!auth.authenticated) { ··· 752 756 753 757 if (!canManageCategories(auth)) { 754 758 return c.html( 755 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 759 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 756 760 <PageHeader title="Forum Structure" /> 757 761 <p>You don&apos;t have permission to manage forum structure.</p> 758 762 </BaseLayout>, ··· 775 779 error: error instanceof Error ? error.message : String(error), 776 780 }); 777 781 return c.html( 778 - <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 782 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 779 783 <PageHeader title="Forum Structure" /> 780 784 <ErrorDisplay 781 785 message="Unable to load forum structure" ··· 795 799 status: categoriesRes.status, 796 800 }); 797 801 return c.html( 798 - <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 802 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 799 803 <PageHeader title="Forum Structure" /> 800 804 <ErrorDisplay 801 805 message="Something went wrong" ··· 845 849 })); 846 850 847 851 return c.html( 848 - <BaseLayout title="Forum Structure — atBB Forum" auth={auth}> 852 + <BaseLayout title="Forum Structure — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 849 853 <PageHeader title="Forum Structure" /> 850 854 {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 851 855 <div class="structure-page"> ··· 882 886 // ── POST /admin/structure/categories ───────────────────────────────────── 883 887 884 888 app.post("/admin/structure/categories", async (c) => { 889 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 885 890 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 886 891 if (!auth.authenticated) return c.redirect("/login"); 887 892 if (!canManageCategories(auth)) { 888 893 return c.html( 889 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 894 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 890 895 <PageHeader title="Forum Structure" /> 891 896 <p>You don&apos;t have permission to manage forum structure.</p> 892 897 </BaseLayout>, ··· 961 966 // ── POST /admin/structure/categories/:id/edit ───────────────────────────── 962 967 963 968 app.post("/admin/structure/categories/:id/edit", async (c) => { 969 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 964 970 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 965 971 if (!auth.authenticated) return c.redirect("/login"); 966 972 if (!canManageCategories(auth)) { 967 973 return c.html( 968 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 974 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 969 975 <PageHeader title="Forum Structure" /> 970 976 <p>You don&apos;t have permission to manage forum structure.</p> 971 977 </BaseLayout>, ··· 1043 1049 // ── POST /admin/structure/categories/:id/delete ─────────────────────────── 1044 1050 1045 1051 app.post("/admin/structure/categories/:id/delete", async (c) => { 1052 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1046 1053 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1047 1054 if (!auth.authenticated) return c.redirect("/login"); 1048 1055 if (!canManageCategories(auth)) { 1049 1056 return c.html( 1050 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1057 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1051 1058 <PageHeader title="Forum Structure" /> 1052 1059 <p>You don&apos;t have permission to manage forum structure.</p> 1053 1060 </BaseLayout>, ··· 1096 1103 // ── POST /admin/structure/boards ────────────────────────────────────────── 1097 1104 1098 1105 app.post("/admin/structure/boards", async (c) => { 1106 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1099 1107 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1100 1108 if (!auth.authenticated) return c.redirect("/login"); 1101 1109 if (!canManageCategories(auth)) { 1102 1110 return c.html( 1103 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1111 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1104 1112 <PageHeader title="Forum Structure" /> 1105 1113 <p>You don&apos;t have permission to manage forum structure.</p> 1106 1114 </BaseLayout>, ··· 1183 1191 // ── POST /admin/structure/boards/:id/edit ───────────────────────────────── 1184 1192 1185 1193 app.post("/admin/structure/boards/:id/edit", async (c) => { 1194 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1186 1195 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1187 1196 if (!auth.authenticated) return c.redirect("/login"); 1188 1197 if (!canManageCategories(auth)) { 1189 1198 return c.html( 1190 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1199 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1191 1200 <PageHeader title="Forum Structure" /> 1192 1201 <p>You don&apos;t have permission to manage forum structure.</p> 1193 1202 </BaseLayout>, ··· 1265 1274 // ── POST /admin/structure/boards/:id/delete ─────────────────────────────── 1266 1275 1267 1276 app.post("/admin/structure/boards/:id/delete", async (c) => { 1277 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1268 1278 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1269 1279 if (!auth.authenticated) return c.redirect("/login"); 1270 1280 if (!canManageCategories(auth)) { 1271 1281 return c.html( 1272 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1282 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1273 1283 <PageHeader title="Forum Structure" /> 1274 1284 <p>You don&apos;t have permission to manage forum structure.</p> 1275 1285 </BaseLayout>, ··· 1318 1328 // ── GET /admin/modlog ───────────────────────────────────────────────────── 1319 1329 1320 1330 app.get("/admin/modlog", async (c) => { 1331 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 1321 1332 const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1322 1333 1323 1334 if (!auth.authenticated) { ··· 1326 1337 1327 1338 if (!canViewModLog(auth)) { 1328 1339 return c.html( 1329 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1340 + <BaseLayout title="Access Denied — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1330 1341 <PageHeader title="Mod Action Log" /> 1331 1342 <p>You don&apos;t have permission to view the mod action log.</p> 1332 1343 </BaseLayout>, ··· 1355 1366 error: error instanceof Error ? error.message : String(error), 1356 1367 }); 1357 1368 return c.html( 1358 - <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 1369 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1359 1370 <PageHeader title="Mod Action Log" /> 1360 1371 <ErrorDisplay 1361 1372 message="Unable to load mod action log" ··· 1375 1386 status: modlogRes.status, 1376 1387 }); 1377 1388 return c.html( 1378 - <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 1389 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1379 1390 <PageHeader title="Mod Action Log" /> 1380 1391 <ErrorDisplay 1381 1392 message="Something went wrong" ··· 1400 1411 operation: "GET /admin/modlog", 1401 1412 }); 1402 1413 return c.html( 1403 - <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 1414 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1404 1415 <PageHeader title="Mod Action Log" /> 1405 1416 <ErrorDisplay 1406 1417 message="Something went wrong" ··· 1418 1429 const hasNext = offset + limit < total; 1419 1430 1420 1431 return c.html( 1421 - <BaseLayout title="Mod Action Log — atBB Forum" auth={auth}> 1432 + <BaseLayout title="Mod Action Log — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 1422 1433 <PageHeader title="Mod Action Log" /> 1423 1434 {actions.length === 0 ? ( 1424 1435 <EmptyState message="No moderation actions yet" />
+2 -1
apps/web/src/routes/auth.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { logger } from "../lib/logger.js"; 3 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 3 4 4 5 /** 5 6 * POST /logout → calls AppView logout, clears cookie, redirects to / 6 7 */ 7 8 export function createAuthRoutes(appviewUrl: string) { 8 - return new Hono() 9 + return new Hono<WebAppEnv>() 9 10 /** 10 11 * POST /logout — logout should be a POST (not a link) to prevent CSRF. 11 12 *
+7 -5
apps/web/src/routes/boards.tsx
··· 6 6 import { isProgrammingError, isNetworkError, isNotFoundError } from "../lib/errors.js"; 7 7 import { timeAgo } from "../lib/time.js"; 8 8 import { logger } from "../lib/logger.js"; 9 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 9 10 10 11 // API response type shapes 11 12 ··· 135 136 // ─── Route factory ─────────────────────────────────────────────────────────── 136 137 137 138 export function createBoardsRoutes(appviewUrl: string) { 138 - return new Hono().get("/boards/:id", async (c) => { 139 + return new Hono<WebAppEnv>().get("/boards/:id", async (c) => { 140 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 139 141 const idParam = c.req.param("id"); 140 142 141 143 // Validate that the ID is an integer (parseable as BigInt) ··· 145 147 return c.html("", 200); 146 148 } 147 149 return c.html( 148 - <BaseLayout title="Bad Request — atBB Forum"> 150 + <BaseLayout title="Bad Request — atBB Forum" resolvedTheme={resolvedTheme}> 149 151 <ErrorDisplay message="Invalid board ID." /> 150 152 </BaseLayout>, 151 153 400 ··· 206 208 207 209 if (isNotFoundError(error)) { 208 210 return c.html( 209 - <BaseLayout title="Not Found — atBB Forum" auth={auth}> 211 + <BaseLayout title="Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 210 212 <ErrorDisplay message="This board doesn't exist." /> 211 213 </BaseLayout>, 212 214 404 ··· 224 226 ? "The forum is temporarily unavailable. Please try again later." 225 227 : "Something went wrong loading this board. Please try again later."; 226 228 return c.html( 227 - <BaseLayout title="Error — atBB Forum" auth={auth}> 229 + <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 228 230 <ErrorDisplay message={message} /> 229 231 </BaseLayout>, 230 232 status ··· 250 252 const { topics, total, offset } = topicsData; 251 253 252 254 return c.html( 253 - <BaseLayout title={`${board.name} — atBB Forum`} auth={auth}> 255 + <BaseLayout title={`${board.name} — atBB Forum`} auth={auth} resolvedTheme={resolvedTheme}> 254 256 <nav class="breadcrumb" aria-label="Breadcrumb"> 255 257 <ol> 256 258 <li><a href="/">Home</a></li>
+6 -4
apps/web/src/routes/home.tsx
··· 10 10 import { getSession } from "../lib/session.js"; 11 11 import { isProgrammingError, isNetworkError } from "../lib/errors.js"; 12 12 import { logger } from "../lib/logger.js"; 13 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 13 14 14 15 // API response type shapes 15 16 interface ForumResponse { ··· 48 49 49 50 50 51 export function createHomeRoutes(appviewUrl: string) { 51 - return new Hono().get("/", async (c) => { 52 + return new Hono<WebAppEnv>().get("/", async (c) => { 53 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 52 54 const auth = await getSession(appviewUrl, c.req.header("cookie")); 53 55 54 56 // Stage 1: fetch forum metadata and category list in parallel ··· 73 75 ? "The forum is temporarily unavailable. Please try again later." 74 76 : "Something went wrong loading the forum. Please try again later."; 75 77 return c.html( 76 - <BaseLayout title="Error — atBB Forum" auth={auth}> 78 + <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 77 79 <ErrorDisplay message={message} /> 78 80 </BaseLayout>, 79 81 status ··· 102 104 ? "The forum is temporarily unavailable. Please try again later." 103 105 : "Something went wrong loading the forum. Please try again later."; 104 106 return c.html( 105 - <BaseLayout title="Error — atBB Forum" auth={auth}> 107 + <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 106 108 <ErrorDisplay message={message} /> 107 109 </BaseLayout>, 108 110 status ··· 116 118 })); 117 119 118 120 return c.html( 119 - <BaseLayout title={`${forum.name} — atBB Forum`} auth={auth}> 121 + <BaseLayout title={`${forum.name} — atBB Forum`} auth={auth} resolvedTheme={resolvedTheme}> 120 122 <PageHeader title={forum.name} description={forum.description ?? undefined} /> 121 123 {categorySections.length === 0 ? ( 122 124 <EmptyState message="No categories yet." />
+4 -1
apps/web/src/routes/index.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { loadConfig } from "../lib/config.js"; 3 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 4 + import { createThemeMiddleware } from "../middleware/theme.js"; 3 5 import { createHomeRoutes } from "./home.js"; 4 6 import { createBoardsRoutes } from "./boards.js"; 5 7 import { createTopicsRoutes } from "./topics.js"; ··· 12 14 13 15 const config = loadConfig(); 14 16 15 - export const webRoutes = new Hono() 17 + export const webRoutes = new Hono<WebAppEnv>() 18 + .use("*", createThemeMiddleware(config.appviewUrl)) 16 19 .route("/", createHomeRoutes(config.appviewUrl)) 17 20 .route("/", createBoardsRoutes(config.appviewUrl)) 18 21 .route("/", createTopicsRoutes(config.appviewUrl))
+4 -2
apps/web/src/routes/login.tsx
··· 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { PageHeader } from "../components/index.js"; 4 4 import { getSession } from "../lib/session.js"; 5 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 5 6 6 7 export function createLoginRoutes(appviewUrl: string) { 7 - return new Hono().get("/login", async (c) => { 8 + return new Hono<WebAppEnv>().get("/login", async (c) => { 9 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 8 10 const auth = await getSession(appviewUrl, c.req.header("cookie")); 9 11 10 12 // If already logged in, redirect to homepage ··· 25 27 : undefined; 26 28 27 29 return c.html( 28 - <BaseLayout title="Sign in — atBB Forum" auth={auth}> 30 + <BaseLayout title="Sign in — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 29 31 <PageHeader 30 32 title="Sign in" 31 33 description="Sign in with your Internet Handle."
+2 -1
apps/web/src/routes/mod.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { isProgrammingError } from "../lib/errors.js"; 3 3 import { logger } from "../lib/logger.js"; 4 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 4 5 5 6 /** 6 7 * Single proxy endpoint for all moderation actions. ··· 18 19 * unban → DELETE /api/mod/ban/:id body: { reason } 19 20 */ 20 21 export function createModActionRoute(appviewUrl: string) { 21 - return new Hono().post("/mod/action", async (c) => { 22 + return new Hono<WebAppEnv>().post("/mod/action", async (c) => { 22 23 let body: Record<string, string | File>; 23 24 try { 24 25 body = await c.req.parseBody();
+6 -4
apps/web/src/routes/new-topic.tsx
··· 8 8 isNotFoundError, 9 9 } from "../lib/errors.js"; 10 10 import { logger } from "../lib/logger.js"; 11 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 11 12 12 13 interface BoardResponse { 13 14 id: string; ··· 41 42 `; 42 43 43 44 export function createNewTopicRoutes(appviewUrl: string) { 44 - return new Hono() 45 + return new Hono<WebAppEnv>() 45 46 .get("/new-topic", async (c) => { 47 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 46 48 const boardIdParam = c.req.query("boardId"); 47 49 48 50 // boardId required and must be numeric ··· 54 56 55 57 if (!auth.authenticated) { 56 58 return c.html( 57 - <BaseLayout title="New Topic — atBB Forum" auth={auth}> 59 + <BaseLayout title="New Topic — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 58 60 <PageHeader title="New Topic" /> 59 61 <p> 60 62 <a href="/login">Log in</a> to create a topic. ··· 72 74 73 75 if (isNotFoundError(error)) { 74 76 return c.html( 75 - <BaseLayout title="Not Found — atBB Forum" auth={auth}> 77 + <BaseLayout title="Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 76 78 <ErrorDisplay message="This board doesn't exist." /> 77 79 </BaseLayout>, 78 80 404 ··· 88 90 } 89 91 90 92 return c.html( 91 - <BaseLayout title="New Topic — atBB Forum" auth={auth}> 93 + <BaseLayout title="New Topic — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 92 94 <nav class="breadcrumb" aria-label="Breadcrumb"> 93 95 <ol> 94 96 <li><a href="/">Home</a></li>
+4 -2
apps/web/src/routes/not-found.tsx
··· 1 1 import { Hono } from "hono"; 2 2 import { BaseLayout } from "../layouts/base.js"; 3 3 import { getSession } from "../lib/session.js"; 4 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 4 5 5 6 export function createNotFoundRoute(appviewUrl?: string) { 6 - return new Hono().all("*", async (c) => { 7 + return new Hono<WebAppEnv>().all("*", async (c) => { 8 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 7 9 const auth = appviewUrl 8 10 ? await getSession(appviewUrl, c.req.header("cookie")) 9 11 : { authenticated: false as const }; 10 12 return c.html( 11 - <BaseLayout title="Page Not Found — atBB Forum" auth={auth}> 13 + <BaseLayout title="Page Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 12 14 <div class="error-page"> 13 15 <div class="error-page__code">404</div> 14 16 <h1 class="error-page__title">Page Not Found</h1>
+7 -5
apps/web/src/routes/topics.tsx
··· 15 15 } from "../lib/errors.js"; 16 16 import { timeAgo } from "../lib/time.js"; 17 17 import { logger } from "../lib/logger.js"; 18 + import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 18 19 19 20 // ─── API response types ─────────────────────────────────────────────────────── 20 21 ··· 253 254 // ─── Route factory ──────────────────────────────────────────────────────────── 254 255 255 256 export function createTopicsRoutes(appviewUrl: string) { 256 - return new Hono().get("/topics/:id", async (c) => { 257 + return new Hono<WebAppEnv>().get("/topics/:id", async (c) => { 258 + const resolvedTheme = c.get("theme") ?? FALLBACK_THEME; 257 259 const idParam = c.req.param("id"); 258 260 const offsetRaw = parseInt(c.req.query("offset") ?? "0", 10); 259 261 const offset = isNaN(offsetRaw) || offsetRaw < 0 ? 0 : offsetRaw; ··· 264 266 return c.html("", 200); 265 267 } 266 268 return c.html( 267 - <BaseLayout title="Bad Request — atBB Forum"> 269 + <BaseLayout title="Bad Request — atBB Forum" resolvedTheme={resolvedTheme}> 268 270 <ErrorDisplay message="Invalid topic ID." /> 269 271 </BaseLayout>, 270 272 400 ··· 339 341 340 342 if (isNotFoundError(error)) { 341 343 return c.html( 342 - <BaseLayout title="Not Found — atBB Forum" auth={auth}> 344 + <BaseLayout title="Not Found — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 343 345 <ErrorDisplay message="This topic doesn't exist." /> 344 346 </BaseLayout>, 345 347 404 ··· 357 359 ? "The forum is temporarily unavailable. Please try again later." 358 360 : "Something went wrong loading this topic. Please try again later."; 359 361 return c.html( 360 - <BaseLayout title="Error — atBB Forum" auth={auth}> 362 + <BaseLayout title="Error — atBB Forum" auth={auth} resolvedTheme={resolvedTheme}> 361 363 <ErrorDisplay message={message} /> 362 364 </BaseLayout>, 363 365 status ··· 406 408 const topicTitle = topicData.post.title ?? topicData.post.text.slice(0, 60); 407 409 408 410 return c.html( 409 - <BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth}> 411 + <BaseLayout title={`${topicTitle} — atBB Forum`} auth={auth} resolvedTheme={resolvedTheme}> 410 412 <nav class="breadcrumb" aria-label="Breadcrumb"> 411 413 <ol> 412 414 <li><a href="/">Home</a></li>
+7 -1
bruno/AppView API/Themes/Get Theme.bru
··· 12 12 res.status: eq 200 13 13 res.body.name: isDefined 14 14 res.body.tokens: isDefined 15 + res.body.cid: isDefined 15 16 } 16 17 17 18 docs { 18 - Returns full theme data (name, colorScheme, tokens, cssOverrides, fontUrls) 19 + Returns full theme data (name, colorScheme, tokens, cssOverrides, fontUrls, cid) 19 20 for the theme identified by its rkey (TID). 20 21 22 + The cid field enables CID integrity checking in the theme resolution waterfall 23 + (ATB-53): the web server compares the fetched theme's CID against the policy's 24 + expected CID to detect stale/mismatched theme records. 25 + 21 26 Set the theme_rkey environment variable to a valid theme rkey before running. 22 27 23 28 Path params: ··· 27 32 { 28 33 "id": "1", 29 34 "uri": "at://did:plc:.../space.atbb.forum.theme/3lblexample", 35 + "cid": "bafyreib2rxk3rybk3aobmv5cjuql3bm2twh4jo5ufuzl4etccplm56vb5e", 30 36 "name": "Neobrutal Light", 31 37 "colorScheme": "light", 32 38 "tokens": { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" },
+1531
docs/plans/2026-03-03-atb59-theme-editor-implementation.md
··· 1 + # ATB-59 Theme Token Editor — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Build the admin theme token editor at `GET /admin/themes/:rkey` with HTMX live preview, save to PDS, and preset reset. 6 + 7 + **Architecture:** Extract all theme-admin route handlers from `admin.tsx` into a new `admin-themes.tsx` module, then add the editor page, HTMX preview endpoint, save handler, and reset-to-preset handler. The preview endpoint is web-server-only (calls `tokensToCss()`, never touches AppView), with tokens scoped to `.preview-pane-inner` so the editor UI doesn't update live. 8 + 9 + **Tech Stack:** Hono, Hono JSX, HTMX, Vitest, TypeScript. No new dependencies. 10 + 11 + --- 12 + 13 + ## Before You Start 14 + 15 + **Run the test suite first to establish a baseline:** 16 + 17 + ```bash 18 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 19 + pnpm --filter @atbb/web test 2>&1 | tail -20 20 + ``` 21 + 22 + Expected: all tests pass. If not, stop and investigate before proceeding. 23 + 24 + **Key files:** 25 + - `apps/web/src/routes/admin.tsx` — source of truth for theme handlers to extract (lines 1491–1992) 26 + - `apps/web/src/routes/__tests__/admin.test.tsx` — existing tests; do NOT break them 27 + - `apps/web/src/lib/theme.ts` — `tokensToCss()` utility 28 + - `apps/web/src/styles/presets/neobrutal-light.json` — 46-token preset (light) 29 + - `apps/web/src/styles/presets/neobrutal-dark.json` — 46-token preset (dark) 30 + - `apps/web/public/static/css/theme.css` — CSS class reference for preview HTML 31 + 32 + **Token names (all 46, from preset JSON):** 33 + ``` 34 + COLOR: color-bg, color-surface, color-text, color-text-muted, color-primary, 35 + color-primary-hover, color-secondary, color-border, color-shadow, 36 + color-success, color-warning, color-danger, color-code-bg, color-code-text 37 + TYPOGRAPHY: font-body, font-heading, font-mono, font-size-base, font-size-sm, 38 + font-size-xs, font-size-lg, font-size-xl, font-size-2xl, 39 + font-weight-normal, font-weight-bold, line-height-body, line-height-heading 40 + SPACING: space-xs, space-sm, space-md, space-lg, space-xl, 41 + radius, border-width, shadow-offset, content-width 42 + COMPONENTS: button-radius, button-shadow, card-radius, card-shadow, 43 + btn-press-hover, btn-press-active, input-radius, input-border, nav-height 44 + ``` 45 + 46 + --- 47 + 48 + ## Task 1: Create `admin-themes.tsx` with extracted handlers 49 + 50 + **Files:** 51 + - Create: `apps/web/src/routes/admin-themes.tsx` 52 + 53 + This task moves existing code. No new functionality yet. The file will: 54 + 1. Re-define the `extractAppviewError` helper (it stays in `admin.tsx` too, for structure routes) 55 + 2. Re-define `AdminThemeEntry`, `ThemePolicy` types (only used by theme handlers) 56 + 3. Move `THEME_PRESETS` constant and the two JSON imports 57 + 4. Export `createAdminThemeRoutes(appviewUrl: string)` factory containing all 5 existing handlers 58 + 59 + **Step 1: Create the file** 60 + 61 + `apps/web/src/routes/admin-themes.tsx`: 62 + 63 + ```tsx 64 + import { Hono } from "hono"; 65 + import { BaseLayout } from "../layouts/base.js"; 66 + import { PageHeader, EmptyState } from "../components/index.js"; 67 + import { 68 + getSessionWithPermissions, 69 + canManageThemes, 70 + } from "../lib/session.js"; 71 + import { isProgrammingError } from "../lib/errors.js"; 72 + import { logger } from "../lib/logger.js"; 73 + import { tokensToCss } from "../lib/theme.js"; 74 + import neobrutalLight from "../styles/presets/neobrutal-light.json" assert { type: "json" }; 75 + import neobrutalDark from "../styles/presets/neobrutal-dark.json" assert { type: "json" }; 76 + 77 + // ─── Types ───────────────────────────────────────────────────────────────── 78 + 79 + interface AdminThemeEntry { 80 + id: string; 81 + uri: string; 82 + name: string; 83 + colorScheme: string; 84 + tokens: Record<string, string>; 85 + cssOverrides: string | null; 86 + fontUrls: string[] | null; 87 + createdAt: string; 88 + indexedAt: string; 89 + } 90 + 91 + interface ThemePolicy { 92 + defaultLightThemeUri: string | null; 93 + defaultDarkThemeUri: string | null; 94 + allowUserChoice: boolean; 95 + availableThemes: Array<{ uri: string; cid: string }>; 96 + } 97 + 98 + // ─── Constants ────────────────────────────────────────────────────────────── 99 + 100 + const THEME_PRESETS: Record<string, Record<string, string>> = { 101 + "neobrutal-light": neobrutalLight as Record<string, string>, 102 + "neobrutal-dark": neobrutalDark as Record<string, string>, 103 + "blank": {}, 104 + }; 105 + 106 + const COLOR_TOKENS = [ 107 + "color-bg", "color-surface", "color-text", "color-text-muted", 108 + "color-primary", "color-primary-hover", "color-secondary", "color-border", 109 + "color-shadow", "color-success", "color-warning", "color-danger", 110 + "color-code-bg", "color-code-text", 111 + ] as const; 112 + 113 + const TYPOGRAPHY_TOKENS = [ 114 + "font-body", "font-heading", "font-mono", 115 + "font-size-base", "font-size-sm", "font-size-xs", "font-size-lg", 116 + "font-size-xl", "font-size-2xl", 117 + "font-weight-normal", "font-weight-bold", 118 + "line-height-body", "line-height-heading", 119 + ] as const; 120 + 121 + const SPACING_TOKENS = [ 122 + "space-xs", "space-sm", "space-md", "space-lg", "space-xl", 123 + "radius", "border-width", "shadow-offset", "content-width", 124 + ] as const; 125 + 126 + const COMPONENT_TOKENS = [ 127 + "button-radius", "button-shadow", 128 + "card-radius", "card-shadow", 129 + "btn-press-hover", "btn-press-active", 130 + "input-radius", "input-border", 131 + "nav-height", 132 + ] as const; 133 + 134 + const ALL_KNOWN_TOKENS: readonly string[] = [ 135 + ...COLOR_TOKENS, ...TYPOGRAPHY_TOKENS, ...SPACING_TOKENS, ...COMPONENT_TOKENS, 136 + ]; 137 + 138 + // ─── Helpers ──────────────────────────────────────────────────────────────── 139 + 140 + async function extractAppviewError(res: Response, fallback: string): Promise<string> { 141 + try { 142 + const data = (await res.json()) as { error?: string }; 143 + return data.error ?? fallback; 144 + } catch { 145 + return fallback; 146 + } 147 + } 148 + 149 + /** Drop token values that could break the CSS style block. */ 150 + function sanitizeTokenValue(value: unknown): string | null { 151 + if (typeof value !== "string") return null; 152 + if (value.includes("<") || value.includes(";") || value.includes("</")) return null; 153 + return value; 154 + } 155 + 156 + // ─── Components ───────────────────────────────────────────────────────────── 157 + 158 + function ColorTokenInput({ name, value }: { name: string; value: string }) { 159 + const safeValue = 160 + !value.startsWith("var(") && !value.includes(";") && !value.includes("<") 161 + ? value 162 + : "#cccccc"; 163 + return ( 164 + <div class="token-input token-input--color"> 165 + <label for={`token-${name}`}>{name}</label> 166 + <div class="token-input__controls"> 167 + <input 168 + type="color" 169 + value={safeValue} 170 + aria-label={`${name} color picker`} 171 + oninput="this.nextElementSibling.value=this.value;this.nextElementSibling.dispatchEvent(new Event('change',{bubbles:true}))" 172 + /> 173 + <input 174 + type="text" 175 + id={`token-${name}`} 176 + name={name} 177 + value={safeValue} 178 + oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))this.previousElementSibling.value=this.value" 179 + /> 180 + </div> 181 + </div> 182 + ); 183 + } 184 + 185 + function TextTokenInput({ name, value }: { name: string; value: string }) { 186 + return ( 187 + <div class="token-input"> 188 + <label for={`token-${name}`}>{name}</label> 189 + <input type="text" id={`token-${name}`} name={name} value={value} /> 190 + </div> 191 + ); 192 + } 193 + 194 + function TokenFieldset({ 195 + legend, 196 + tokens, 197 + effectiveTokens, 198 + isColor, 199 + }: { 200 + legend: string; 201 + tokens: readonly string[]; 202 + effectiveTokens: Record<string, string>; 203 + isColor: boolean; 204 + }) { 205 + return ( 206 + <fieldset class="token-group"> 207 + <legend>{legend}</legend> 208 + {tokens.map((name) => 209 + isColor ? ( 210 + <ColorTokenInput name={name} value={effectiveTokens[name] ?? ""} /> 211 + ) : ( 212 + <TextTokenInput name={name} value={effectiveTokens[name] ?? ""} /> 213 + ) 214 + )} 215 + </fieldset> 216 + ); 217 + } 218 + 219 + function ThemePreviewContent({ tokens }: { tokens: Record<string, string> }) { 220 + const css = tokensToCss(tokens); 221 + return ( 222 + <> 223 + <style>{`.preview-pane-inner{${css}}`}</style> 224 + <div class="preview-pane-inner"> 225 + <div 226 + style="background:var(--color-surface);border-bottom:var(--border-width) solid var(--color-border);padding:var(--space-sm) var(--space-md);display:flex;align-items:center;font-family:var(--font-heading);font-weight:var(--font-weight-bold);font-size:var(--font-size-lg);color:var(--color-text);" 227 + role="navigation" 228 + aria-label="Preview navigation" 229 + > 230 + atBB Forum Preview 231 + </div> 232 + <div style="padding:var(--space-md);"> 233 + <div 234 + style="background:var(--color-surface);border:var(--border-width) solid var(--color-border);border-radius:var(--card-radius);box-shadow:var(--card-shadow);padding:var(--space-md);margin-bottom:var(--space-md);" 235 + > 236 + <h2 237 + style="font-family:var(--font-heading);font-size:var(--font-size-xl);font-weight:var(--font-weight-bold);line-height:var(--line-height-heading);color:var(--color-text);margin:0 0 var(--space-sm) 0;" 238 + > 239 + Sample Thread Title 240 + </h2> 241 + <p style="font-family:var(--font-body);font-size:var(--font-size-base);line-height:var(--line-height-body);color:var(--color-text);margin:0 0 var(--space-md) 0;"> 242 + Body text showing font, color, and spacing at work.{" "} 243 + <a href="#" style="color:var(--color-primary);">A sample link</a> 244 + </p> 245 + <pre 246 + style="font-family:var(--font-mono);font-size:var(--font-size-sm);background:var(--color-code-bg);color:var(--color-code-text);padding:var(--space-sm) var(--space-md);border-radius:var(--radius);margin:0 0 var(--space-md) 0;overflow-x:auto;" 247 + > 248 + {`const greeting = "hello forum";`} 249 + </pre> 250 + <input 251 + type="text" 252 + placeholder="Reply…" 253 + style="font-family:var(--font-body);font-size:var(--font-size-base);border:var(--input-border);border-radius:var(--input-radius);padding:var(--space-sm) var(--space-md);width:100%;box-sizing:border-box;background:var(--color-bg);color:var(--color-text);margin-bottom:var(--space-sm);" 254 + /> 255 + <div style="display:flex;gap:var(--space-sm);flex-wrap:wrap;"> 256 + <button 257 + type="button" 258 + style="background:var(--color-primary);color:var(--color-surface);border:var(--border-width) solid var(--color-border);border-radius:var(--button-radius);box-shadow:var(--button-shadow);font-family:var(--font-body);font-weight:var(--font-weight-bold);padding:var(--space-sm) var(--space-md);cursor:pointer;" 259 + > 260 + Post Reply 261 + </button> 262 + <button 263 + type="button" 264 + style="background:var(--color-surface);color:var(--color-text);border:var(--border-width) solid var(--color-border);border-radius:var(--button-radius);box-shadow:var(--button-shadow);font-family:var(--font-body);font-weight:var(--font-weight-bold);padding:var(--space-sm) var(--space-md);cursor:pointer;" 265 + > 266 + Cancel 267 + </button> 268 + <span 269 + style="display:inline-block;background:var(--color-success);color:var(--color-surface);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);" 270 + > 271 + success 272 + </span> 273 + <span 274 + style="display:inline-block;background:var(--color-warning);color:var(--color-text);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);" 275 + > 276 + warning 277 + </span> 278 + <span 279 + style="display:inline-block;background:var(--color-danger);color:var(--color-surface);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);" 280 + > 281 + danger 282 + </span> 283 + </div> 284 + </div> 285 + </div> 286 + </div> 287 + </> 288 + ); 289 + } 290 + 291 + // ─── Route factory ────────────────────────────────────────────────────────── 292 + 293 + export function createAdminThemeRoutes(appviewUrl: string) { 294 + const app = new Hono(); 295 + 296 + // ── GET /admin/themes ────────────────────────────────────────────────────── 297 + // PASTE the GET /admin/themes handler from admin.tsx here (lines 1493–1771) 298 + // Change app.get to app.get (it's already on app in admin.tsx) 299 + // Update all fetch URLs to use appviewUrl parameter instead of the module-level variable 300 + 301 + // ── POST /admin/themes ───────────────────────────────────────────────────── 302 + // PASTE the POST /admin/themes handler from admin.tsx here (lines 1773–1836) 303 + 304 + // ── POST /admin/themes/:rkey/duplicate ───────────────────────────────────── 305 + // PASTE the handler from admin.tsx here (lines 1838–1875) 306 + 307 + // ── POST /admin/themes/:rkey/delete ──────────────────────────────────────── 308 + // PASTE the handler from admin.tsx here (lines 1877–1920) 309 + 310 + // ── POST /admin/theme-policy ─────────────────────────────────────────────── 311 + // PASTE the handler from admin.tsx here (lines 1922–1992) 312 + 313 + return app; 314 + } 315 + ``` 316 + 317 + **Step 2: Fill in the extracted handlers** 318 + 319 + Open `apps/web/src/routes/admin.tsx`. Copy the body of each theme handler (lines 1493–1992) into the corresponding slot in `admin-themes.tsx`. Important differences: 320 + - In `admin.tsx`, the handlers reference the module-level `appviewUrl` variable. In the new factory, use the `appviewUrl` parameter instead. 321 + - The imports already exist in the new file (`logger`, `isProgrammingError`, etc.). 322 + - Remove the `// PASTE...` placeholder comments as you fill each one in. 323 + 324 + **Step 3: Run tests to verify no regressions** 325 + 326 + ```bash 327 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 328 + pnpm --filter @atbb/web test 2>&1 | tail -20 329 + ``` 330 + 331 + Expected: same pass count as baseline. (The admin-themes tests don't exist yet — that's OK.) 332 + 333 + **Step 4: Commit** 334 + 335 + ```bash 336 + git add apps/web/src/routes/admin-themes.tsx 337 + git commit -m "refactor(web): extract theme admin handlers into admin-themes.tsx (ATB-59)" 338 + ``` 339 + 340 + --- 341 + 342 + ## Task 2: Update `admin.tsx` to delegate to `admin-themes.tsx` 343 + 344 + **Files:** 345 + - Modify: `apps/web/src/routes/admin.tsx` 346 + 347 + **Step 1: Add import at the top of admin.tsx** 348 + 349 + After the existing imports, add: 350 + ```typescript 351 + import { createAdminThemeRoutes } from "./admin-themes.js"; 352 + ``` 353 + 354 + Also remove (now unused) imports from admin.tsx: 355 + - `neobrutalLight` and `neobrutalDark` JSON imports (lines 15–16 — only used by THEME_PRESETS) 356 + 357 + **Step 2: Mount the theme routes** 358 + 359 + Just before `return app;` at the end of `createAdminRoutes` (around line 1993), add: 360 + ```typescript 361 + app.route("/", createAdminThemeRoutes(appviewUrl)); 362 + ``` 363 + 364 + **Step 3: Remove the extracted code from admin.tsx** 365 + 366 + Delete lines 1491–1992 (the `// ─── Themes ───` section and all theme handler blocks). Leave `return app;` in place. 367 + 368 + Also delete from admin.tsx: 369 + - `AdminThemeEntry` interface (lines 65–75) 370 + - `ThemePolicy` interface (lines 77–82) 371 + - `THEME_PRESETS` constant and JSON imports (lines 15–16, 84–89) 372 + 373 + **Step 4: Run tests — must still pass** 374 + 375 + ```bash 376 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 377 + pnpm --filter @atbb/web test 2>&1 | tail -20 378 + ``` 379 + 380 + All existing tests must pass. The theme list routes are now handled by admin-themes.tsx but mounted at the same paths. 381 + 382 + **Step 5: Commit** 383 + 384 + ```bash 385 + git add apps/web/src/routes/admin.tsx 386 + git commit -m "refactor(web): mount admin-themes routes, remove extracted code from admin.tsx (ATB-59)" 387 + ``` 388 + 389 + --- 390 + 391 + ## Task 3: Write tests for `GET /admin/themes/:rkey` (TDD) 392 + 393 + **Files:** 394 + - Create: `apps/web/src/routes/__tests__/admin-themes.test.tsx` 395 + 396 + **Step 1: Create the test file with scaffolding** 397 + 398 + ```tsx 399 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 400 + 401 + const mockFetch = vi.fn(); 402 + 403 + describe("createAdminThemeRoutes — GET /admin/themes/:rkey", () => { 404 + beforeEach(() => { 405 + vi.stubGlobal("fetch", mockFetch); 406 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 407 + vi.resetModules(); 408 + }); 409 + 410 + afterEach(() => { 411 + vi.unstubAllGlobals(); 412 + vi.unstubAllEnvs(); 413 + mockFetch.mockReset(); 414 + }); 415 + 416 + function mockResponse(body: unknown, ok = true, status = 200) { 417 + return { 418 + ok, 419 + status, 420 + statusText: ok ? "OK" : "Error", 421 + json: () => Promise.resolve(body), 422 + }; 423 + } 424 + 425 + /** Session check: 2 fetches — /api/auth/session, then /api/admin/members/me */ 426 + function setupAuth(permissions: string[]) { 427 + mockFetch.mockResolvedValueOnce( 428 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" }) 429 + ); 430 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 431 + } 432 + 433 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 434 + 435 + const sampleTheme = { 436 + id: "1", 437 + uri: "at://did:plc:forum/space.atbb.forum.theme/abc123", 438 + name: "My Theme", 439 + colorScheme: "light", 440 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 441 + cssOverrides: null, 442 + fontUrls: null, 443 + createdAt: "2026-01-01T00:00:00.000Z", 444 + indexedAt: "2026-01-01T00:00:00.000Z", 445 + }; 446 + 447 + async function loadThemeRoutes() { 448 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 449 + return createAdminThemeRoutes("http://localhost:3000"); 450 + } 451 + 452 + it("redirects unauthenticated users to /login", async () => { 453 + // No session cookie — no fetch calls made 454 + const routes = await loadThemeRoutes(); 455 + const res = await routes.request("/admin/themes/abc123"); 456 + expect(res.status).toBe(302); 457 + expect(res.headers.get("location")).toBe("/login"); 458 + }); 459 + 460 + it("returns 403 for users without manageThemes permission", async () => { 461 + setupAuth([]); 462 + const routes = await loadThemeRoutes(); 463 + const res = await routes.request("/admin/themes/abc123", { 464 + headers: { cookie: "atbb_session=token" }, 465 + }); 466 + expect(res.status).toBe(403); 467 + const html = await res.text(); 468 + expect(html).toContain("Access Denied"); 469 + }); 470 + 471 + it("returns 404 when theme not found (AppView returns 404)", async () => { 472 + setupAuth([MANAGE_THEMES]); 473 + mockFetch.mockResolvedValueOnce(mockResponse({ error: "Theme not found" }, false, 404)); 474 + 475 + const routes = await loadThemeRoutes(); 476 + const res = await routes.request("/admin/themes/notexist", { 477 + headers: { cookie: "atbb_session=token" }, 478 + }); 479 + expect(res.status).toBe(404); 480 + }); 481 + 482 + it("renders editor with theme name, colorScheme, and token inputs", async () => { 483 + setupAuth([MANAGE_THEMES]); 484 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 485 + 486 + const routes = await loadThemeRoutes(); 487 + const res = await routes.request("/admin/themes/abc123", { 488 + headers: { cookie: "atbb_session=token" }, 489 + }); 490 + expect(res.status).toBe(200); 491 + const html = await res.text(); 492 + expect(html).toContain("My Theme"); 493 + expect(html).toContain('value="light"'); 494 + expect(html).toContain("#f5f0e8"); // color-bg token 495 + expect(html).toContain("#1a1a1a"); // color-text token 496 + expect(html).toContain('name="color-bg"'); 497 + }); 498 + 499 + it("shows success banner when ?success=1 is present", async () => { 500 + setupAuth([MANAGE_THEMES]); 501 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 502 + 503 + const routes = await loadThemeRoutes(); 504 + const res = await routes.request("/admin/themes/abc123?success=1", { 505 + headers: { cookie: "atbb_session=token" }, 506 + }); 507 + const html = await res.text(); 508 + expect(html).toContain("saved"); // some form of success text 509 + }); 510 + 511 + it("shows error banner when ?error=<msg> is present", async () => { 512 + setupAuth([MANAGE_THEMES]); 513 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 514 + 515 + const routes = await loadThemeRoutes(); 516 + const res = await routes.request("/admin/themes/abc123?error=Something+went+wrong", { 517 + headers: { cookie: "atbb_session=token" }, 518 + }); 519 + const html = await res.text(); 520 + expect(html).toContain("Something went wrong"); 521 + }); 522 + 523 + it("uses preset tokens when ?preset=neobrutal-light is present", async () => { 524 + setupAuth([MANAGE_THEMES]); 525 + // Theme has no tokens; preset should override 526 + const emptyTheme = { ...sampleTheme, tokens: {} }; 527 + mockFetch.mockResolvedValueOnce(mockResponse(emptyTheme)); 528 + 529 + const routes = await loadThemeRoutes(); 530 + const res = await routes.request("/admin/themes/abc123?preset=neobrutal-light", { 531 + headers: { cookie: "atbb_session=token" }, 532 + }); 533 + const html = await res.text(); 534 + // neobrutal-light has color-bg: #f5f0e8 535 + expect(html).toContain("#f5f0e8"); 536 + }); 537 + 538 + it("renders CSS overrides field as disabled (awaiting ATB-62)", async () => { 539 + setupAuth([MANAGE_THEMES]); 540 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 541 + 542 + const routes = await loadThemeRoutes(); 543 + const res = await routes.request("/admin/themes/abc123", { 544 + headers: { cookie: "atbb_session=token" }, 545 + }); 546 + const html = await res.text(); 547 + expect(html).toContain("css-overrides"); 548 + expect(html).toContain("disabled"); 549 + }); 550 + }); 551 + ``` 552 + 553 + **Step 2: Run the tests to verify they fail** 554 + 555 + ```bash 556 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 557 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "admin-themes" 558 + ``` 559 + 560 + Expected: All new tests fail with "createAdminThemeRoutes has no route matching GET /admin/themes/:rkey". 561 + 562 + --- 563 + 564 + ## Task 4: Implement `GET /admin/themes/:rkey` 565 + 566 + **Files:** 567 + - Modify: `apps/web/src/routes/admin-themes.tsx` 568 + 569 + **Step 1: Add the route handler inside `createAdminThemeRoutes`** 570 + 571 + Add this after the existing POST /admin/theme-policy handler, before `return app;`: 572 + 573 + ```tsx 574 + // ── GET /admin/themes/:rkey ──────────────────────────────────────────────── 575 + 576 + app.get("/admin/themes/:rkey", async (c) => { 577 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 578 + if (!auth.authenticated) return c.redirect("/login"); 579 + if (!canManageThemes(auth)) { 580 + return c.html( 581 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 582 + <PageHeader title="Access Denied" /> 583 + <p>You don&apos;t have permission to manage themes.</p> 584 + </BaseLayout>, 585 + 403 586 + ); 587 + } 588 + 589 + const themeRkey = c.req.param("rkey"); 590 + const cookie = c.req.header("cookie") ?? ""; 591 + const presetParam = c.req.query("preset") ?? null; 592 + const successMsg = c.req.query("success") === "1" ? "Theme saved successfully." : null; 593 + const errorMsg = c.req.query("error") ?? null; 594 + 595 + // Fetch theme from AppView 596 + let theme: AdminThemeEntry | null = null; 597 + try { 598 + const res = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 599 + headers: { Cookie: cookie }, 600 + }); 601 + if (res.status === 404) { 602 + return c.html( 603 + <BaseLayout title="Theme Not Found — atBB Admin" auth={auth}> 604 + <PageHeader title="Theme Not Found" /> 605 + <p>This theme does not exist.</p> 606 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 607 + </BaseLayout>, 608 + 404 609 + ); 610 + } 611 + if (res.ok) { 612 + try { 613 + theme = (await res.json()) as AdminThemeEntry; 614 + } catch { 615 + logger.error("Failed to parse theme response", { 616 + operation: "GET /admin/themes/:rkey", 617 + themeRkey, 618 + }); 619 + } 620 + } else { 621 + logger.error("AppView returned error loading theme", { 622 + operation: "GET /admin/themes/:rkey", 623 + themeRkey, 624 + status: res.status, 625 + }); 626 + } 627 + } catch (error) { 628 + if (isProgrammingError(error)) throw error; 629 + logger.error("Network error loading theme", { 630 + operation: "GET /admin/themes/:rkey", 631 + themeRkey, 632 + error: error instanceof Error ? error.message : String(error), 633 + }); 634 + } 635 + 636 + if (!theme) { 637 + return c.html( 638 + <BaseLayout title="Theme Unavailable — atBB Admin" auth={auth}> 639 + <PageHeader title="Theme Unavailable" /> 640 + <p>Unable to load theme data. Please try again.</p> 641 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 642 + </BaseLayout>, 643 + 500 644 + ); 645 + } 646 + 647 + // If ?preset is set, override DB tokens with preset tokens 648 + const presetTokens = presetParam ? (THEME_PRESETS[presetParam] ?? null) : null; 649 + const effectiveTokens: Record<string, string> = presetTokens 650 + ? { ...theme.tokens, ...presetTokens } 651 + : { ...theme.tokens }; 652 + 653 + const fontUrlsText = (theme.fontUrls ?? []).join("\n"); 654 + 655 + return c.html( 656 + <BaseLayout title={`Edit Theme: ${theme.name} — atBB Admin`} auth={auth}> 657 + <PageHeader title={`Edit Theme: ${theme.name}`} /> 658 + 659 + {successMsg && <div class="structure-success-banner">{successMsg}</div>} 660 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 661 + 662 + <a href="/admin/themes" class="btn btn-secondary btn-sm" style="margin-bottom: var(--space-md); display: inline-block;"> 663 + ← Back to themes 664 + </a> 665 + 666 + {/* Metadata + tokens form */} 667 + <form 668 + id="editor-form" 669 + method="post" 670 + action={`/admin/themes/${themeRkey}/save`} 671 + class="theme-editor" 672 + > 673 + {/* Metadata */} 674 + <fieldset class="token-group"> 675 + <legend>Theme Metadata</legend> 676 + <div class="token-input"> 677 + <label for="theme-name">Name</label> 678 + <input type="text" id="theme-name" name="name" value={theme.name} required /> 679 + </div> 680 + <div class="token-input"> 681 + <label for="theme-scheme">Color Scheme</label> 682 + <select id="theme-scheme" name="colorScheme"> 683 + <option value="light" selected={theme.colorScheme === "light"}>Light</option> 684 + <option value="dark" selected={theme.colorScheme === "dark"}>Dark</option> 685 + </select> 686 + </div> 687 + <div class="token-input"> 688 + <label for="theme-font-urls">Font URLs (one per line)</label> 689 + <textarea id="theme-font-urls" name="fontUrls" rows={3} placeholder="https://fonts.googleapis.com/css2?family=..."> 690 + {fontUrlsText} 691 + </textarea> 692 + </div> 693 + </fieldset> 694 + 695 + {/* Token editor + live preview layout */} 696 + <div class="theme-editor__layout"> 697 + {/* Left: token controls */} 698 + <div 699 + class="theme-editor__controls" 700 + hx-post={`/admin/themes/${themeRkey}/preview`} 701 + hx-trigger="input delay:500ms" 702 + hx-target="#preview-pane" 703 + hx-include="#editor-form" 704 + > 705 + <TokenFieldset 706 + legend="Colors" 707 + tokens={COLOR_TOKENS} 708 + effectiveTokens={effectiveTokens} 709 + isColor={true} 710 + /> 711 + <TokenFieldset 712 + legend="Typography" 713 + tokens={TYPOGRAPHY_TOKENS} 714 + effectiveTokens={effectiveTokens} 715 + isColor={false} 716 + /> 717 + <TokenFieldset 718 + legend="Spacing & Layout" 719 + tokens={SPACING_TOKENS} 720 + effectiveTokens={effectiveTokens} 721 + isColor={false} 722 + /> 723 + <TokenFieldset 724 + legend="Components" 725 + tokens={COMPONENT_TOKENS} 726 + effectiveTokens={effectiveTokens} 727 + isColor={false} 728 + /> 729 + 730 + {/* CSS overrides — disabled until ATB-62 */} 731 + <fieldset class="token-group"> 732 + <legend>CSS Overrides</legend> 733 + <div class="token-input"> 734 + <label for="css-overrides"> 735 + Custom CSS{" "} 736 + <span class="form-hint">(disabled — CSS sanitization not yet implemented)</span> 737 + </label> 738 + <textarea 739 + id="css-overrides" 740 + name="cssOverrides" 741 + rows={6} 742 + disabled 743 + aria-describedby="css-overrides-hint" 744 + placeholder="/* Will be enabled in ATB-62 */" 745 + > 746 + {theme.cssOverrides ?? ""} 747 + </textarea> 748 + <p id="css-overrides-hint" class="form-hint"> 749 + Raw CSS overrides will be available after CSS sanitization is implemented (ATB-62). 750 + </p> 751 + </div> 752 + </fieldset> 753 + </div> 754 + 755 + {/* Right: live preview */} 756 + <div class="theme-editor__preview"> 757 + <h3>Live Preview</h3> 758 + <div id="preview-pane" class="preview-pane"> 759 + <ThemePreviewContent tokens={effectiveTokens} /> 760 + </div> 761 + </div> 762 + </div> 763 + 764 + {/* Actions */} 765 + <div class="theme-editor__actions"> 766 + <button type="submit" class="btn btn-primary">Save Theme</button> 767 + 768 + <button 769 + type="button" 770 + class="btn btn-secondary" 771 + onclick="document.getElementById('reset-dialog').showModal()" 772 + > 773 + Reset to Preset 774 + </button> 775 + </div> 776 + </form> 777 + 778 + {/* Reset to preset dialog */} 779 + <dialog id="reset-dialog" class="structure-confirm-dialog"> 780 + <form method="post" action={`/admin/themes/${themeRkey}/reset-to-preset`}> 781 + <p>Reset all token values to a built-in preset? Your unsaved changes will be lost.</p> 782 + <div class="form-group"> 783 + <label for="reset-preset-select">Reset to preset:</label> 784 + <select id="reset-preset-select" name="preset"> 785 + <option value="neobrutal-light">Neobrutal Light</option> 786 + <option value="neobrutal-dark">Neobrutal Dark</option> 787 + <option value="blank">Blank (empty tokens)</option> 788 + </select> 789 + </div> 790 + <div class="dialog-actions"> 791 + <button type="submit" class="btn btn-danger">Reset</button> 792 + <button 793 + type="button" 794 + class="btn btn-secondary" 795 + onclick="document.getElementById('reset-dialog').close()" 796 + > 797 + Cancel 798 + </button> 799 + </div> 800 + </form> 801 + </dialog> 802 + </BaseLayout> 803 + ); 804 + }); 805 + ``` 806 + 807 + **Step 2: Run the tests** 808 + 809 + ```bash 810 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 811 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "admin-themes" 812 + ``` 813 + 814 + Expected: All 8 GET /admin/themes/:rkey tests pass. 815 + 816 + **Step 3: Also fix the Edit button in the theme list** 817 + 818 + In `admin-themes.tsx`, find the `GET /admin/themes` list handler. Find this line: 819 + ```tsx 820 + <span class="btn btn-secondary btn-sm" aria-disabled="true"> 821 + Edit 822 + </span> 823 + ``` 824 + 825 + Replace with: 826 + ```tsx 827 + <a href={`/admin/themes/${themeRkey}`} class="btn btn-secondary btn-sm"> 828 + Edit 829 + </a> 830 + ``` 831 + 832 + **Step 4: Run tests again** 833 + 834 + ```bash 835 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 836 + pnpm --filter @atbb/web test 2>&1 | tail -20 837 + ``` 838 + 839 + All tests must pass. 840 + 841 + **Step 5: Commit** 842 + 843 + ```bash 844 + git add apps/web/src/routes/admin-themes.tsx 845 + git commit -m "feat(web): GET /admin/themes/:rkey token editor page + fix Edit button (ATB-59)" 846 + ``` 847 + 848 + --- 849 + 850 + ## Task 5: Write tests for `POST /admin/themes/:rkey/preview` 851 + 852 + **Files:** 853 + - Modify: `apps/web/src/routes/__tests__/admin-themes.test.tsx` 854 + 855 + **Step 1: Add a new describe block** 856 + 857 + ```tsx 858 + describe("createAdminThemeRoutes — POST /admin/themes/:rkey/preview", () => { 859 + beforeEach(() => { 860 + vi.stubGlobal("fetch", mockFetch); 861 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 862 + vi.resetModules(); 863 + }); 864 + 865 + afterEach(() => { 866 + vi.unstubAllGlobals(); 867 + vi.unstubAllEnvs(); 868 + mockFetch.mockReset(); 869 + }); 870 + 871 + function mockResponse(body: unknown, ok = true, status = 200) { 872 + return { 873 + ok, status, 874 + statusText: ok ? "OK" : "Error", 875 + json: () => Promise.resolve(body), 876 + }; 877 + } 878 + 879 + function setupAuth(permissions: string[]) { 880 + mockFetch.mockResolvedValueOnce( 881 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" }) 882 + ); 883 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 884 + } 885 + 886 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 887 + 888 + async function loadThemeRoutes() { 889 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 890 + return createAdminThemeRoutes("http://localhost:3000"); 891 + } 892 + 893 + it("redirects unauthenticated users to /login", async () => { 894 + const routes = await loadThemeRoutes(); 895 + const body = new URLSearchParams({ "color-bg": "#ff0000" }); 896 + const res = await routes.request("/admin/themes/abc123/preview", { 897 + method: "POST", 898 + headers: { "content-type": "application/x-www-form-urlencoded" }, 899 + body: body.toString(), 900 + }); 901 + expect(res.status).toBe(302); 902 + expect(res.headers.get("location")).toBe("/login"); 903 + }); 904 + 905 + it("returns an HTML fragment with a scoped <style> block containing submitted token values", async () => { 906 + setupAuth([MANAGE_THEMES]); 907 + 908 + const routes = await loadThemeRoutes(); 909 + const body = new URLSearchParams({ 910 + "color-bg": "#ff0000", 911 + "color-text": "#0000ff", 912 + }); 913 + const res = await routes.request("/admin/themes/abc123/preview", { 914 + method: "POST", 915 + headers: { 916 + "content-type": "application/x-www-form-urlencoded", 917 + cookie: "atbb_session=token", 918 + }, 919 + body: body.toString(), 920 + }); 921 + 922 + expect(res.status).toBe(200); 923 + const html = await res.text(); 924 + expect(html).toContain("--color-bg"); 925 + expect(html).toContain("#ff0000"); 926 + expect(html).toContain("--color-text"); 927 + expect(html).toContain("#0000ff"); 928 + expect(html).toContain(".preview-pane-inner"); 929 + // Should NOT have full page HTML — this is a fragment 930 + expect(html).not.toContain("<html"); 931 + expect(html).not.toContain("<BaseLayout"); 932 + }); 933 + 934 + it("drops token values containing '<' (sanitization)", async () => { 935 + setupAuth([MANAGE_THEMES]); 936 + 937 + const routes = await loadThemeRoutes(); 938 + const body = new URLSearchParams({ 939 + "color-bg": "<script>alert(1)</script>", 940 + "color-text": "#1a1a1a", 941 + }); 942 + const res = await routes.request("/admin/themes/abc123/preview", { 943 + method: "POST", 944 + headers: { 945 + "content-type": "application/x-www-form-urlencoded", 946 + cookie: "atbb_session=token", 947 + }, 948 + body: body.toString(), 949 + }); 950 + 951 + expect(res.status).toBe(200); 952 + const html = await res.text(); 953 + // The malicious value must not appear 954 + expect(html).not.toContain("<script>"); 955 + expect(html).not.toContain("alert(1)"); 956 + }); 957 + 958 + it("drops token values containing ';' (sanitization)", async () => { 959 + setupAuth([MANAGE_THEMES]); 960 + 961 + const routes = await loadThemeRoutes(); 962 + const body = new URLSearchParams({ 963 + "color-bg": "red; --injected: 1", 964 + }); 965 + const res = await routes.request("/admin/themes/abc123/preview", { 966 + method: "POST", 967 + headers: { 968 + "content-type": "application/x-www-form-urlencoded", 969 + cookie: "atbb_session=token", 970 + }, 971 + body: body.toString(), 972 + }); 973 + 974 + const html = await res.text(); 975 + expect(html).not.toContain("--injected"); 976 + }); 977 + }); 978 + ``` 979 + 980 + **Step 2: Run tests to verify they fail** 981 + 982 + ```bash 983 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 984 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -E "preview|FAIL|PASS" | head -20 985 + ``` 986 + 987 + Expected: New preview tests fail. 988 + 989 + --- 990 + 991 + ## Task 6: Implement `POST /admin/themes/:rkey/preview` 992 + 993 + **Files:** 994 + - Modify: `apps/web/src/routes/admin-themes.tsx` 995 + 996 + **Step 1: Add the route handler inside `createAdminThemeRoutes`, after the GET handler** 997 + 998 + ```tsx 999 + // ── POST /admin/themes/:rkey/preview ───────────────────────────────────── 1000 + 1001 + app.post("/admin/themes/:rkey/preview", async (c) => { 1002 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1003 + if (!auth.authenticated) return c.redirect("/login"); 1004 + 1005 + let rawBody: Record<string, string | File>; 1006 + try { 1007 + rawBody = await c.req.parseBody(); 1008 + } catch (error) { 1009 + if (isProgrammingError(error)) throw error; 1010 + // Return empty preview on parse error — don't break the HTMX swap 1011 + return c.html(<ThemePreviewContent tokens={{}} />); 1012 + } 1013 + 1014 + // Build token map from only known token names (ignore unknown fields like name/colorScheme) 1015 + const tokens: Record<string, string> = {}; 1016 + for (const tokenName of ALL_KNOWN_TOKENS) { 1017 + const raw = rawBody[tokenName]; 1018 + if (typeof raw !== "string") continue; 1019 + const safe = sanitizeTokenValue(raw); 1020 + if (safe !== null) { 1021 + tokens[tokenName] = safe; 1022 + } 1023 + } 1024 + 1025 + return c.html(<ThemePreviewContent tokens={tokens} />); 1026 + }); 1027 + ``` 1028 + 1029 + **Step 2: Run the tests** 1030 + 1031 + ```bash 1032 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1033 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "preview" 1034 + ``` 1035 + 1036 + Expected: All preview tests pass. 1037 + 1038 + **Step 3: Commit** 1039 + 1040 + ```bash 1041 + git add apps/web/src/routes/admin-themes.tsx 1042 + git commit -m "feat(web): POST /admin/themes/:rkey/preview — HTMX live preview endpoint (ATB-59)" 1043 + ``` 1044 + 1045 + --- 1046 + 1047 + ## Task 7: Write tests for `POST /admin/themes/:rkey/save` 1048 + 1049 + **Files:** 1050 + - Modify: `apps/web/src/routes/__tests__/admin-themes.test.tsx` 1051 + 1052 + **Step 1: Add describe block** 1053 + 1054 + ```tsx 1055 + describe("createAdminThemeRoutes — POST /admin/themes/:rkey/save", () => { 1056 + beforeEach(() => { 1057 + vi.stubGlobal("fetch", mockFetch); 1058 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1059 + vi.resetModules(); 1060 + }); 1061 + 1062 + afterEach(() => { 1063 + vi.unstubAllGlobals(); 1064 + vi.unstubAllEnvs(); 1065 + mockFetch.mockReset(); 1066 + }); 1067 + 1068 + function mockResponse(body: unknown, ok = true, status = 200) { 1069 + return { 1070 + ok, status, 1071 + statusText: ok ? "OK" : "Error", 1072 + json: () => Promise.resolve(body), 1073 + }; 1074 + } 1075 + 1076 + function setupAuth(permissions: string[]) { 1077 + mockFetch.mockResolvedValueOnce( 1078 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" }) 1079 + ); 1080 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1081 + } 1082 + 1083 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 1084 + 1085 + async function loadThemeRoutes() { 1086 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 1087 + return createAdminThemeRoutes("http://localhost:3000"); 1088 + } 1089 + 1090 + function makeFormBody(overrides: Record<string, string> = {}): string { 1091 + return new URLSearchParams({ 1092 + name: "My Theme", 1093 + colorScheme: "light", 1094 + fontUrls: "", 1095 + "color-bg": "#f5f0e8", 1096 + ...overrides, 1097 + }).toString(); 1098 + } 1099 + 1100 + it("redirects to ?success=1 on AppView 200", async () => { 1101 + setupAuth([MANAGE_THEMES]); 1102 + mockFetch.mockResolvedValueOnce(mockResponse({ id: "1", name: "My Theme" })); 1103 + 1104 + const routes = await loadThemeRoutes(); 1105 + const res = await routes.request("/admin/themes/abc123/save", { 1106 + method: "POST", 1107 + headers: { 1108 + "content-type": "application/x-www-form-urlencoded", 1109 + cookie: "atbb_session=token", 1110 + }, 1111 + body: makeFormBody(), 1112 + }); 1113 + 1114 + expect(res.status).toBe(302); 1115 + expect(res.headers.get("location")).toContain("/admin/themes/abc123"); 1116 + expect(res.headers.get("location")).toContain("success=1"); 1117 + }); 1118 + 1119 + it("redirects with ?error=<msg> when AppView returns 400", async () => { 1120 + setupAuth([MANAGE_THEMES]); 1121 + mockFetch.mockResolvedValueOnce( 1122 + mockResponse({ error: "Name is required" }, false, 400) 1123 + ); 1124 + 1125 + const routes = await loadThemeRoutes(); 1126 + const res = await routes.request("/admin/themes/abc123/save", { 1127 + method: "POST", 1128 + headers: { 1129 + "content-type": "application/x-www-form-urlencoded", 1130 + cookie: "atbb_session=token", 1131 + }, 1132 + body: makeFormBody({ name: "" }), 1133 + }); 1134 + 1135 + expect(res.status).toBe(302); 1136 + const location = res.headers.get("location") ?? ""; 1137 + expect(location).toContain("error="); 1138 + expect(decodeURIComponent(location)).toContain("Name is required"); 1139 + }); 1140 + 1141 + it("redirects with generic error on network failure", async () => { 1142 + setupAuth([MANAGE_THEMES]); 1143 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1144 + 1145 + const routes = await loadThemeRoutes(); 1146 + const res = await routes.request("/admin/themes/abc123/save", { 1147 + method: "POST", 1148 + headers: { 1149 + "content-type": "application/x-www-form-urlencoded", 1150 + cookie: "atbb_session=token", 1151 + }, 1152 + body: makeFormBody(), 1153 + }); 1154 + 1155 + expect(res.status).toBe(302); 1156 + const location = res.headers.get("location") ?? ""; 1157 + expect(location).toContain("error="); 1158 + expect(decodeURIComponent(location).toLowerCase()).toContain("unavailable"); 1159 + }); 1160 + 1161 + it("redirects unauthenticated users to /login", async () => { 1162 + const routes = await loadThemeRoutes(); 1163 + const res = await routes.request("/admin/themes/abc123/save", { 1164 + method: "POST", 1165 + headers: { "content-type": "application/x-www-form-urlencoded" }, 1166 + body: makeFormBody(), 1167 + }); 1168 + expect(res.status).toBe(302); 1169 + expect(res.headers.get("location")).toBe("/login"); 1170 + }); 1171 + }); 1172 + ``` 1173 + 1174 + **Step 2: Verify tests fail** 1175 + 1176 + ```bash 1177 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1178 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "save" 1179 + ``` 1180 + 1181 + Expected: New save tests fail. 1182 + 1183 + --- 1184 + 1185 + ## Task 8: Implement `POST /admin/themes/:rkey/save` 1186 + 1187 + **Files:** 1188 + - Modify: `apps/web/src/routes/admin-themes.tsx` 1189 + 1190 + **Step 1: Add the route handler after the preview handler** 1191 + 1192 + ```tsx 1193 + // ── POST /admin/themes/:rkey/save ───────────────────────────────────────── 1194 + 1195 + app.post("/admin/themes/:rkey/save", async (c) => { 1196 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1197 + if (!auth.authenticated) return c.redirect("/login"); 1198 + if (!canManageThemes(auth)) { 1199 + return c.html( 1200 + <BaseLayout title="Access Denied" auth={auth}> 1201 + <p>Access denied.</p> 1202 + </BaseLayout>, 1203 + 403 1204 + ); 1205 + } 1206 + 1207 + const themeRkey = c.req.param("rkey"); 1208 + const cookie = c.req.header("cookie") ?? ""; 1209 + 1210 + let rawBody: Record<string, string | File>; 1211 + try { 1212 + rawBody = await c.req.parseBody(); 1213 + } catch (error) { 1214 + if (isProgrammingError(error)) throw error; 1215 + return c.redirect( 1216 + `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid form submission.")}`, 1217 + 302 1218 + ); 1219 + } 1220 + 1221 + const name = typeof rawBody.name === "string" ? rawBody.name.trim() : ""; 1222 + const colorScheme = typeof rawBody.colorScheme === "string" ? rawBody.colorScheme : "light"; 1223 + const fontUrlsRaw = typeof rawBody.fontUrls === "string" ? rawBody.fontUrls : ""; 1224 + const fontUrls = fontUrlsRaw 1225 + .split("\n") 1226 + .map((u) => u.trim()) 1227 + .filter(Boolean); 1228 + 1229 + // Extract token values from form fields 1230 + const tokens: Record<string, string> = {}; 1231 + for (const tokenName of ALL_KNOWN_TOKENS) { 1232 + const raw = rawBody[tokenName]; 1233 + if (typeof raw === "string" && raw.trim()) { 1234 + tokens[tokenName] = raw.trim(); 1235 + } 1236 + } 1237 + 1238 + let apiRes: Response; 1239 + try { 1240 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 1241 + method: "PUT", 1242 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1243 + body: JSON.stringify({ name, colorScheme, tokens, fontUrls }), 1244 + }); 1245 + } catch (error) { 1246 + if (isProgrammingError(error)) throw error; 1247 + logger.error("Network error saving theme", { 1248 + operation: "POST /admin/themes/:rkey/save", 1249 + themeRkey, 1250 + error: error instanceof Error ? error.message : String(error), 1251 + }); 1252 + return c.redirect( 1253 + `/admin/themes/${themeRkey}?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1254 + 302 1255 + ); 1256 + } 1257 + 1258 + if (!apiRes.ok) { 1259 + const msg = await extractAppviewError(apiRes, "Failed to save theme. Please try again."); 1260 + return c.redirect( 1261 + `/admin/themes/${themeRkey}?error=${encodeURIComponent(msg)}`, 1262 + 302 1263 + ); 1264 + } 1265 + 1266 + return c.redirect(`/admin/themes/${themeRkey}?success=1`, 302); 1267 + }); 1268 + ``` 1269 + 1270 + **Step 2: Run the tests** 1271 + 1272 + ```bash 1273 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1274 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "save" 1275 + ``` 1276 + 1277 + Expected: All save tests pass. 1278 + 1279 + **Step 3: Commit** 1280 + 1281 + ```bash 1282 + git add apps/web/src/routes/admin-themes.tsx 1283 + git commit -m "feat(web): POST /admin/themes/:rkey/save — persist token edits to AppView (ATB-59)" 1284 + ``` 1285 + 1286 + --- 1287 + 1288 + ## Task 9: Write tests for `POST /admin/themes/:rkey/reset-to-preset` 1289 + 1290 + **Files:** 1291 + - Modify: `apps/web/src/routes/__tests__/admin-themes.test.tsx` 1292 + 1293 + **Step 1: Add describe block** 1294 + 1295 + ```tsx 1296 + describe("createAdminThemeRoutes — POST /admin/themes/:rkey/reset-to-preset", () => { 1297 + beforeEach(() => { 1298 + vi.stubGlobal("fetch", mockFetch); 1299 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1300 + vi.resetModules(); 1301 + }); 1302 + 1303 + afterEach(() => { 1304 + vi.unstubAllGlobals(); 1305 + vi.unstubAllEnvs(); 1306 + mockFetch.mockReset(); 1307 + }); 1308 + 1309 + function mockResponse(body: unknown, ok = true, status = 200) { 1310 + return { 1311 + ok, status, 1312 + statusText: ok ? "OK" : "Error", 1313 + json: () => Promise.resolve(body), 1314 + }; 1315 + } 1316 + 1317 + function setupAuth(permissions: string[]) { 1318 + mockFetch.mockResolvedValueOnce( 1319 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" }) 1320 + ); 1321 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1322 + } 1323 + 1324 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 1325 + 1326 + async function loadThemeRoutes() { 1327 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 1328 + return createAdminThemeRoutes("http://localhost:3000"); 1329 + } 1330 + 1331 + it("redirects to ?preset=neobrutal-light for valid preset", async () => { 1332 + setupAuth([MANAGE_THEMES]); 1333 + 1334 + const routes = await loadThemeRoutes(); 1335 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 1336 + method: "POST", 1337 + headers: { 1338 + "content-type": "application/x-www-form-urlencoded", 1339 + cookie: "atbb_session=token", 1340 + }, 1341 + body: new URLSearchParams({ preset: "neobrutal-light" }).toString(), 1342 + }); 1343 + 1344 + expect(res.status).toBe(302); 1345 + expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=neobrutal-light"); 1346 + }); 1347 + 1348 + it("redirects to ?preset=neobrutal-dark for dark preset", async () => { 1349 + setupAuth([MANAGE_THEMES]); 1350 + 1351 + const routes = await loadThemeRoutes(); 1352 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 1353 + method: "POST", 1354 + headers: { 1355 + "content-type": "application/x-www-form-urlencoded", 1356 + cookie: "atbb_session=token", 1357 + }, 1358 + body: new URLSearchParams({ preset: "neobrutal-dark" }).toString(), 1359 + }); 1360 + 1361 + expect(res.status).toBe(302); 1362 + expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=neobrutal-dark"); 1363 + }); 1364 + 1365 + it("redirects to ?preset=blank for blank preset", async () => { 1366 + setupAuth([MANAGE_THEMES]); 1367 + 1368 + const routes = await loadThemeRoutes(); 1369 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 1370 + method: "POST", 1371 + headers: { 1372 + "content-type": "application/x-www-form-urlencoded", 1373 + cookie: "atbb_session=token", 1374 + }, 1375 + body: new URLSearchParams({ preset: "blank" }).toString(), 1376 + }); 1377 + 1378 + expect(res.status).toBe(302); 1379 + expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=blank"); 1380 + }); 1381 + 1382 + it("returns 400 for unknown preset name", async () => { 1383 + setupAuth([MANAGE_THEMES]); 1384 + 1385 + const routes = await loadThemeRoutes(); 1386 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 1387 + method: "POST", 1388 + headers: { 1389 + "content-type": "application/x-www-form-urlencoded", 1390 + cookie: "atbb_session=token", 1391 + }, 1392 + body: new URLSearchParams({ preset: "hacked" }).toString(), 1393 + }); 1394 + 1395 + expect(res.status).toBe(400); 1396 + }); 1397 + 1398 + it("redirects unauthenticated users to /login", async () => { 1399 + const routes = await loadThemeRoutes(); 1400 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 1401 + method: "POST", 1402 + headers: { "content-type": "application/x-www-form-urlencoded" }, 1403 + body: new URLSearchParams({ preset: "neobrutal-light" }).toString(), 1404 + }); 1405 + expect(res.status).toBe(302); 1406 + expect(res.headers.get("location")).toBe("/login"); 1407 + }); 1408 + }); 1409 + ``` 1410 + 1411 + **Step 2: Verify tests fail** 1412 + 1413 + ```bash 1414 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1415 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "reset" 1416 + ``` 1417 + 1418 + --- 1419 + 1420 + ## Task 10: Implement `POST /admin/themes/:rkey/reset-to-preset` 1421 + 1422 + **Files:** 1423 + - Modify: `apps/web/src/routes/admin-themes.tsx` 1424 + 1425 + **Step 1: Add the route handler** 1426 + 1427 + ```tsx 1428 + // ── POST /admin/themes/:rkey/reset-to-preset ────────────────────────────── 1429 + 1430 + app.post("/admin/themes/:rkey/reset-to-preset", async (c) => { 1431 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1432 + if (!auth.authenticated) return c.redirect("/login"); 1433 + if (!canManageThemes(auth)) { 1434 + return c.html( 1435 + <BaseLayout title="Access Denied" auth={auth}> 1436 + <p>Access denied.</p> 1437 + </BaseLayout>, 1438 + 403 1439 + ); 1440 + } 1441 + 1442 + const themeRkey = c.req.param("rkey"); 1443 + 1444 + let body: Record<string, string | File>; 1445 + try { 1446 + body = await c.req.parseBody(); 1447 + } catch (error) { 1448 + if (isProgrammingError(error)) throw error; 1449 + return c.json({ error: "Invalid form submission." }, 400); 1450 + } 1451 + 1452 + const preset = typeof body.preset === "string" ? body.preset : ""; 1453 + if (!(preset in THEME_PRESETS)) { 1454 + return c.json({ error: `Unknown preset: ${preset}` }, 400); 1455 + } 1456 + 1457 + return c.redirect(`/admin/themes/${themeRkey}?preset=${encodeURIComponent(preset)}`, 302); 1458 + }); 1459 + ``` 1460 + 1461 + **Step 2: Run all tests** 1462 + 1463 + ```bash 1464 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1465 + pnpm --filter @atbb/web test 2>&1 | tail -20 1466 + ``` 1467 + 1468 + Expected: All tests pass. 1469 + 1470 + **Step 3: Commit** 1471 + 1472 + ```bash 1473 + git add apps/web/src/routes/admin-themes.tsx apps/web/src/routes/__tests__/admin-themes.test.tsx 1474 + git commit -m "feat(web): POST /admin/themes/:rkey/reset-to-preset + all tests (ATB-59)" 1475 + ``` 1476 + 1477 + --- 1478 + 1479 + ## Task 11: Full test suite + lint + Linear update 1480 + 1481 + **Step 1: Run the full test suite** 1482 + 1483 + ```bash 1484 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1485 + pnpm test 2>&1 | tail -30 1486 + ``` 1487 + 1488 + Expected: All packages pass. 1489 + 1490 + **Step 2: Run lint fix** 1491 + 1492 + ```bash 1493 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1494 + pnpm --filter @atbb/web lint:fix 1495 + ``` 1496 + 1497 + Fix any lint errors that appear. 1498 + 1499 + **Step 3: If any lint fixes were needed, commit them** 1500 + 1501 + ```bash 1502 + git add -A 1503 + git commit -m "style(web): lint fixes for ATB-59 theme editor" 1504 + ``` 1505 + 1506 + **Step 4: Update Linear issue ATB-59** 1507 + - Change status to **In Progress** → **In Review** (or Done after review) 1508 + - Add a comment listing what was implemented 1509 + 1510 + --- 1511 + 1512 + ## What We Did NOT Implement (per spec) 1513 + 1514 + - `cssOverrides` editor — disabled, awaiting ATB-62 (CSS sanitization) 1515 + - Font URL validation — currently stored as-is; proper HTTPS URL validation can be added in ATB-62 1516 + - Import/export JSON — listed in theming-plan.md Phase 3/5 but not in ATB-59 scope 1517 + - User theme picker — ATB-60 scope 1518 + 1519 + --- 1520 + 1521 + ## Known Token JSON Import Note 1522 + 1523 + If TypeScript complains about `import ... assert { type: "json" }`, check how it was done in the existing `admin.tsx`: 1524 + 1525 + ```typescript 1526 + // admin.tsx already uses: 1527 + import neobrutalLight from "../styles/presets/neobrutal-light.json"; 1528 + import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 1529 + ``` 1530 + 1531 + Use the same syntax (without `assert { type: "json" }`) if that's what the project's tsconfig supports.
+273
docs/plans/2026-03-03-atb59-theme-token-editor-design.md
··· 1 + # ATB-59 Design: Admin Theme Token Editor with Live Preview 2 + 3 + **Linear:** ATB-59 4 + **Date:** 2026-03-03 5 + **Status:** Approved — ready for implementation 6 + **Depends on:** ATB-57 (Done), ATB-58 (Done) 7 + 8 + --- 9 + 10 + ## Overview 11 + 12 + Build the theme token editor page at `GET /admin/themes/:rkey` and a HTMX-driven preview endpoint that shows a live sample forum page as token values change. The editor lets admins customize all 46 design tokens through grouped form controls, save to the PDS via the AppView, and reset to any built-in preset. 13 + 14 + --- 15 + 16 + ## Architecture 17 + 18 + ### File Structure Changes 19 + 20 + **New file:** `apps/web/src/routes/admin-themes.tsx` 21 + 22 + Extract all existing theme-admin handlers from `admin.tsx` into a dedicated module and add the new editor and preview handlers there. 23 + 24 + | Route | Handler Location | Description | 25 + |-------|-----------------|-------------| 26 + | `GET /admin/themes` | moved from admin.tsx | Theme list page | 27 + | `POST /admin/themes` | moved | Create theme | 28 + | `POST /admin/themes/:rkey/duplicate` | moved | Duplicate theme | 29 + | `POST /admin/themes/:rkey/delete` | moved | Delete theme | 30 + | `POST /admin/theme-policy` | moved | Update policy | 31 + | `GET /admin/themes/:rkey` | **new** | Token editor page | 32 + | `POST /admin/themes/:rkey/preview` | **new** | HTMX preview fragment | 33 + | `POST /admin/themes/:rkey/save` | **new** | Save tokens to AppView | 34 + | `POST /admin/themes/:rkey/reset-to-preset` | **new** | Redirect with preset query param | 35 + 36 + In `admin.tsx`, replace all theme handler blocks with: 37 + 38 + ```typescript 39 + import { createAdminThemeRoutes } from "./admin-themes.js"; 40 + const themeRoutes = createAdminThemeRoutes(appviewUrl); 41 + app.route("/", themeRoutes); 42 + ``` 43 + 44 + The `createAdminThemeRoutes(appviewUrl: string)` factory matches the existing pattern for route modules in this project. 45 + 46 + --- 47 + 48 + ## Editor Page — `GET /admin/themes/:rkey` 49 + 50 + ### Data Loading 51 + 52 + 1. Fetch `GET /api/admin/themes/:rkey` from the AppView (requires `manageThemes` session cookie) 53 + 2. If `?preset=<name>` is present in the query string, merge preset tokens over the DB tokens for initial input values (enables the reset-to-preset flow) 54 + 3. If `?success=1`, show a success banner 55 + 4. If `?error=<msg>`, show an error banner 56 + 57 + ### Layout 58 + 59 + Two-column layout on desktop, stacked on mobile: 60 + 61 + ``` 62 + ┌─ Metadata ─────────────────────────────────────────────────┐ 63 + │ Name: [_________________] Color Scheme: [light ▾] │ 64 + │ Font URLs: [textarea — one per line] │ 65 + └──────────────────────────────────────────────────────────────┘ 66 + 67 + ┌─ Token Editor (~60%) ──────┐ ┌─ Live Preview (~40%) ───────┐ 68 + │ <fieldset> Colors │ │ <div id="preview-pane" │ 69 + │ color-bg [■] [#f5f0e8] │ │ hx-swap-oob="true"> │ 70 + │ color-text [■] [#1a1a1a]│ │ │ 71 + │ ... (14 color tokens) │ │ <style>:root{--tokens}</style> 72 + │ </fieldset> │ │ [Sample: card, button, │ 73 + │ │ │ heading, text, code, │ 74 + │ <fieldset> Typography │ │ input, nav strip] │ 75 + │ font-body [___________] │ │ │ 76 + │ font-size-base [16] px │ │ Updates on every change │ 77 + │ ... (13 tokens) │ │ via hx-trigger="change │ 78 + │ </fieldset> │ │ delay:300ms" │ 79 + │ │ └──────────────────────────────┘ 80 + │ <fieldset> Spacing & Layout│ 81 + │ space-xs [____] │ 82 + │ radius [____] │ 83 + │ ... (9 tokens) │ 84 + │ </fieldset> │ 85 + │ │ 86 + │ <fieldset> Components │ 87 + │ button-shadow [________] │ (plain text — CSS shorthand) 88 + │ card-shadow [________] │ 89 + │ ... (10 tokens) │ 90 + │ </fieldset> │ 91 + │ │ 92 + │ [Save] [Reset to Preset] │ 93 + │ [← Back to themes] │ 94 + └─────────────────────────────┘ 95 + ``` 96 + 97 + ### Token Groups (all 46 tokens from preset JSON) 98 + 99 + **Colors (14):** `color-bg`, `color-surface`, `color-text`, `color-text-muted`, `color-primary`, `color-primary-hover`, `color-secondary`, `color-border`, `color-shadow`, `color-success`, `color-warning`, `color-danger`, `color-code-bg`, `color-code-text` 100 + - Input: `<input type="color">` + adjacent text input for hex value 101 + 102 + **Typography (13):** `font-body`, `font-heading`, `font-mono`, `font-size-base`, `font-size-sm`, `font-size-xs`, `font-size-lg`, `font-size-xl`, `font-size-2xl`, `font-weight-normal`, `font-weight-bold`, `line-height-body`, `line-height-heading` 103 + - Font families: text inputs; sizes: text inputs with `px` unit hint 104 + 105 + **Spacing & Layout (9):** `space-xs`, `space-sm`, `space-md`, `space-lg`, `space-xl`, `radius`, `border-width`, `shadow-offset`, `content-width` 106 + - Input: text inputs 107 + 108 + **Components (10):** `button-radius`, `button-shadow`, `card-radius`, `card-shadow`, `btn-press-hover`, `btn-press-active`, `input-radius`, `input-border`, `nav-height` 109 + - Input: plain text inputs (CSS shorthand values) 110 + 111 + ### HTMX Wiring 112 + 113 + The token fieldsets (not the metadata inputs) carry: 114 + 115 + ```html 116 + <form id="token-form" 117 + hx-post="/admin/themes/:rkey/preview" 118 + hx-trigger="change delay:300ms from:find input, change delay:300ms from:find select" 119 + hx-target="#preview-pane" 120 + hx-swap="innerHTML"> 121 + ``` 122 + 123 + This debounces preview updates 300ms after any input change. 124 + 125 + ### Reset to Preset Dialog 126 + 127 + ```html 128 + <dialog id="reset-dialog"> 129 + <form method="post" action="/admin/themes/:rkey/reset-to-preset"> 130 + <label for="reset-preset">Reset tokens to:</label> 131 + <select name="preset" id="reset-preset"> 132 + <option value="neobrutal-light">Neobrutal Light</option> 133 + <option value="neobrutal-dark">Neobrutal Dark</option> 134 + <option value="blank">Blank (empty tokens)</option> 135 + </select> 136 + <p>This will replace all token values. Your current changes will be lost.</p> 137 + <button type="submit">Reset</button> 138 + <button type="button" onclick="document.getElementById('reset-dialog').close()">Cancel</button> 139 + </form> 140 + </dialog> 141 + <button type="button" onclick="document.getElementById('reset-dialog').showModal()"> 142 + Reset to Preset 143 + </button> 144 + ``` 145 + 146 + ### CSS Override Field 147 + 148 + Hidden/disabled pending ATB-62 (CSS sanitization). Render a disabled textarea with a note explaining it will be available after sanitization is implemented. 149 + 150 + --- 151 + 152 + ## Preview Endpoint — `POST /admin/themes/:rkey/preview` 153 + 154 + **This is a pure web-server computation — no AppView call, no DB, no PDS write.** 155 + 156 + ### Request 157 + 158 + Form data containing all token name/value pairs submitted by the HTMX form. 159 + 160 + ### Processing 161 + 162 + 1. Parse form fields into `tokens: Record<string, string>` 163 + 2. Sanitize each value: reject values containing `<`, `;` outside strings, or that look like injected markup. For color tokens, validate hex format. 164 + 3. Call `tokensToCss(tokens)` from `apps/web/src/lib/theme.ts` 165 + 4. Build and return an HTML fragment 166 + 167 + ### Response 168 + 169 + ```html 170 + <style> 171 + :root { 172 + --color-bg: #f5f0e8; 173 + --color-text: #1a1a1a; 174 + /* ... all 46 tokens */ 175 + } 176 + </style> 177 + 178 + <div class="preview-sample"> 179 + <!-- Representative forum elements using the theme classes --> 180 + <nav class="preview-nav">atBB Forum Preview</nav> 181 + <div class="card"> 182 + <h2>Sample Thread Title</h2> 183 + <p>Body text showing font, color, and spacing tokens at work.</p> 184 + <code>const example = "code block";</code> 185 + <input type="text" placeholder="Reply..." /> 186 + <button class="btn-primary">Post Reply</button> 187 + </div> 188 + </div> 189 + ``` 190 + 191 + The sample HTML uses existing `.card`, `.btn-primary`, and other CSS classes from `theme.css` so the preview reflects the real design system. 192 + 193 + --- 194 + 195 + ## Save Flow — `POST /admin/themes/:rkey/save` 196 + 197 + 1. Parse form: `name` (string), `colorScheme` (light|dark), `fontUrls` (string → split by newline), plus all 46 token key/value pairs 198 + 2. Build `tokens: Record<string, string>` from the token fields 199 + 3. `fetch(PUT /api/admin/themes/:rkey, { body: JSON.stringify({ name, colorScheme, tokens, fontUrls }) })` 200 + 4. On success (2xx) → `redirect /admin/themes/:rkey?success=1` 201 + 5. On AppView 4xx → extract error message → `redirect /admin/themes/:rkey?error=<msg>` 202 + 6. On network error → `redirect /admin/themes/:rkey?error=Forum+temporarily+unavailable` 203 + 204 + --- 205 + 206 + ## Reset Flow — `POST /admin/themes/:rkey/reset-to-preset` 207 + 208 + 1. Parse `preset` from body 209 + 2. Validate: must be one of `neobrutal-light`, `neobrutal-dark`, `blank`; otherwise return 400 210 + 3. On valid preset → `redirect /admin/themes/:rkey?preset=<name>` 211 + 212 + The `GET /admin/themes/:rkey` handler already handles `?preset=<name>` by loading preset tokens from the imported JSON files and using them as the initial input values instead of the DB values. 213 + 214 + --- 215 + 216 + ## Error Handling 217 + 218 + | Scenario | Behavior | 219 + |----------|----------| 220 + | Theme not found (AppView 404) | 404 page | 221 + | Network error loading theme | Error banner, no crash | 222 + | Unauthenticated | Redirect to /login | 223 + | No manageThemes permission | 403 page | 224 + | Preview parse error | Return preview pane with fallback style | 225 + | Save AppView failure | Redirect with `?error=<message>` | 226 + | Save network error | Redirect with generic error message | 227 + | Invalid preset name in reset | 400 response | 228 + 229 + --- 230 + 231 + ## Testing Plan 232 + 233 + All tests added to `apps/web/src/routes/__tests__/admin.test.tsx` (or a new `admin-themes.test.tsx` if extracted). 234 + 235 + **`GET /admin/themes/:rkey`** 236 + - Redirects unauthenticated users to /login 237 + - Returns 403 for users without manageThemes permission 238 + - Returns 404 for unknown rkey 239 + - Renders editor with correct initial token values from theme data 240 + - `?preset=neobrutal-light` populates inputs from preset instead of DB values 241 + - `?success=1` shows success banner 242 + - `?error=<msg>` shows error banner 243 + - CSS overrides field is rendered disabled 244 + 245 + **`POST /admin/themes/:rkey/preview`** 246 + - Returns HTML fragment containing a `<style>` block with submitted token values 247 + - Sanitizes malicious input (drops values containing `<` or `;`) 248 + - Returns a fallback preview on parse error (doesn't crash) 249 + 250 + **`POST /admin/themes/:rkey/save`** 251 + - Redirects to `?success=1` on AppView 2xx 252 + - Redirects with `?error=<msg>` on AppView 4xx 253 + - Redirects with generic error on network failure 254 + 255 + **`POST /admin/themes/:rkey/reset-to-preset`** 256 + - Redirects to `?preset=neobrutal-light` for valid preset name 257 + - Returns 400 for unknown preset name 258 + 259 + --- 260 + 261 + ## Bruno Collection Update 262 + 263 + Add to `bruno/AppView API/Admin Themes/`: 264 + - No new AppView endpoints are introduced in ATB-59 (preview is web-only; save reuses the existing `PUT /api/admin/themes/:rkey`). The existing `Update Theme.bru` file covers the save path. 265 + 266 + --- 267 + 268 + ## Implementation Notes 269 + 270 + - The `THEME_PRESETS` constant (already in `admin.tsx`) moves to `admin-themes.tsx` 271 + - `tokensToCss()` is imported from `apps/web/src/lib/theme.ts` — already exists, no changes needed 272 + - The Edit button in the theme list (`aria-disabled="true"`) becomes a real `<a href="/admin/themes/:rkey">` link 273 + - `admin.tsx` imports `createAdminThemeRoutes` and mounts it — all other theme-handler blocks are deleted
+115
docs/plans/complete/2026-03-04-atb53-theme-resolution-design.md
··· 1 + # ATB-53: Theme Resolution and Server-Side Token Injection — Design 2 + 3 + **Status:** Approved 4 + **Linear:** [ATB-53](https://linear.app/atbb/issue/ATB-53) 5 + **Branch:** `atb-59-theme-editor` (active) → target `main` 6 + 7 + --- 8 + 9 + ## Context 10 + 11 + Dependencies are complete: 12 + - ATB-51 (lexicons): Done 13 + - ATB-52 (CSS token system): Done 14 + - ATB-55 (theme read API): Done 15 + - ATB-57 (theme write API): Done 16 + 17 + `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. 18 + 19 + --- 20 + 21 + ## Architecture 22 + 23 + ### Middleware approach 24 + 25 + 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. 26 + 27 + ### Resolution waterfall 28 + 29 + ``` 30 + 1. User preference — always null for now (TODO: populate when 31 + space.atbb.membership.preferredTheme is added in Theme Phase 4) 32 + 2. Color scheme detection — atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light" 33 + 3. Forum default — fetch GET /api/theme-policy, pick defaultLightThemeUri or 34 + defaultDarkThemeUri, fetch full theme, CID integrity check 35 + 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light tokens + Google Fonts URL) 36 + ``` 37 + 38 + Any error at any step catches, logs, and returns `FALLBACK_THEME` with the detected color scheme. 39 + 40 + ### CID integrity check 41 + 42 + 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). 43 + 44 + ### Caching 45 + 46 + 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. 47 + 48 + --- 49 + 50 + ## Types 51 + 52 + ```typescript 53 + // apps/web/src/lib/theme-resolution.ts 54 + 55 + export type ResolvedTheme = { 56 + tokens: Record<string, string>; 57 + cssOverrides: string | null; 58 + fontUrls: string[] | null; 59 + colorScheme: "light" | "dark"; 60 + }; 61 + 62 + export type WebAppEnv = { 63 + Variables: { theme: ResolvedTheme }; 64 + }; 65 + 66 + export const FALLBACK_THEME: ResolvedTheme = { 67 + tokens: neobrutalLight, 68 + cssOverrides: null, 69 + fontUrls: ["https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap"], 70 + colorScheme: "light", 71 + }; 72 + ``` 73 + 74 + --- 75 + 76 + ## BaseLayout changes 77 + 78 + `resolvedTheme: ResolvedTheme` becomes a required prop. Removes: 79 + - Module-level `ROOT_CSS` constant and `neobrutal-light` import 80 + - Hardcoded Google Fonts `<link>` tags (moved to `FALLBACK_THEME.fontUrls`) 81 + 82 + Adds: 83 + - `<meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" />` 84 + - Dynamic `:root { ... }` `<style>` block from `tokensToCss(resolvedTheme.tokens)` 85 + - `resolvedTheme.fontUrls` rendered as `<link rel="stylesheet">` tags (with `<link rel="preconnect">` to googleapis if any font URLs are present) 86 + - `resolvedTheme.cssOverrides` rendered as an additional `<style>` block if non-null 87 + 88 + --- 89 + 90 + ## Files Changed 91 + 92 + | File | Change | 93 + |------|--------| 94 + | `apps/appview/src/routes/themes.ts` | Add `cid` to `serializeThemeFull` | 95 + | `apps/web/src/lib/theme-resolution.ts` | **New** — `ResolvedTheme`, `WebAppEnv`, `FALLBACK_THEME`, `detectColorScheme`, `parseRkeyFromUri`, `resolveTheme` | 96 + | `apps/web/src/middleware/theme.ts` | **New** — Hono middleware wrapping `resolveTheme` | 97 + | `apps/web/src/routes/index.ts` | Add `WebAppEnv` type + `createThemeMiddleware` before routes | 98 + | `apps/web/src/layouts/base.tsx` | Dynamic theme prop, `Accept-CH`, font URLs, cssOverrides | 99 + | `apps/web/src/routes/home.tsx` | `new Hono<WebAppEnv>()`, pass theme to BaseLayout | 100 + | `apps/web/src/routes/boards.tsx` | Same | 101 + | `apps/web/src/routes/topics.tsx` | Same | 102 + | `apps/web/src/routes/login.tsx` | Same | 103 + | `apps/web/src/routes/new-topic.tsx` | Same | 104 + | `apps/web/src/routes/admin.tsx` | Same | 105 + | `apps/web/src/routes/not-found.tsx` | Same | 106 + | `apps/web/src/routes/auth.ts` | `new Hono<WebAppEnv>()` only (no HTML rendering) | 107 + | `apps/web/src/routes/mod.ts` | Same | 108 + 109 + --- 110 + 111 + ## Tests 112 + 113 + - `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; `detectColorScheme` priority order 114 + - `apps/web/src/layouts/__tests__/base.test.tsx`: update to pass `resolvedTheme` prop; verify `Accept-CH` meta; verify `cssOverrides` block renders when present 115 + - `apps/appview/src/routes/__tests__/themes.test.ts`: assert `cid` field in `GET /api/themes/:rkey` response
+1089
docs/plans/complete/2026-03-04-atb53-theme-resolution-implementation.md
··· 1 + # ATB-53: Theme Resolution and Server-Side Token Injection — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Wire the resolved theme into every server-rendered HTML response — fetching the active theme from the AppView, applying the waterfall (user pref → color scheme → forum default → hardcoded fallback), and injecting the winning tokens as a `<style>:root { ... }</style>` block in `BaseLayout`. 6 + 7 + **Architecture:** A Hono middleware runs before all page routes, calls `resolveTheme()`, and sets the result on a typed Hono context variable. `BaseLayout` accepts `resolvedTheme` as a required prop and renders tokens, font URLs, and optional CSS overrides dynamically. The AppView's `GET /api/themes/:rkey` gains a `cid` field to enable the CID integrity check. 8 + 9 + **Tech Stack:** Hono (middleware + typed Variables), Vitest (vi.stubGlobal for fetch mocking, vi.mock for module mocking), TypeScript, existing `tokensToCss()` utility, existing `neobrutal-light.json` preset. 10 + 11 + --- 12 + 13 + ## Context 14 + 15 + All dependencies are done: theme lexicons (ATB-51), CSS token system (ATB-52), theme read API (ATB-55), theme write API (ATB-57), theme list page (ATB-58), token editor (ATB-59). 16 + 17 + **What already exists:** 18 + - `apps/web/src/lib/theme.ts` — `tokensToCss(tokens)` utility 19 + - `apps/web/src/styles/presets/neobrutal-light.json` and `neobrutal-dark.json` 20 + - `apps/appview/src/routes/themes.ts` — `GET /api/themes/:rkey` (missing `cid` in response) 21 + - `apps/appview/src/routes/__tests__/themes.test.ts` — existing tests (no `cid` assertion yet) 22 + - `apps/web/src/layouts/base.tsx` — currently hardcodes neobrutal-light as module-level constant 23 + - `apps/web/src/layouts/__tests__/base.test.tsx` — existing tests pass `<BaseLayout>` without `resolvedTheme` 24 + 25 + **Fetch-mocking pattern** (from `session.test.ts`): 26 + ```typescript 27 + const mockFetch = vi.fn(); 28 + beforeEach(() => { vi.stubGlobal("fetch", mockFetch); }); 29 + afterEach(() => { vi.unstubAllGlobals(); mockFetch.mockReset(); }); 30 + ``` 31 + 32 + **Test commands:** 33 + ```bash 34 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 35 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts 36 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/middleware/__tests__/theme.test.ts 37 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/layouts/__tests__/base.test.tsx 38 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run 39 + PATH=.devenv/profile/bin:$PATH pnpm test 40 + ``` 41 + 42 + --- 43 + 44 + ## Task 1: AppView — Add `cid` to `GET /api/themes/:rkey` response 45 + 46 + **Files:** 47 + - Modify: `apps/appview/src/routes/themes.ts` (`serializeThemeFull` function) 48 + - Modify: `apps/appview/src/routes/__tests__/themes.test.ts` (add `cid` assertion) 49 + 50 + ### Step 1: Add failing assertion to existing test 51 + 52 + In `apps/appview/src/routes/__tests__/themes.test.ts`, find the test "returns full theme data including tokens, cssOverrides, and fontUrls" and add one line: 53 + 54 + ```typescript 55 + // After: expect(body.indexedAt).toBeDefined(); 56 + expect(body.cid).toBe("bafyfull"); 57 + ``` 58 + 59 + ### Step 2: Run test to verify it fails 60 + 61 + ```bash 62 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 63 + ``` 64 + 65 + Expected: FAIL — `expect(received).toBe(expected)` with `received: undefined`. 66 + 67 + ### Step 3: Add `cid` to `serializeThemeFull` 68 + 69 + In `apps/appview/src/routes/themes.ts`, update `serializeThemeFull`: 70 + 71 + ```typescript 72 + function serializeThemeFull(theme: ThemeRow) { 73 + return { 74 + id: serializeBigInt(theme.id), 75 + uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`, 76 + cid: theme.cid, // ← add this line 77 + name: theme.name, 78 + colorScheme: theme.colorScheme, 79 + tokens: theme.tokens, 80 + cssOverrides: theme.cssOverrides ?? null, 81 + fontUrls: (theme.fontUrls as string[] | null) ?? null, 82 + createdAt: serializeDate(theme.createdAt), 83 + indexedAt: serializeDate(theme.indexedAt), 84 + }; 85 + } 86 + ``` 87 + 88 + ### Step 4: Run tests to verify they pass 89 + 90 + ```bash 91 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/appview exec vitest run src/routes/__tests__/themes.test.ts 92 + ``` 93 + 94 + Expected: all PASS. 95 + 96 + ### Step 5: Commit 97 + 98 + ```bash 99 + git add apps/appview/src/routes/themes.ts apps/appview/src/routes/__tests__/themes.test.ts 100 + git commit -m "feat(appview): include cid in GET /api/themes/:rkey response (ATB-53)" 101 + ``` 102 + 103 + --- 104 + 105 + ## Task 2: Web — Core types, helpers, and FALLBACK_THEME 106 + 107 + **Files:** 108 + - Create: `apps/web/src/lib/theme-resolution.ts` 109 + - Create: `apps/web/src/lib/__tests__/theme-resolution.test.ts` 110 + 111 + ### Step 1: Write failing tests for `detectColorScheme` and `parseRkeyFromUri` 112 + 113 + Create `apps/web/src/lib/__tests__/theme-resolution.test.ts`: 114 + 115 + ```typescript 116 + import { describe, it, expect } from "vitest"; 117 + import { 118 + detectColorScheme, 119 + parseRkeyFromUri, 120 + FALLBACK_THEME, 121 + } from "../theme-resolution.js"; 122 + 123 + describe("detectColorScheme", () => { 124 + it("returns 'light' by default when no cookie or hint", () => { 125 + expect(detectColorScheme(undefined, undefined)).toBe("light"); 126 + }); 127 + 128 + it("reads atbb-color-scheme=dark from cookie", () => { 129 + expect(detectColorScheme("atbb-color-scheme=dark; other=1", undefined)).toBe("dark"); 130 + }); 131 + 132 + it("reads atbb-color-scheme=light from cookie", () => { 133 + expect(detectColorScheme("atbb-color-scheme=light", undefined)).toBe("light"); 134 + }); 135 + 136 + it("prefers cookie over client hint", () => { 137 + expect(detectColorScheme("atbb-color-scheme=light", "dark")).toBe("light"); 138 + }); 139 + 140 + it("falls back to client hint when no cookie", () => { 141 + expect(detectColorScheme(undefined, "dark")).toBe("dark"); 142 + }); 143 + 144 + it("ignores unrecognized hint values and returns 'light'", () => { 145 + expect(detectColorScheme(undefined, "no-preference")).toBe("light"); 146 + }); 147 + }); 148 + 149 + describe("parseRkeyFromUri", () => { 150 + it("extracts rkey from valid AT URI", () => { 151 + expect( 152 + parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme/3lblthemeabc") 153 + ).toBe("3lblthemeabc"); 154 + }); 155 + 156 + it("returns null for URI with no rkey segment", () => { 157 + expect(parseRkeyFromUri("at://did:plc:abc123/space.atbb.forum.theme")).toBeNull(); 158 + }); 159 + 160 + it("returns null for malformed URI", () => { 161 + expect(parseRkeyFromUri("not-a-uri")).toBeNull(); 162 + }); 163 + 164 + it("returns null for empty string", () => { 165 + expect(parseRkeyFromUri("")).toBeNull(); 166 + }); 167 + }); 168 + 169 + describe("FALLBACK_THEME", () => { 170 + it("uses neobrutal-light tokens", () => { 171 + expect(FALLBACK_THEME.tokens["color-bg"]).toBe("#f5f0e8"); 172 + expect(FALLBACK_THEME.tokens["color-primary"]).toBe("#ff5c00"); 173 + }); 174 + 175 + it("has light colorScheme", () => { 176 + expect(FALLBACK_THEME.colorScheme).toBe("light"); 177 + }); 178 + 179 + it("includes Google Fonts URL for Space Grotesk", () => { 180 + expect(FALLBACK_THEME.fontUrls).toEqual( 181 + expect.arrayContaining([expect.stringContaining("Space+Grotesk")]) 182 + ); 183 + }); 184 + 185 + it("has null cssOverrides", () => { 186 + expect(FALLBACK_THEME.cssOverrides).toBeNull(); 187 + }); 188 + }); 189 + ``` 190 + 191 + ### Step 2: Run to verify they fail 192 + 193 + ```bash 194 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts 195 + ``` 196 + 197 + Expected: FAIL — module not found. 198 + 199 + ### Step 3: Create `apps/web/src/lib/theme-resolution.ts` with types, helpers, and FALLBACK_THEME 200 + 201 + ```typescript 202 + import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 203 + 204 + export type ResolvedTheme = { 205 + tokens: Record<string, string>; 206 + cssOverrides: string | null; 207 + fontUrls: string[] | null; 208 + colorScheme: "light" | "dark"; 209 + }; 210 + 211 + /** Hono app environment type — used by middleware and all route factories. */ 212 + export type WebAppEnv = { 213 + Variables: { theme: ResolvedTheme }; 214 + }; 215 + 216 + /** Hardcoded fallback used when theme policy is missing or resolution fails. */ 217 + export const FALLBACK_THEME: ResolvedTheme = { 218 + tokens: neobrutalLight as Record<string, string>, 219 + cssOverrides: null, 220 + fontUrls: [ 221 + "https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;700&display=swap", 222 + ], 223 + colorScheme: "light", 224 + }; 225 + 226 + /** 227 + * Detects the user's preferred color scheme. 228 + * Priority: atbb-color-scheme cookie → Sec-CH-Prefers-Color-Scheme hint → "light". 229 + */ 230 + export function detectColorScheme( 231 + cookieHeader: string | undefined, 232 + hint: string | undefined 233 + ): "light" | "dark" { 234 + const match = cookieHeader?.match(/atbb-color-scheme=(light|dark)/); 235 + if (match) return match[1] as "light" | "dark"; 236 + if (hint === "dark") return "dark"; 237 + return "light"; 238 + } 239 + 240 + /** 241 + * Extracts the rkey segment from an AT URI. 242 + * Example: "at://did:plc:abc/space.atbb.forum.theme/rkey123" → "rkey123" 243 + */ 244 + export function parseRkeyFromUri(atUri: string): string | null { 245 + // Format: at://<did>/<collection>/<rkey> 246 + // Split gives: ["at:", "", "<did>", "<collection>", "<rkey>"] 247 + const parts = atUri.split("/"); 248 + if (parts.length < 5) return null; 249 + return parts[4] ?? null; 250 + } 251 + ``` 252 + 253 + ### Step 4: Run tests to verify they pass 254 + 255 + ```bash 256 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts 257 + ``` 258 + 259 + Expected: all PASS. 260 + 261 + ### Step 5: Commit 262 + 263 + ```bash 264 + git add apps/web/src/lib/theme-resolution.ts apps/web/src/lib/__tests__/theme-resolution.test.ts 265 + git commit -m "feat(web): add ResolvedTheme types, FALLBACK_THEME, and color scheme helpers (ATB-53)" 266 + ``` 267 + 268 + --- 269 + 270 + ## Task 3: Web — `resolveTheme()` waterfall 271 + 272 + **Files:** 273 + - Modify: `apps/web/src/lib/theme-resolution.ts` 274 + - Modify: `apps/web/src/lib/__tests__/theme-resolution.test.ts` 275 + 276 + ### Step 1: Write failing tests for all waterfall branches 277 + 278 + Add a new `describe("resolveTheme", ...)` block to the end of `apps/web/src/lib/__tests__/theme-resolution.test.ts`: 279 + 280 + ```typescript 281 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 282 + import { 283 + detectColorScheme, 284 + parseRkeyFromUri, 285 + resolveTheme, 286 + FALLBACK_THEME, 287 + } from "../theme-resolution.js"; 288 + import { logger } from "../logger.js"; 289 + 290 + vi.mock("../logger.js", () => ({ 291 + logger: { 292 + debug: vi.fn(), 293 + info: vi.fn(), 294 + warn: vi.fn(), 295 + error: vi.fn(), 296 + fatal: vi.fn(), 297 + }, 298 + })); 299 + 300 + // ... (keep existing describe blocks above, add this new one:) 301 + 302 + describe("resolveTheme", () => { 303 + const mockFetch = vi.fn(); 304 + const APPVIEW = "http://localhost:3001"; 305 + 306 + beforeEach(() => { 307 + vi.stubGlobal("fetch", mockFetch); 308 + vi.mocked(logger.warn).mockClear(); 309 + vi.mocked(logger.error).mockClear(); 310 + }); 311 + 312 + afterEach(() => { 313 + vi.unstubAllGlobals(); 314 + mockFetch.mockReset(); 315 + }); 316 + 317 + function policyResponse(overrides: object = {}) { 318 + return { 319 + ok: true, 320 + json: () => 321 + Promise.resolve({ 322 + defaultLightThemeUri: 323 + "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 324 + defaultDarkThemeUri: 325 + "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 326 + allowUserChoice: true, 327 + availableThemes: [ 328 + { 329 + uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight", 330 + cid: "bafylight", 331 + }, 332 + { 333 + uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark", 334 + cid: "bafydark", 335 + }, 336 + ], 337 + ...overrides, 338 + }), 339 + }; 340 + } 341 + 342 + function themeResponse(colorScheme: "light" | "dark", cid: string) { 343 + return { 344 + ok: true, 345 + json: () => 346 + Promise.resolve({ 347 + cid, 348 + tokens: { "color-bg": colorScheme === "light" ? "#fff" : "#111" }, 349 + cssOverrides: null, 350 + fontUrls: null, 351 + colorScheme, 352 + }), 353 + }; 354 + } 355 + 356 + it("returns FALLBACK_THEME with detected colorScheme when policy fetch fails (non-ok)", async () => { 357 + mockFetch.mockResolvedValueOnce({ ok: false, status: 404 }); 358 + 359 + const result = await resolveTheme(APPVIEW, undefined, undefined); 360 + 361 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 362 + expect(result.colorScheme).toBe("light"); 363 + }); 364 + 365 + it("returns FALLBACK_THEME with dark colorScheme when policy fails and dark cookie set", async () => { 366 + mockFetch.mockResolvedValueOnce({ ok: false, status: 500 }); 367 + 368 + const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 369 + 370 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 371 + expect(result.colorScheme).toBe("dark"); 372 + }); 373 + 374 + it("returns FALLBACK_THEME when policy has no defaultLightThemeUri", async () => { 375 + mockFetch.mockResolvedValueOnce( 376 + policyResponse({ defaultLightThemeUri: null }) 377 + ); 378 + 379 + const result = await resolveTheme(APPVIEW, undefined, undefined); 380 + 381 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 382 + }); 383 + 384 + it("returns FALLBACK_THEME when theme fetch fails", async () => { 385 + mockFetch 386 + .mockResolvedValueOnce(policyResponse()) 387 + .mockResolvedValueOnce({ ok: false, status: 404 }); 388 + 389 + const result = await resolveTheme(APPVIEW, undefined, undefined); 390 + 391 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 392 + }); 393 + 394 + it("returns FALLBACK_THEME and logs warning on CID mismatch", async () => { 395 + mockFetch 396 + .mockResolvedValueOnce(policyResponse()) 397 + .mockResolvedValueOnce(themeResponse("light", "WRONG_CID")); 398 + 399 + const result = await resolveTheme(APPVIEW, undefined, undefined); 400 + 401 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 402 + expect(logger.warn).toHaveBeenCalledWith( 403 + expect.stringContaining("CID mismatch"), 404 + expect.objectContaining({ expectedCid: "bafylight", actualCid: "WRONG_CID" }) 405 + ); 406 + }); 407 + 408 + it("resolves the light theme on happy path (no cookie)", async () => { 409 + mockFetch 410 + .mockResolvedValueOnce(policyResponse()) 411 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 412 + 413 + const result = await resolveTheme(APPVIEW, undefined, undefined); 414 + 415 + expect(result.tokens["color-bg"]).toBe("#fff"); 416 + expect(result.colorScheme).toBe("light"); 417 + expect(result.cssOverrides).toBeNull(); 418 + expect(result.fontUrls).toBeNull(); 419 + }); 420 + 421 + it("resolves the dark theme when atbb-color-scheme=dark cookie is set", async () => { 422 + mockFetch 423 + .mockResolvedValueOnce(policyResponse()) 424 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 425 + 426 + const result = await resolveTheme(APPVIEW, "atbb-color-scheme=dark", undefined); 427 + 428 + expect(result.tokens["color-bg"]).toBe("#111"); 429 + expect(result.colorScheme).toBe("dark"); 430 + // Verify the dark theme URI was fetched (3lbldark, not 3lbllight) 431 + expect(mockFetch).toHaveBeenCalledWith( 432 + expect.stringContaining("3lbldark") 433 + ); 434 + }); 435 + 436 + it("resolves dark theme from Sec-CH-Prefers-Color-Scheme hint when no cookie", async () => { 437 + mockFetch 438 + .mockResolvedValueOnce(policyResponse()) 439 + .mockResolvedValueOnce(themeResponse("dark", "bafydark")); 440 + 441 + const result = await resolveTheme(APPVIEW, undefined, "dark"); 442 + 443 + expect(result.colorScheme).toBe("dark"); 444 + }); 445 + 446 + it("returns FALLBACK_THEME and logs error on network exception", async () => { 447 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 448 + 449 + const result = await resolveTheme(APPVIEW, undefined, undefined); 450 + 451 + expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 452 + expect(logger.error).toHaveBeenCalledWith( 453 + expect.stringContaining("Theme resolution failed"), 454 + expect.objectContaining({ operation: "resolveTheme" }) 455 + ); 456 + }); 457 + 458 + it("passes cssOverrides and fontUrls through from theme response", async () => { 459 + mockFetch 460 + .mockResolvedValueOnce(policyResponse()) 461 + .mockResolvedValueOnce({ 462 + ok: true, 463 + json: () => 464 + Promise.resolve({ 465 + cid: "bafylight", 466 + tokens: { "color-bg": "#fff" }, 467 + cssOverrides: ".btn { font-weight: 700; }", 468 + fontUrls: ["https://fonts.example.com/font.css"], 469 + colorScheme: "light", 470 + }), 471 + }); 472 + 473 + const result = await resolveTheme(APPVIEW, undefined, undefined); 474 + 475 + expect(result.cssOverrides).toBe(".btn { font-weight: 700; }"); 476 + expect(result.fontUrls).toEqual(["https://fonts.example.com/font.css"]); 477 + }); 478 + }); 479 + ``` 480 + 481 + **Note:** Also add the `vi.mock("../logger.js", ...)` block and update the `import` line at the top of the test file. 482 + 483 + ### Step 2: Run to verify they fail 484 + 485 + ```bash 486 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts 487 + ``` 488 + 489 + Expected: FAIL — `resolveTheme` is not exported. 490 + 491 + ### Step 3: Add `resolveTheme` to `apps/web/src/lib/theme-resolution.ts` 492 + 493 + Add after the existing helpers. Also add the internal type interfaces and the logger import: 494 + 495 + ```typescript 496 + import neobrutalLight from "../styles/presets/neobrutal-light.json" with { type: "json" }; 497 + import { logger } from "./logger.js"; 498 + 499 + // ... (existing exports: ResolvedTheme, WebAppEnv, FALLBACK_THEME, detectColorScheme, parseRkeyFromUri) 500 + 501 + interface ThemePolicyResponse { 502 + defaultLightThemeUri: string | null; 503 + defaultDarkThemeUri: string | null; 504 + allowUserChoice: boolean; 505 + availableThemes: Array<{ uri: string; cid: string }>; 506 + } 507 + 508 + interface ThemeResponse { 509 + cid: string; 510 + tokens: Record<string, unknown>; 511 + cssOverrides: string | null; 512 + fontUrls: string[] | null; 513 + } 514 + 515 + /** 516 + * Resolves which theme to render for a request using the waterfall: 517 + * 1. User preference — not yet implemented (TODO: Theme Phase 4) 518 + * 2. Color scheme default — atbb-color-scheme cookie or Sec-CH hint 519 + * 3. Forum default — fetched from GET /api/theme-policy 520 + * 4. Hardcoded fallback — FALLBACK_THEME (neobrutal-light) 521 + * 522 + * Never throws — always returns a usable theme. 523 + */ 524 + export async function resolveTheme( 525 + appviewUrl: string, 526 + cookieHeader: string | undefined, 527 + colorSchemeHint: string | undefined 528 + ): Promise<ResolvedTheme> { 529 + const colorScheme = detectColorScheme(cookieHeader, colorSchemeHint); 530 + 531 + // Step 1: User preference 532 + // TODO: implement when space.atbb.membership.preferredTheme is added (Theme Phase 4) 533 + 534 + // Steps 2-3: Forum default via theme policy 535 + try { 536 + const policyRes = await fetch(`${appviewUrl}/api/theme-policy`); 537 + if (!policyRes.ok) { 538 + return { ...FALLBACK_THEME, colorScheme }; 539 + } 540 + 541 + const policy = (await policyRes.json()) as ThemePolicyResponse; 542 + 543 + const defaultUri = 544 + colorScheme === "dark" 545 + ? policy.defaultDarkThemeUri 546 + : policy.defaultLightThemeUri; 547 + 548 + if (!defaultUri) { 549 + return { ...FALLBACK_THEME, colorScheme }; 550 + } 551 + 552 + const rkey = parseRkeyFromUri(defaultUri); 553 + if (!rkey) { 554 + return { ...FALLBACK_THEME, colorScheme }; 555 + } 556 + 557 + const expectedCid = 558 + policy.availableThemes.find((t) => t.uri === defaultUri)?.cid ?? null; 559 + 560 + const themeRes = await fetch(`${appviewUrl}/api/themes/${rkey}`); 561 + if (!themeRes.ok) { 562 + return { ...FALLBACK_THEME, colorScheme }; 563 + } 564 + 565 + const theme = (await themeRes.json()) as ThemeResponse; 566 + 567 + if (expectedCid && theme.cid !== expectedCid) { 568 + logger.warn("Theme CID mismatch — using hardcoded fallback", { 569 + operation: "resolveTheme", 570 + expectedCid, 571 + actualCid: theme.cid, 572 + themeUri: defaultUri, 573 + }); 574 + return { ...FALLBACK_THEME, colorScheme }; 575 + } 576 + 577 + return { 578 + tokens: theme.tokens as Record<string, string>, 579 + cssOverrides: theme.cssOverrides ?? null, 580 + fontUrls: theme.fontUrls ?? null, 581 + colorScheme, 582 + }; 583 + } catch (error) { 584 + // Intentionally don't re-throw: a broken theme system should serve the 585 + // fallback and log the error, rather than crash every page request. 586 + logger.error("Theme resolution failed — using hardcoded fallback", { 587 + operation: "resolveTheme", 588 + error: error instanceof Error ? error.message : String(error), 589 + }); 590 + return { ...FALLBACK_THEME, colorScheme }; 591 + } 592 + } 593 + ``` 594 + 595 + ### Step 4: Run tests to verify they pass 596 + 597 + ```bash 598 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/lib/__tests__/theme-resolution.test.ts 599 + ``` 600 + 601 + Expected: all PASS. 602 + 603 + ### Step 5: Commit 604 + 605 + ```bash 606 + git add apps/web/src/lib/theme-resolution.ts apps/web/src/lib/__tests__/theme-resolution.test.ts 607 + git commit -m "feat(web): implement resolveTheme waterfall with CID integrity check (ATB-53)" 608 + ``` 609 + 610 + --- 611 + 612 + ## Task 4: Web — Theme middleware 613 + 614 + **Files:** 615 + - Create: `apps/web/src/middleware/theme.ts` 616 + - Create: `apps/web/src/middleware/__tests__/theme.test.ts` 617 + 618 + ### Step 1: Write failing test 619 + 620 + Create `apps/web/src/middleware/__tests__/theme.test.ts`: 621 + 622 + ```typescript 623 + import { describe, it, expect, vi, beforeEach } from "vitest"; 624 + import { Hono } from "hono"; 625 + import { createThemeMiddleware } from "../theme.js"; 626 + import { FALLBACK_THEME } from "../../lib/theme-resolution.js"; 627 + import type { WebAppEnv } from "../../lib/theme-resolution.js"; 628 + 629 + vi.mock("../../lib/theme-resolution.js", async (importOriginal) => { 630 + const actual = 631 + await importOriginal<typeof import("../../lib/theme-resolution.js")>(); 632 + return { 633 + ...actual, 634 + resolveTheme: vi.fn().mockResolvedValue(actual.FALLBACK_THEME), 635 + }; 636 + }); 637 + 638 + const { resolveTheme } = await import("../../lib/theme-resolution.js"); 639 + 640 + describe("createThemeMiddleware", () => { 641 + const APPVIEW = "http://localhost:3001"; 642 + 643 + beforeEach(() => { 644 + vi.mocked(resolveTheme).mockClear(); 645 + }); 646 + 647 + function makeApp() { 648 + return new Hono<WebAppEnv>() 649 + .use("*", createThemeMiddleware(APPVIEW)) 650 + .get("/test", (c) => { 651 + const theme = c.get("theme"); 652 + return c.json({ colorScheme: theme.colorScheme }); 653 + }); 654 + } 655 + 656 + it("sets resolved theme on context so handlers can access it", async () => { 657 + const app = makeApp(); 658 + const res = await app.request("/test"); 659 + expect(res.status).toBe(200); 660 + const body = await res.json(); 661 + expect(body.colorScheme).toBe("light"); 662 + }); 663 + 664 + it("forwards cookie header to resolveTheme", async () => { 665 + const app = makeApp(); 666 + await app.request("/test", { 667 + headers: { cookie: "atbb-color-scheme=dark" }, 668 + }); 669 + expect(resolveTheme).toHaveBeenCalledWith( 670 + APPVIEW, 671 + "atbb-color-scheme=dark", 672 + undefined 673 + ); 674 + }); 675 + 676 + it("forwards Sec-CH-Prefers-Color-Scheme header to resolveTheme", async () => { 677 + const app = makeApp(); 678 + await app.request("/test", { 679 + headers: { "sec-ch-prefers-color-scheme": "dark" }, 680 + }); 681 + expect(resolveTheme).toHaveBeenCalledWith( 682 + APPVIEW, 683 + undefined, 684 + "dark" 685 + ); 686 + }); 687 + 688 + it("calls next() so the route handler executes", async () => { 689 + const app = makeApp(); 690 + const res = await app.request("/test"); 691 + expect(res.status).toBe(200); // not 404 — next() was called 692 + }); 693 + }); 694 + ``` 695 + 696 + ### Step 2: Run to verify it fails 697 + 698 + ```bash 699 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/middleware/__tests__/theme.test.ts 700 + ``` 701 + 702 + Expected: FAIL — module not found. 703 + 704 + ### Step 3: Create `apps/web/src/middleware/theme.ts` 705 + 706 + ```typescript 707 + import type { MiddlewareHandler } from "hono"; 708 + import { resolveTheme } from "../lib/theme-resolution.js"; 709 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 710 + 711 + export function createThemeMiddleware( 712 + appviewUrl: string 713 + ): MiddlewareHandler<WebAppEnv> { 714 + return async (c, next) => { 715 + const resolvedTheme = await resolveTheme( 716 + appviewUrl, 717 + c.req.header("cookie"), 718 + c.req.header("Sec-CH-Prefers-Color-Scheme") 719 + ); 720 + c.set("theme", resolvedTheme); 721 + await next(); 722 + }; 723 + } 724 + ``` 725 + 726 + ### Step 4: Run tests to verify they pass 727 + 728 + ```bash 729 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/middleware/__tests__/theme.test.ts 730 + ``` 731 + 732 + Expected: all PASS. 733 + 734 + ### Step 5: Commit 735 + 736 + ```bash 737 + git add apps/web/src/middleware/theme.ts apps/web/src/middleware/__tests__/theme.test.ts 738 + git commit -m "feat(web): add theme resolution middleware (ATB-53)" 739 + ``` 740 + 741 + --- 742 + 743 + ## Task 5: Web — Wire middleware into `routes/index.ts` 744 + 745 + **Files:** 746 + - Modify: `apps/web/src/routes/index.ts` 747 + 748 + ### Step 1: Update `routes/index.ts` 749 + 750 + ```typescript 751 + import { Hono } from "hono"; 752 + import { loadConfig } from "../lib/config.js"; 753 + import { createThemeMiddleware } from "../middleware/theme.js"; 754 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 755 + import { createHomeRoutes } from "./home.js"; 756 + import { createBoardsRoutes } from "./boards.js"; 757 + import { createTopicsRoutes } from "./topics.js"; 758 + import { createLoginRoutes } from "./login.js"; 759 + import { createNewTopicRoutes } from "./new-topic.js"; 760 + import { createAuthRoutes } from "./auth.js"; 761 + import { createModActionRoute } from "./mod.js"; 762 + import { createAdminRoutes } from "./admin.js"; 763 + import { createNotFoundRoute } from "./not-found.js"; 764 + 765 + const config = loadConfig(); 766 + 767 + export const webRoutes = new Hono<WebAppEnv>() 768 + .use("*", createThemeMiddleware(config.appviewUrl)) 769 + .route("/", createHomeRoutes(config.appviewUrl)) 770 + .route("/", createBoardsRoutes(config.appviewUrl)) 771 + .route("/", createTopicsRoutes(config.appviewUrl)) 772 + .route("/", createLoginRoutes(config.appviewUrl)) 773 + .route("/", createNewTopicRoutes(config.appviewUrl)) 774 + .route("/", createAuthRoutes(config.appviewUrl)) 775 + .route("/", createModActionRoute(config.appviewUrl)) 776 + .route("/", createAdminRoutes(config.appviewUrl)) 777 + .route("/", createNotFoundRoute(config.appviewUrl)); 778 + ``` 779 + 780 + ### Step 2: Run TypeScript check 781 + 782 + ```bash 783 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec tsc --noEmit 784 + ``` 785 + 786 + Expected: TypeScript errors about `new Hono()` in route factories not having `WebAppEnv`. These will be fixed in Task 7. 787 + 788 + ### Step 3: Commit (even with TS errors — the plan fixes them in Task 7) 789 + 790 + ```bash 791 + git add apps/web/src/routes/index.ts 792 + git commit -m "feat(web): apply theme middleware to webRoutes (ATB-53)" 793 + ``` 794 + 795 + --- 796 + 797 + ## Task 6: Web — Update `BaseLayout` 798 + 799 + **Files:** 800 + - Modify: `apps/web/src/layouts/base.tsx` 801 + - Modify: `apps/web/src/layouts/__tests__/base.test.tsx` 802 + 803 + ### Step 1: Update tests 804 + 805 + Replace the top of `apps/web/src/layouts/__tests__/base.test.tsx` to import `FALLBACK_THEME` and pass it everywhere `<BaseLayout>` is used: 806 + 807 + ```typescript 808 + import { describe, it, expect } from "vitest"; 809 + import { Hono } from "hono"; 810 + import { BaseLayout } from "../base.js"; 811 + import type { WebSession } from "../../lib/session.js"; 812 + import { FALLBACK_THEME } from "../../lib/theme-resolution.js"; 813 + 814 + // Default test app — all tests use FALLBACK_THEME unless they need specific theme values 815 + const app = new Hono().get("/", (c) => 816 + c.html( 817 + <BaseLayout title="Test Page" resolvedTheme={FALLBACK_THEME}> 818 + Page content 819 + </BaseLayout> 820 + ) 821 + ); 822 + ``` 823 + 824 + Then update every other `new Hono().get(...)` call in the file to also pass `resolvedTheme={FALLBACK_THEME}`. 825 + 826 + Add these new tests at the end of the file: 827 + 828 + ```typescript 829 + describe("theme injection", () => { 830 + it("renders Accept-CH meta tag for Sec-CH-Prefers-Color-Scheme client hint", async () => { 831 + const res = await app.request("/"); 832 + const html = await res.text(); 833 + expect(html).toContain('http-equiv="Accept-CH"'); 834 + expect(html).toContain("Sec-CH-Prefers-Color-Scheme"); 835 + }); 836 + 837 + it("injects resolved tokens as :root CSS custom properties", async () => { 838 + const res = await app.request("/"); 839 + const html = await res.text(); 840 + expect(html).toContain(":root {"); 841 + expect(html).toContain("--color-bg:"); 842 + expect(html).toContain("--color-primary:"); 843 + }); 844 + 845 + it("renders fontUrls from resolvedTheme as stylesheet links", async () => { 846 + const theme = { 847 + ...FALLBACK_THEME, 848 + fontUrls: ["https://fonts.example.com/custom.css"], 849 + }; 850 + const fontApp = new Hono().get("/", (c) => 851 + c.html(<BaseLayout resolvedTheme={theme}>content</BaseLayout>) 852 + ); 853 + const res = await fontApp.request("/"); 854 + const html = await res.text(); 855 + expect(html).toContain('href="https://fonts.example.com/custom.css"'); 856 + expect(html).toContain('rel="preconnect"'); 857 + }); 858 + 859 + it("renders cssOverrides as an additional style block when present", async () => { 860 + const theme = { 861 + ...FALLBACK_THEME, 862 + cssOverrides: ".btn { font-weight: 900; }", 863 + }; 864 + const overrideApp = new Hono().get("/", (c) => 865 + c.html(<BaseLayout resolvedTheme={theme}>content</BaseLayout>) 866 + ); 867 + const res = await overrideApp.request("/"); 868 + const html = await res.text(); 869 + expect(html).toContain(".btn { font-weight: 900; }"); 870 + }); 871 + 872 + it("does not render a cssOverrides style block when null", async () => { 873 + const res = await app.request("/"); 874 + const html = await res.text(); 875 + // Only the :root tokens block should be present, not a second style block 876 + const styleBlocks = html.match(/<style>/g) ?? []; 877 + expect(styleBlocks.length).toBe(1); 878 + }); 879 + }); 880 + ``` 881 + 882 + ### Step 2: Run tests to verify they fail 883 + 884 + ```bash 885 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/layouts/__tests__/base.test.tsx 886 + ``` 887 + 888 + Expected: FAIL — `resolvedTheme` prop not accepted by `BaseLayout`. 889 + 890 + ### Step 3: Rewrite `apps/web/src/layouts/base.tsx` 891 + 892 + ```typescript 893 + import type { FC, PropsWithChildren } from "hono/jsx"; 894 + import { tokensToCss } from "../lib/theme.js"; 895 + import type { WebSession } from "../lib/session.js"; 896 + import type { ResolvedTheme } from "../lib/theme-resolution.js"; 897 + 898 + const NavContent: FC<{ auth?: WebSession }> = ({ auth }) => ( 899 + <> 900 + {auth?.authenticated ? ( 901 + <> 902 + <span class="site-header__handle">{auth.handle}</span> 903 + <form action="/logout" method="post" class="site-header__logout-form"> 904 + <button type="submit" class="site-header__logout-btn"> 905 + Log out 906 + </button> 907 + </form> 908 + </> 909 + ) : ( 910 + <a href="/login" class="site-header__login-link"> 911 + Log in 912 + </a> 913 + )} 914 + </> 915 + ); 916 + 917 + export const BaseLayout: FC< 918 + PropsWithChildren<{ 919 + title?: string; 920 + auth?: WebSession; 921 + resolvedTheme: ResolvedTheme; 922 + }> 923 + > = (props) => { 924 + const { auth, resolvedTheme } = props; 925 + const rootCss = `:root { ${tokensToCss(resolvedTheme.tokens)} }`; 926 + const hasFontUrls = 927 + resolvedTheme.fontUrls !== null && resolvedTheme.fontUrls.length > 0; 928 + 929 + return ( 930 + <html lang="en"> 931 + <head> 932 + <meta charset="UTF-8" /> 933 + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 934 + <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" /> 935 + <title>{props.title ?? "atBB Forum"}</title> 936 + <style>{rootCss}</style> 937 + {hasFontUrls && ( 938 + <link rel="preconnect" href="https://fonts.googleapis.com" /> 939 + )} 940 + {resolvedTheme.fontUrls?.map((url) => ( 941 + <link rel="stylesheet" href={url} /> 942 + ))} 943 + <link rel="stylesheet" href="/static/css/reset.css" /> 944 + <link rel="stylesheet" href="/static/css/theme.css" /> 945 + {resolvedTheme.cssOverrides && ( 946 + <style>{resolvedTheme.cssOverrides}</style> 947 + )} 948 + <link rel="icon" type="image/svg+xml" href="/static/favicon.svg" /> 949 + <script src="https://unpkg.com/htmx.org@2.0.4" defer /> 950 + </head> 951 + <body> 952 + <a href="#main-content" class="skip-link"> 953 + Skip to main content 954 + </a> 955 + <header class="site-header"> 956 + <div class="site-header__inner"> 957 + <a href="/" class="site-header__title"> 958 + atBB Forum 959 + </a> 960 + <nav class="desktop-nav" aria-label="Main navigation"> 961 + <NavContent auth={auth} /> 962 + </nav> 963 + <details class="mobile-nav"> 964 + <summary class="mobile-nav__toggle" aria-label="Menu"> 965 + &#9776; 966 + </summary> 967 + <nav class="mobile-nav__menu" aria-label="Mobile navigation"> 968 + <NavContent auth={auth} /> 969 + </nav> 970 + </details> 971 + </div> 972 + </header> 973 + <main id="main-content" class="content-container"> 974 + {props.children} 975 + </main> 976 + <footer class="site-footer"> 977 + <p>Powered by atBB on the ATmosphere</p> 978 + </footer> 979 + </body> 980 + </html> 981 + ); 982 + }; 983 + ``` 984 + 985 + ### Step 4: Run tests to verify they pass 986 + 987 + ```bash 988 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run src/layouts/__tests__/base.test.tsx 989 + ``` 990 + 991 + Expected: all PASS. 992 + 993 + ### Step 5: Commit 994 + 995 + ```bash 996 + git add apps/web/src/layouts/base.tsx apps/web/src/layouts/__tests__/base.test.tsx 997 + git commit -m "feat(web): BaseLayout accepts resolvedTheme prop, adds Accept-CH meta and dynamic font URLs (ATB-53)" 998 + ``` 999 + 1000 + --- 1001 + 1002 + ## Task 7: Web — Update all HTML-rendering route factories 1003 + 1004 + **Files (all in `apps/web/src/routes/`):** 1005 + - Modify: `home.tsx`, `boards.tsx`, `topics.tsx`, `login.tsx`, `new-topic.tsx`, `admin.tsx`, `not-found.tsx` — update Hono type + read theme + pass to BaseLayout 1006 + - Modify: `auth.ts`, `mod.ts` — update Hono type only (no BaseLayout calls) 1007 + 1008 + ### Step 1: Apply the pattern to each HTML-rendering route 1009 + 1010 + For each file (`home.tsx`, `boards.tsx`, `topics.tsx`, `login.tsx`, `new-topic.tsx`, `admin.tsx`, `not-found.tsx`): 1011 + 1012 + **a) Add import at the top:** 1013 + ```typescript 1014 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 1015 + ``` 1016 + 1017 + **b) Change the Hono instance:** 1018 + ```typescript 1019 + // Before: 1020 + return new Hono().get(...) 1021 + // After: 1022 + return new Hono<WebAppEnv>().get(...) 1023 + ``` 1024 + 1025 + **c) At the top of the route handler function body, add:** 1026 + ```typescript 1027 + const theme = c.get("theme"); 1028 + ``` 1029 + 1030 + **d) Add `resolvedTheme={theme}` to every `<BaseLayout>` call in that handler** (including error-path renders — `theme` is always available since it's set before any async calls). 1031 + 1032 + For `auth.ts` and `mod.ts` (no BaseLayout, just update the Hono type): 1033 + ```typescript 1034 + import type { WebAppEnv } from "../lib/theme-resolution.js"; 1035 + // Change: new Hono() → new Hono<WebAppEnv>() 1036 + ``` 1037 + 1038 + ### Step 2: Run all web tests 1039 + 1040 + ```bash 1041 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec vitest run 1042 + ``` 1043 + 1044 + Expected: all PASS. TypeScript `--noEmit` should now pass too: 1045 + 1046 + ```bash 1047 + PATH=.devenv/profile/bin:$PATH pnpm --filter @atbb/web exec tsc --noEmit 1048 + ``` 1049 + 1050 + ### Step 3: Commit 1051 + 1052 + ```bash 1053 + git add apps/web/src/routes/ 1054 + git commit -m "feat(web): update all route factories to use WebAppEnv and pass resolvedTheme to BaseLayout (ATB-53)" 1055 + ``` 1056 + 1057 + --- 1058 + 1059 + ## Task 8: Final verification and Bruno collection update 1060 + 1061 + ### Step 1: Run full test suite 1062 + 1063 + ```bash 1064 + PATH=.devenv/profile/bin:$PATH pnpm test 1065 + ``` 1066 + 1067 + Expected: all PASS across all packages. 1068 + 1069 + ### Step 2: Run lint fix 1070 + 1071 + ```bash 1072 + PATH=.devenv/profile/bin:$PATH pnpm turbo lint:fix 1073 + ``` 1074 + 1075 + ### Step 3: Check Bruno collections 1076 + 1077 + Open `bruno/` directory. The ATB-53 changes are server-side rendering only — no new API endpoints are added (the AppView `GET /api/themes/:rkey` now returns `cid`, which should be documented). Update the `GET /api/themes/:rkey` Bruno request to show `cid` in the example response body if there is one. 1078 + 1079 + ### Step 4: Update Linear and plan doc 1080 + 1081 + Mark ATB-53 as Done in Linear with a comment summarising the implementation. Move `docs/plans/2026-03-04-atb53-theme-resolution-design.md` and this file to `docs/plans/complete/`. 1082 + 1083 + ```bash 1084 + mkdir -p docs/plans/complete 1085 + git mv docs/plans/2026-03-04-atb53-theme-resolution-design.md docs/plans/complete/ 1086 + git mv docs/plans/2026-03-04-atb53-theme-resolution-implementation.md docs/plans/complete/ 1087 + git add docs/plans/complete/ 1088 + git commit -m "docs: move ATB-53 plan docs to complete/ (ATB-53)" 1089 + ```