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): admin theme token editor with live preview (ATB-59) (#90)

* 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.

* refactor(web): extract theme admin handlers into admin-themes.tsx (ATB-59)

* refactor(web): mount admin-themes routes, remove extracted code from admin.tsx (ATB-59)

* test(web): add failing tests for GET /admin/themes/:rkey (ATB-59)

* test(web): improve admin-themes test quality for GET /admin/themes/:rkey

- Extract MANAGE_THEMES constant to reduce repetition
- Rename setupAuth → setupAuthenticatedSession to match admin.test.tsx pattern
- Remove unnecessary fetch mock from unauthenticated test
- Strengthen CSS overrides assertion to require co-location via regex
- Add colorScheme and second token assertions to happy-path test
- Restore strict "Access Denied" assertion on 403 test
- Add ATB-62 reference to CSS overrides test description

* feat(web): GET /admin/themes/:rkey token editor page + fix Edit button (ATB-59)

* fix(web): block } in sanitizeTokenValue to prevent CSS block-escape injection (ATB-59)

* test(web): write failing tests for POST /admin/themes/:rkey/preview (ATB-59 TDD red)

* test(web): strengthen preview tests — add fallback test, fix semicolon sanitization assertion (ATB-59)

* test(web): fix preview test quality — align auth fixture, strengthen } assertion, clarify description (ATB-59)

* test(web): add 403 test for preview POST — manageThemes permission gate (ATB-59)

* feat(web): POST /admin/themes/:rkey/preview — HTMX live preview endpoint (ATB-59)

Adds the live-preview fragment endpoint used by the theme editor's HTMX
integration. Sanitizes token values via sanitizeTokenValue() before
rendering ThemePreviewContent, dropping any value containing '<', ';',
or '}' to prevent CSS injection.

* fix(web): tighten sanitization assertions to --name: format, restore var(--color-bg) in preview template (ATB-59)

* test(web): write failing tests for POST /admin/themes/:rkey/save (ATB-59)

* feat(web): POST /admin/themes/:rkey/save — persist token edits to AppView (ATB-59)

* fix(web): sanitize token values on save + add PUT body forwarding test (ATB-59)

* test(web): write failing tests for POST /admin/themes/:rkey/reset-to-preset (ATB-59)

* test(web): strengthen reset-to-preset 400 assertion (ATB-59)

* feat(web): POST /admin/themes/:rkey/reset-to-preset (ATB-59)

* fix(web): address code review issues — ATB-59

- Fix GET /admin/themes/:rkey to call public /api/themes/:rkey instead
of nonexistent /api/admin/themes/:rkey; remove unused cookie variable
- Validate name before AppView PUT in save handler; redirect with error
if empty (prevents wasteful round-trip and unclear AppView message)
- Replace c.json() with redirect-on-error in reset-to-preset handler so
browser form POSTs show friendly error pages instead of raw JSON
- Add network failure test for GET /admin/themes/:rkey (500 unavailable)
- Add empty-name validation test for save handler
- Move ATB-59 plan docs to docs/plans/complete/

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

authored by

Malpercio and committed by
GitHub
a7fd628b de51e863

+3648 -528
+715
apps/web/src/routes/__tests__/admin-themes.test.tsx
··· 1 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 2 + 3 + const mockFetch = vi.fn(); 4 + 5 + describe("createAdminThemeRoutes — GET /admin/themes/:rkey", () => { 6 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 7 + 8 + beforeEach(() => { 9 + vi.stubGlobal("fetch", mockFetch); 10 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 11 + vi.resetModules(); 12 + }); 13 + 14 + afterEach(() => { 15 + vi.unstubAllGlobals(); 16 + vi.unstubAllEnvs(); 17 + mockFetch.mockReset(); 18 + }); 19 + 20 + function mockResponse(body: unknown, ok = true, status = 200) { 21 + return { 22 + ok, 23 + status, 24 + statusText: ok ? "OK" : "Error", 25 + json: () => Promise.resolve(body), 26 + }; 27 + } 28 + 29 + /** 30 + * Sets up the two-fetch mock sequence for an authenticated session. 31 + * Call 1: GET /api/auth/session 32 + * Call 2: GET /api/admin/members/me 33 + */ 34 + function setupAuthenticatedSession(permissions: string[]) { 35 + mockFetch.mockResolvedValueOnce( 36 + mockResponse({ authenticated: true, did: "did:plc:forum", handle: "admin.bsky.social" }) 37 + ); 38 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 39 + } 40 + 41 + async function loadThemeRoutes() { 42 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 43 + return createAdminThemeRoutes("http://localhost:3000"); 44 + } 45 + 46 + const sampleTheme = { 47 + id: "1", 48 + uri: "at://did:plc:forum/space.atbb.forum.theme/abc123", 49 + name: "My Theme", 50 + colorScheme: "light", 51 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 52 + cssOverrides: null, 53 + fontUrls: null, 54 + createdAt: "2026-01-01T00:00:00.000Z", 55 + indexedAt: "2026-01-01T00:00:00.000Z", 56 + }; 57 + 58 + // ── Unauthenticated ────────────────────────────────────────────────────── 59 + 60 + it("redirects unauthenticated users to /login", async () => { 61 + // No atbb_session cookie → getSession returns early without fetch 62 + const routes = await loadThemeRoutes(); 63 + const res = await routes.request("/admin/themes/abc123"); 64 + expect(res.status).toBe(302); 65 + expect(res.headers.get("location")).toBe("/login"); 66 + }); 67 + 68 + // ── No manageThemes permission → 403 ──────────────────────────────────── 69 + 70 + it("returns 403 for users without manageThemes permission", async () => { 71 + setupAuthenticatedSession([]); 72 + const routes = await loadThemeRoutes(); 73 + const res = await routes.request("/admin/themes/abc123", { 74 + headers: { cookie: "atbb_session=token" }, 75 + }); 76 + expect(res.status).toBe(403); 77 + const html = await res.text(); 78 + expect(html).toContain("Access Denied"); 79 + }); 80 + 81 + // ── Theme not found → 404 ──────────────────────────────────────────────── 82 + 83 + it("returns 404 when theme not found", async () => { 84 + setupAuthenticatedSession([MANAGE_THEMES]); 85 + // Third fetch: AppView returns 404 86 + mockFetch.mockResolvedValueOnce( 87 + mockResponse({ error: "Theme not found" }, false, 404) 88 + ); 89 + const routes = await loadThemeRoutes(); 90 + const res = await routes.request("/admin/themes/abc123", { 91 + headers: { cookie: "atbb_session=token" }, 92 + }); 93 + expect(res.status).toBe(404); 94 + }); 95 + 96 + // ── Network failure loading theme ───────────────────────────────────────── 97 + 98 + it("renders error page when AppView fetch throws (network failure)", async () => { 99 + setupAuthenticatedSession([MANAGE_THEMES]); 100 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 101 + const routes = await loadThemeRoutes(); 102 + const res = await routes.request("/admin/themes/abc123", { 103 + headers: { cookie: "atbb_session=token" }, 104 + }); 105 + expect(res.status).toBe(500); 106 + const html = await res.text(); 107 + expect(html.toLowerCase()).toContain("unavailable"); 108 + }); 109 + 110 + // ── Happy path: renders editor with theme tokens ────────────────────────── 111 + 112 + it("renders editor with theme name and token inputs", async () => { 113 + setupAuthenticatedSession([MANAGE_THEMES]); 114 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 115 + const routes = await loadThemeRoutes(); 116 + const res = await routes.request("/admin/themes/abc123", { 117 + headers: { cookie: "atbb_session=token" }, 118 + }); 119 + expect(res.status).toBe(200); 120 + const html = await res.text(); 121 + expect(html).toContain("My Theme"); 122 + expect(html).toContain('value="light"'); 123 + expect(html).toContain('name="color-bg"'); 124 + expect(html).toContain("#f5f0e8"); 125 + expect(html).toContain("#1a1a1a"); 126 + }); 127 + 128 + // ── Preset override ─────────────────────────────────────────────────────── 129 + 130 + it("uses preset tokens when ?preset=neobrutal-light is present", async () => { 131 + setupAuthenticatedSession([MANAGE_THEMES]); 132 + // Theme has empty tokens — preset should fill them in 133 + mockFetch.mockResolvedValueOnce( 134 + mockResponse({ ...sampleTheme, tokens: {} }) 135 + ); 136 + const routes = await loadThemeRoutes(); 137 + const res = await routes.request("/admin/themes/abc123?preset=neobrutal-light", { 138 + headers: { cookie: "atbb_session=token" }, 139 + }); 140 + expect(res.status).toBe(200); 141 + const html = await res.text(); 142 + // neobrutal-light color-bg is #f5f0e8 — preset fills empty token slots 143 + expect(html).toContain("#f5f0e8"); 144 + }); 145 + 146 + // ── Success banner ──────────────────────────────────────────────────────── 147 + 148 + it("shows success banner when ?success=1 is present", async () => { 149 + setupAuthenticatedSession([MANAGE_THEMES]); 150 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 151 + const routes = await loadThemeRoutes(); 152 + const res = await routes.request("/admin/themes/abc123?success=1", { 153 + headers: { cookie: "atbb_session=token" }, 154 + }); 155 + expect(res.status).toBe(200); 156 + const html = await res.text(); 157 + expect(html.toLowerCase()).toContain("saved"); 158 + }); 159 + 160 + // ── Error banner ────────────────────────────────────────────────────────── 161 + 162 + it("shows error banner when ?error= is present", async () => { 163 + setupAuthenticatedSession([MANAGE_THEMES]); 164 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 165 + const routes = await loadThemeRoutes(); 166 + const res = await routes.request( 167 + "/admin/themes/abc123?error=Something+went+wrong", 168 + { headers: { cookie: "atbb_session=token" } } 169 + ); 170 + expect(res.status).toBe(200); 171 + const html = await res.text(); 172 + expect(html).toContain("Something went wrong"); 173 + }); 174 + 175 + // ── CSS overrides field is disabled ───────────────────────────────────── 176 + 177 + it("renders CSS overrides field as disabled (awaiting ATB-62)", async () => { 178 + setupAuthenticatedSession([MANAGE_THEMES]); 179 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 180 + const routes = await loadThemeRoutes(); 181 + const res = await routes.request("/admin/themes/abc123", { 182 + headers: { cookie: "atbb_session=token" }, 183 + }); 184 + expect(res.status).toBe(200); 185 + const html = await res.text(); 186 + // Both attributes must be on the same element 187 + expect(html).toMatch(/name="css-overrides"[^>]*disabled|disabled[^>]*name="css-overrides"/); 188 + }); 189 + }); 190 + 191 + describe("createAdminThemeRoutes — POST /admin/themes/:rkey/preview", () => { 192 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 193 + 194 + beforeEach(() => { 195 + vi.stubGlobal("fetch", mockFetch); 196 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 197 + vi.resetModules(); 198 + }); 199 + 200 + afterEach(() => { 201 + vi.unstubAllGlobals(); 202 + vi.unstubAllEnvs(); 203 + mockFetch.mockReset(); 204 + }); 205 + 206 + function mockResponse(body: unknown, ok = true, status = 200) { 207 + return { 208 + ok, 209 + status, 210 + statusText: ok ? "OK" : "Error", 211 + json: () => Promise.resolve(body), 212 + }; 213 + } 214 + 215 + function setupAuthenticatedSession(permissions: string[]) { 216 + mockFetch.mockResolvedValueOnce( 217 + mockResponse({ authenticated: true, did: "did:plc:forum", handle: "admin.bsky.social" }) 218 + ); 219 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 220 + } 221 + 222 + async function loadThemeRoutes() { 223 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 224 + return createAdminThemeRoutes("http://localhost:3000"); 225 + } 226 + 227 + it("redirects unauthenticated users to /login", async () => { 228 + // No atbb_session cookie → getSession returns early without fetch 229 + const routes = await loadThemeRoutes(); 230 + const body = new URLSearchParams({ "color-bg": "#ff0000" }); 231 + const res = await routes.request("/admin/themes/abc123/preview", { 232 + method: "POST", 233 + headers: { "content-type": "application/x-www-form-urlencoded" }, 234 + body: body.toString(), 235 + }); 236 + expect(res.status).toBe(302); 237 + expect(res.headers.get("location")).toBe("/login"); 238 + }); 239 + 240 + it("returns 403 for users without manageThemes permission", async () => { 241 + setupAuthenticatedSession([]); 242 + 243 + const routes = await loadThemeRoutes(); 244 + const body = new URLSearchParams({ "color-bg": "#ff0000" }); 245 + const res = await routes.request("/admin/themes/abc123/preview", { 246 + method: "POST", 247 + headers: { 248 + "content-type": "application/x-www-form-urlencoded", 249 + cookie: "atbb_session=token", 250 + }, 251 + body: body.toString(), 252 + }); 253 + expect(res.status).toBe(403); 254 + }); 255 + 256 + it("returns an HTML fragment with a scoped style block containing submitted token values", async () => { 257 + setupAuthenticatedSession([MANAGE_THEMES]); 258 + 259 + const routes = await loadThemeRoutes(); 260 + const body = new URLSearchParams({ 261 + "color-bg": "#ff0000", 262 + "color-text": "#0000ff", 263 + }); 264 + const res = await routes.request("/admin/themes/abc123/preview", { 265 + method: "POST", 266 + headers: { 267 + "content-type": "application/x-www-form-urlencoded", 268 + cookie: "atbb_session=token", 269 + }, 270 + body: body.toString(), 271 + }); 272 + 273 + expect(res.status).toBe(200); 274 + const html = await res.text(); 275 + expect(html).toContain("--color-bg"); 276 + expect(html).toContain("#ff0000"); 277 + expect(html).toContain("--color-text"); 278 + expect(html).toContain("#0000ff"); 279 + expect(html).toContain(".preview-pane-inner"); 280 + // Fragment only — no full page wrapper 281 + expect(html).not.toContain("<html"); 282 + }); 283 + 284 + it("drops token values containing '<' (HTML injection prevention)", async () => { 285 + setupAuthenticatedSession([MANAGE_THEMES]); 286 + 287 + const routes = await loadThemeRoutes(); 288 + const body = new URLSearchParams({ 289 + "color-bg": "<script>alert(1)</script>", 290 + "color-text": "#1a1a1a", 291 + }); 292 + const res = await routes.request("/admin/themes/abc123/preview", { 293 + method: "POST", 294 + headers: { 295 + "content-type": "application/x-www-form-urlencoded", 296 + cookie: "atbb_session=token", 297 + }, 298 + body: body.toString(), 299 + }); 300 + 301 + expect(res.status).toBe(200); 302 + const html = await res.text(); 303 + expect(html).not.toContain("<script>"); 304 + expect(html).not.toContain("alert(1)"); 305 + // Clean token still renders 306 + expect(html).toContain("#1a1a1a"); 307 + }); 308 + 309 + it("drops token values containing ';' (CSS declaration injection prevention)", async () => { 310 + setupAuthenticatedSession([MANAGE_THEMES]); 311 + 312 + const routes = await loadThemeRoutes(); 313 + const body = new URLSearchParams({ 314 + "color-bg": "red; --injected: 1", 315 + }); 316 + const res = await routes.request("/admin/themes/abc123/preview", { 317 + method: "POST", 318 + headers: { 319 + "content-type": "application/x-www-form-urlencoded", 320 + cookie: "atbb_session=token", 321 + }, 322 + body: body.toString(), 323 + }); 324 + 325 + expect(res.status).toBe(200); 326 + const html = await res.text(); 327 + // The injected declaration must not appear 328 + expect(html).not.toContain("--injected"); 329 + // The entire dirty value must be dropped — not just the injected suffix 330 + // (a partial-strip bug would output '--color-bg: red' which looks safe) 331 + // Uses colon suffix to match the tokensToCss declaration format (--name: value) 332 + // and avoid false matches on var(--color-bg) references in the template HTML 333 + expect(html).not.toContain("--color-bg:"); 334 + }); 335 + 336 + it("drops token values containing '}' (CSS block-escape injection prevention)", async () => { 337 + setupAuthenticatedSession([MANAGE_THEMES]); 338 + 339 + const routes = await loadThemeRoutes(); 340 + const body = new URLSearchParams({ 341 + "color-bg": "red} body{background:red", 342 + }); 343 + const res = await routes.request("/admin/themes/abc123/preview", { 344 + method: "POST", 345 + headers: { 346 + "content-type": "application/x-www-form-urlencoded", 347 + cookie: "atbb_session=token", 348 + }, 349 + body: body.toString(), 350 + }); 351 + 352 + expect(res.status).toBe(200); 353 + const html = await res.text(); 354 + // The injected block-escape value must not appear 355 + expect(html).not.toContain("red} body"); 356 + // The entire dirty value must be dropped — not just the injected suffix 357 + // (colon suffix matches tokensToCss format; avoids false match on var(--color-bg) in template) 358 + expect(html).not.toContain("--color-bg:"); 359 + }); 360 + 361 + it("returns an empty-token preview fragment when no tokens are submitted (does not crash)", async () => { 362 + setupAuthenticatedSession([MANAGE_THEMES]); 363 + 364 + const routes = await loadThemeRoutes(); 365 + // POST with no body — parseBody() returns {} which produces an empty token map 366 + const res = await routes.request("/admin/themes/abc123/preview", { 367 + method: "POST", 368 + headers: { 369 + "content-type": "application/x-www-form-urlencoded", 370 + cookie: "atbb_session=token", 371 + }, 372 + }); 373 + 374 + // Must not crash — returns a valid HTML fragment 375 + expect(res.status).toBe(200); 376 + const html = await res.text(); 377 + expect(html).not.toContain("<html"); 378 + expect(html).toContain(".preview-pane-inner"); 379 + }); 380 + }); 381 + 382 + describe("createAdminThemeRoutes — POST /admin/themes/:rkey/save", () => { 383 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 384 + 385 + beforeEach(() => { 386 + vi.stubGlobal("fetch", mockFetch); 387 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 388 + vi.resetModules(); 389 + }); 390 + 391 + afterEach(() => { 392 + vi.unstubAllGlobals(); 393 + vi.unstubAllEnvs(); 394 + mockFetch.mockReset(); 395 + }); 396 + 397 + function mockResponse(body: unknown, ok = true, status = 200) { 398 + return { 399 + ok, 400 + status, 401 + statusText: ok ? "OK" : "Error", 402 + json: () => Promise.resolve(body), 403 + }; 404 + } 405 + 406 + function setupAuthenticatedSession(permissions: string[]) { 407 + mockFetch.mockResolvedValueOnce( 408 + mockResponse({ authenticated: true, did: "did:plc:forum", handle: "admin.bsky.social" }) 409 + ); 410 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 411 + } 412 + 413 + async function loadThemeRoutes() { 414 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 415 + return createAdminThemeRoutes("http://localhost:3000"); 416 + } 417 + 418 + function makeFormBody(overrides: Record<string, string> = {}): string { 419 + return new URLSearchParams({ 420 + name: "My Theme", 421 + colorScheme: "light", 422 + fontUrls: "", 423 + "color-bg": "#f5f0e8", 424 + ...overrides, 425 + }).toString(); 426 + } 427 + 428 + // ── Unauthenticated ──────────────────────────────────────────────────────── 429 + 430 + it("redirects unauthenticated users to /login", async () => { 431 + const routes = await loadThemeRoutes(); 432 + const res = await routes.request("/admin/themes/abc123/save", { 433 + method: "POST", 434 + headers: { "content-type": "application/x-www-form-urlencoded" }, 435 + body: makeFormBody(), 436 + }); 437 + expect(res.status).toBe(302); 438 + expect(res.headers.get("location")).toBe("/login"); 439 + }); 440 + 441 + // ── No manageThemes permission → 403 ─────────────────────────────────────── 442 + 443 + it("returns 403 for users without manageThemes permission", async () => { 444 + setupAuthenticatedSession([]); 445 + const routes = await loadThemeRoutes(); 446 + const res = await routes.request("/admin/themes/abc123/save", { 447 + method: "POST", 448 + headers: { 449 + "content-type": "application/x-www-form-urlencoded", 450 + cookie: "atbb_session=token", 451 + }, 452 + body: makeFormBody(), 453 + }); 454 + expect(res.status).toBe(403); 455 + }); 456 + 457 + // ── Happy path: AppView 200 → redirect ?success=1 ───────────────────────── 458 + 459 + it("redirects to ?success=1 on AppView 200", async () => { 460 + setupAuthenticatedSession([MANAGE_THEMES]); 461 + mockFetch.mockResolvedValueOnce(mockResponse({ id: "1", name: "My Theme" })); 462 + 463 + const routes = await loadThemeRoutes(); 464 + const res = await routes.request("/admin/themes/abc123/save", { 465 + method: "POST", 466 + headers: { 467 + "content-type": "application/x-www-form-urlencoded", 468 + cookie: "atbb_session=token", 469 + }, 470 + body: makeFormBody(), 471 + }); 472 + 473 + expect(res.status).toBe(302); 474 + expect(res.headers.get("location")).toContain("/admin/themes/abc123"); 475 + expect(res.headers.get("location")).toContain("success=1"); 476 + }); 477 + 478 + // ── AppView 400 → redirect ?error=<msg> ──────────────────────────────────── 479 + 480 + it("redirects with ?error when name is empty (client-side validation)", async () => { 481 + setupAuthenticatedSession([MANAGE_THEMES]); 482 + 483 + const routes = await loadThemeRoutes(); 484 + const res = await routes.request("/admin/themes/abc123/save", { 485 + method: "POST", 486 + headers: { 487 + "content-type": "application/x-www-form-urlencoded", 488 + cookie: "atbb_session=token", 489 + }, 490 + body: makeFormBody({ name: "" }), 491 + }); 492 + 493 + expect(res.status).toBe(302); 494 + const location = res.headers.get("location") ?? ""; 495 + expect(location).toContain("error="); 496 + expect(decodeURIComponent(location).toLowerCase()).toContain("name"); 497 + }); 498 + 499 + it("redirects with ?error=<msg> when AppView returns 400", async () => { 500 + setupAuthenticatedSession([MANAGE_THEMES]); 501 + mockFetch.mockResolvedValueOnce( 502 + mockResponse({ error: "Tokens are invalid" }, false, 400) 503 + ); 504 + 505 + const routes = await loadThemeRoutes(); 506 + const res = await routes.request("/admin/themes/abc123/save", { 507 + method: "POST", 508 + headers: { 509 + "content-type": "application/x-www-form-urlencoded", 510 + cookie: "atbb_session=token", 511 + }, 512 + body: makeFormBody(), 513 + }); 514 + 515 + expect(res.status).toBe(302); 516 + const location = res.headers.get("location") ?? ""; 517 + expect(location).toContain("error="); 518 + expect(decodeURIComponent(location)).toContain("Tokens are invalid"); 519 + }); 520 + 521 + // ── Network failure → redirect generic error ─────────────────────────────── 522 + 523 + it("redirects with generic error on network failure", async () => { 524 + setupAuthenticatedSession([MANAGE_THEMES]); 525 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 526 + 527 + const routes = await loadThemeRoutes(); 528 + const res = await routes.request("/admin/themes/abc123/save", { 529 + method: "POST", 530 + headers: { 531 + "content-type": "application/x-www-form-urlencoded", 532 + cookie: "atbb_session=token", 533 + }, 534 + body: makeFormBody(), 535 + }); 536 + 537 + expect(res.status).toBe(302); 538 + const location = res.headers.get("location") ?? ""; 539 + expect(location).toContain("error="); 540 + expect(decodeURIComponent(location).toLowerCase()).toContain("unavailable"); 541 + }); 542 + 543 + // ── PUT body forwarding ──────────────────────────────────────────────────── 544 + 545 + it("forwards name, colorScheme, tokens, and fontUrls to the AppView PUT call", async () => { 546 + setupAuthenticatedSession([MANAGE_THEMES]); 547 + mockFetch.mockResolvedValueOnce(mockResponse({ id: "1" })); 548 + 549 + const routes = await loadThemeRoutes(); 550 + await routes.request("/admin/themes/abc123/save", { 551 + method: "POST", 552 + headers: { 553 + "content-type": "application/x-www-form-urlencoded", 554 + cookie: "atbb_session=token", 555 + }, 556 + body: new URLSearchParams({ 557 + name: "Saved Theme", 558 + colorScheme: "dark", 559 + fontUrls: "https://fonts.example.com/font.css\nhttps://fonts.example.com/font2.css", 560 + "color-bg": "#000000", 561 + }).toString(), 562 + }); 563 + 564 + // Calls: 1 = session, 2 = members/me, 3 = AppView PUT 565 + const putCall = mockFetch.mock.calls[2]; 566 + const putBody = JSON.parse(putCall[1].body as string); 567 + expect(putBody.name).toBe("Saved Theme"); 568 + expect(putBody.colorScheme).toBe("dark"); 569 + expect(putBody.fontUrls).toEqual([ 570 + "https://fonts.example.com/font.css", 571 + "https://fonts.example.com/font2.css", 572 + ]); 573 + expect(putBody.tokens["color-bg"]).toBe("#000000"); 574 + }); 575 + }); 576 + 577 + describe("createAdminThemeRoutes — POST /admin/themes/:rkey/reset-to-preset", () => { 578 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 579 + 580 + beforeEach(() => { 581 + vi.stubGlobal("fetch", mockFetch); 582 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 583 + vi.resetModules(); 584 + }); 585 + 586 + afterEach(() => { 587 + vi.unstubAllGlobals(); 588 + vi.unstubAllEnvs(); 589 + mockFetch.mockReset(); 590 + }); 591 + 592 + function mockResponse(body: unknown, ok = true, status = 200) { 593 + return { 594 + ok, 595 + status, 596 + statusText: ok ? "OK" : "Error", 597 + json: () => Promise.resolve(body), 598 + }; 599 + } 600 + 601 + function setupAuthenticatedSession(permissions: string[]) { 602 + mockFetch.mockResolvedValueOnce( 603 + mockResponse({ authenticated: true, did: "did:plc:forum", handle: "admin.bsky.social" }) 604 + ); 605 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 606 + } 607 + 608 + async function loadThemeRoutes() { 609 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 610 + return createAdminThemeRoutes("http://localhost:3000"); 611 + } 612 + 613 + // ── Unauthenticated ──────────────────────────────────────────────────────── 614 + 615 + it("redirects unauthenticated users to /login", async () => { 616 + const routes = await loadThemeRoutes(); 617 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 618 + method: "POST", 619 + headers: { "content-type": "application/x-www-form-urlencoded" }, 620 + body: new URLSearchParams({ preset: "neobrutal-light" }).toString(), 621 + }); 622 + expect(res.status).toBe(302); 623 + expect(res.headers.get("location")).toBe("/login"); 624 + }); 625 + 626 + // ── No manageThemes permission → 403 ─────────────────────────────────────── 627 + 628 + it("returns 403 for users without manageThemes permission", async () => { 629 + setupAuthenticatedSession([]); 630 + const routes = await loadThemeRoutes(); 631 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 632 + method: "POST", 633 + headers: { 634 + "content-type": "application/x-www-form-urlencoded", 635 + cookie: "atbb_session=token", 636 + }, 637 + body: new URLSearchParams({ preset: "neobrutal-light" }).toString(), 638 + }); 639 + expect(res.status).toBe(403); 640 + }); 641 + 642 + // ── Valid presets redirect ────────────────────────────────────────────────── 643 + 644 + it("redirects to ?preset=neobrutal-light for valid preset", async () => { 645 + setupAuthenticatedSession([MANAGE_THEMES]); 646 + 647 + const routes = await loadThemeRoutes(); 648 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 649 + method: "POST", 650 + headers: { 651 + "content-type": "application/x-www-form-urlencoded", 652 + cookie: "atbb_session=token", 653 + }, 654 + body: new URLSearchParams({ preset: "neobrutal-light" }).toString(), 655 + }); 656 + 657 + expect(res.status).toBe(302); 658 + expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=neobrutal-light"); 659 + }); 660 + 661 + it("redirects to ?preset=neobrutal-dark for dark preset", async () => { 662 + setupAuthenticatedSession([MANAGE_THEMES]); 663 + 664 + const routes = await loadThemeRoutes(); 665 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 666 + method: "POST", 667 + headers: { 668 + "content-type": "application/x-www-form-urlencoded", 669 + cookie: "atbb_session=token", 670 + }, 671 + body: new URLSearchParams({ preset: "neobrutal-dark" }).toString(), 672 + }); 673 + 674 + expect(res.status).toBe(302); 675 + expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=neobrutal-dark"); 676 + }); 677 + 678 + it("redirects to ?preset=blank for blank preset", async () => { 679 + setupAuthenticatedSession([MANAGE_THEMES]); 680 + 681 + const routes = await loadThemeRoutes(); 682 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 683 + method: "POST", 684 + headers: { 685 + "content-type": "application/x-www-form-urlencoded", 686 + cookie: "atbb_session=token", 687 + }, 688 + body: new URLSearchParams({ preset: "blank" }).toString(), 689 + }); 690 + 691 + expect(res.status).toBe(302); 692 + expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=blank"); 693 + }); 694 + 695 + // ── Invalid preset → 400 ─────────────────────────────────────────────────── 696 + 697 + it("redirects with ?error for unknown preset name", async () => { 698 + setupAuthenticatedSession([MANAGE_THEMES]); 699 + 700 + const routes = await loadThemeRoutes(); 701 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 702 + method: "POST", 703 + headers: { 704 + "content-type": "application/x-www-form-urlencoded", 705 + cookie: "atbb_session=token", 706 + }, 707 + body: new URLSearchParams({ preset: "hacked" }).toString(), 708 + }); 709 + 710 + expect(res.status).toBe(302); 711 + const location = res.headers.get("location") ?? ""; 712 + expect(location).toContain("error="); 713 + expect(decodeURIComponent(location).toLowerCase()).toMatch(/invalid|unknown|preset/); 714 + }); 715 + });
+1127
apps/web/src/routes/admin-themes.tsx
··· 1 + import { Hono } from "hono"; 2 + import { BaseLayout } from "../layouts/base.js"; 3 + import { PageHeader, EmptyState } from "../components/index.js"; 4 + import { 5 + getSessionWithPermissions, 6 + canManageThemes, 7 + } from "../lib/session.js"; 8 + import { isProgrammingError } from "../lib/errors.js"; 9 + import { logger } from "../lib/logger.js"; 10 + import { tokensToCss } from "../lib/theme.js"; 11 + import neobrutalLight from "../styles/presets/neobrutal-light.json"; 12 + import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 13 + 14 + // ─── Types ───────────────────────────────────────────────────────────────── 15 + 16 + interface AdminThemeEntry { 17 + id: string; 18 + uri: string; 19 + name: string; 20 + colorScheme: string; 21 + tokens: Record<string, string>; 22 + cssOverrides: string | null; 23 + fontUrls: string[] | null; 24 + createdAt: string; 25 + indexedAt: string; 26 + } 27 + 28 + interface ThemePolicy { 29 + defaultLightThemeUri: string | null; 30 + defaultDarkThemeUri: string | null; 31 + allowUserChoice: boolean; 32 + availableThemes: Array<{ uri: string; cid: string }>; 33 + } 34 + 35 + // Preset token maps — used by POST /admin/themes to seed tokens on creation 36 + const THEME_PRESETS: Record<string, Record<string, string>> = { 37 + "neobrutal-light": neobrutalLight as Record<string, string>, 38 + "neobrutal-dark": neobrutalDark as Record<string, string>, 39 + "blank": {}, 40 + }; 41 + 42 + // ─── Token Group Constants ────────────────────────────────────────────────── 43 + 44 + export const COLOR_TOKENS = [ 45 + "color-bg", "color-surface", "color-text", "color-text-muted", 46 + "color-primary", "color-primary-hover", "color-secondary", "color-border", 47 + "color-shadow", "color-success", "color-warning", "color-danger", 48 + "color-code-bg", "color-code-text", 49 + ] as const; 50 + 51 + export const TYPOGRAPHY_TOKENS = [ 52 + "font-body", "font-heading", "font-mono", 53 + "font-size-base", "font-size-sm", "font-size-xs", "font-size-lg", 54 + "font-size-xl", "font-size-2xl", 55 + "font-weight-normal", "font-weight-bold", 56 + "line-height-body", "line-height-heading", 57 + ] as const; 58 + 59 + export const SPACING_TOKENS = [ 60 + "space-xs", "space-sm", "space-md", "space-lg", "space-xl", 61 + "radius", "border-width", "shadow-offset", "content-width", 62 + ] as const; 63 + 64 + export const COMPONENT_TOKENS = [ 65 + "button-radius", "button-shadow", 66 + "card-radius", "card-shadow", 67 + "btn-press-hover", "btn-press-active", 68 + "input-radius", "input-border", 69 + "nav-height", 70 + ] as const; 71 + 72 + export const ALL_KNOWN_TOKENS: readonly string[] = [ 73 + ...COLOR_TOKENS, ...TYPOGRAPHY_TOKENS, ...SPACING_TOKENS, ...COMPONENT_TOKENS, 74 + ]; 75 + 76 + // ─── Helpers ──────────────────────────────────────────────────────────────── 77 + 78 + /** 79 + * Extracts the error message from an AppView error response. 80 + * Falls back to the provided default if JSON parsing fails. 81 + */ 82 + async function extractAppviewError(res: Response, fallback: string): Promise<string> { 83 + try { 84 + const data = (await res.json()) as { error?: string }; 85 + return data.error ?? fallback; 86 + } catch { 87 + return fallback; 88 + } 89 + } 90 + 91 + /** Drop token values that could break the CSS style block. */ 92 + function sanitizeTokenValue(value: unknown): string | null { 93 + if (typeof value !== "string") return null; 94 + if (value.includes("<") || value.includes(";") || value.includes("}")) return null; 95 + return value; 96 + } 97 + 98 + // ─── JSX Components ───────────────────────────────────────────────────────── 99 + 100 + function ColorTokenInput({ name, value }: { name: string; value: string }) { 101 + const safeValue = 102 + !value.startsWith("var(") && !value.includes(";") && !value.includes("<") 103 + ? value 104 + : "#cccccc"; 105 + return ( 106 + <div class="token-input token-input--color"> 107 + <label for={`token-${name}`}>{name}</label> 108 + <div class="token-input__controls"> 109 + <input 110 + type="color" 111 + value={safeValue} 112 + aria-label={`${name} color picker`} 113 + oninput="this.nextElementSibling.value=this.value;this.nextElementSibling.dispatchEvent(new Event('change',{bubbles:true}))" 114 + /> 115 + <input 116 + type="text" 117 + id={`token-${name}`} 118 + name={name} 119 + value={safeValue} 120 + oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))this.previousElementSibling.value=this.value" 121 + /> 122 + </div> 123 + </div> 124 + ); 125 + } 126 + 127 + function TextTokenInput({ name, value }: { name: string; value: string }) { 128 + return ( 129 + <div class="token-input"> 130 + <label for={`token-${name}`}>{name}</label> 131 + <input type="text" id={`token-${name}`} name={name} value={value} /> 132 + </div> 133 + ); 134 + } 135 + 136 + function TokenFieldset({ 137 + legend, 138 + tokens, 139 + effectiveTokens, 140 + isColor, 141 + }: { 142 + legend: string; 143 + tokens: readonly string[]; 144 + effectiveTokens: Record<string, string>; 145 + isColor: boolean; 146 + }) { 147 + return ( 148 + <fieldset class="token-group"> 149 + <legend>{legend}</legend> 150 + {tokens.map((name) => 151 + isColor ? ( 152 + <ColorTokenInput name={name} value={effectiveTokens[name] ?? ""} /> 153 + ) : ( 154 + <TextTokenInput name={name} value={effectiveTokens[name] ?? ""} /> 155 + ) 156 + )} 157 + </fieldset> 158 + ); 159 + } 160 + 161 + function ThemePreviewContent({ tokens }: { tokens: Record<string, string> }) { 162 + const css = tokensToCss(tokens); 163 + return ( 164 + <> 165 + <style>{`.preview-pane-inner{${css}}`}</style> 166 + <div class="preview-pane-inner"> 167 + <div 168 + 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);" 169 + role="navigation" 170 + aria-label="Preview navigation" 171 + > 172 + atBB Forum Preview 173 + </div> 174 + <div style="padding:var(--space-md);"> 175 + <div 176 + 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);" 177 + > 178 + <h2 179 + 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;" 180 + > 181 + Sample Thread Title 182 + </h2> 183 + <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;"> 184 + Body text showing font, color, and spacing at work.{" "} 185 + <a href="#" style="color:var(--color-primary);">A sample link</a> 186 + </p> 187 + <pre 188 + 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;" 189 + > 190 + {`const greeting = "hello forum";`} 191 + </pre> 192 + <input 193 + type="text" 194 + placeholder="Reply…" 195 + 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);" 196 + /> 197 + <div style="display:flex;gap:var(--space-sm);flex-wrap:wrap;"> 198 + <button 199 + type="button" 200 + 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;" 201 + > 202 + Post Reply 203 + </button> 204 + <button 205 + type="button" 206 + 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;" 207 + > 208 + Cancel 209 + </button> 210 + <span 211 + 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);" 212 + > 213 + success 214 + </span> 215 + <span 216 + 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);" 217 + > 218 + warning 219 + </span> 220 + <span 221 + 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);" 222 + > 223 + danger 224 + </span> 225 + </div> 226 + </div> 227 + </div> 228 + </div> 229 + </> 230 + ); 231 + } 232 + 233 + 234 + // ─── Route Factory ────────────────────────────────────────────────────────── 235 + 236 + export function createAdminThemeRoutes(appviewUrl: string) { 237 + const app = new Hono(); 238 + 239 + // ── GET /admin/themes ────────────────────────────────────────────────────── 240 + 241 + app.get("/admin/themes", async (c) => { 242 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 243 + 244 + if (!auth.authenticated) { 245 + return c.redirect("/login"); 246 + } 247 + 248 + if (!canManageThemes(auth)) { 249 + return c.html( 250 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 251 + <PageHeader title="Themes" /> 252 + <p>You don&apos;t have permission to manage themes.</p> 253 + </BaseLayout>, 254 + 403 255 + ); 256 + } 257 + 258 + const cookie = c.req.header("cookie") ?? ""; 259 + const errorMsg = c.req.query("error") ?? null; 260 + 261 + let adminThemes: AdminThemeEntry[] = []; 262 + let policy: ThemePolicy | null = null; 263 + 264 + try { 265 + const [themesRes, policyRes] = await Promise.all([ 266 + fetch(`${appviewUrl}/api/admin/themes`, { headers: { Cookie: cookie } }), 267 + fetch(`${appviewUrl}/api/theme-policy`, { headers: { Cookie: cookie } }), 268 + ]); 269 + 270 + if (themesRes.ok) { 271 + try { 272 + const data = (await themesRes.json()) as { themes: AdminThemeEntry[] }; 273 + adminThemes = data.themes; 274 + } catch { 275 + logger.error("Failed to parse admin themes response", { 276 + operation: "GET /admin/themes", 277 + status: themesRes.status, 278 + }); 279 + } 280 + } else { 281 + logger.error("Failed to fetch admin themes list", { 282 + operation: "GET /admin/themes", 283 + status: themesRes.status, 284 + }); 285 + } 286 + 287 + if (policyRes.ok) { 288 + try { 289 + policy = (await policyRes.json()) as ThemePolicy; 290 + } catch { 291 + logger.error("Failed to parse theme policy response", { 292 + operation: "GET /admin/themes", 293 + status: policyRes.status, 294 + }); 295 + } 296 + } else if (policyRes.status !== 404) { 297 + logger.error("Failed to fetch theme policy", { 298 + operation: "GET /admin/themes", 299 + status: policyRes.status, 300 + }); 301 + } 302 + // 404 = no policy yet — render page with empty policy (not an error) 303 + } catch (error) { 304 + if (isProgrammingError(error)) throw error; 305 + logger.error("Network error fetching themes data", { 306 + operation: "GET /admin/themes", 307 + error: error instanceof Error ? error.message : String(error), 308 + }); 309 + } 310 + 311 + const availableUris = new Set((policy?.availableThemes ?? []).map((t) => t.uri)); 312 + const lightThemes = adminThemes.filter((t) => t.colorScheme === "light"); 313 + const darkThemes = adminThemes.filter((t) => t.colorScheme === "dark"); 314 + 315 + return c.html( 316 + <BaseLayout title="Themes — atBB Admin" auth={auth}> 317 + <PageHeader title="Themes" /> 318 + 319 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 320 + 321 + {adminThemes.length === 0 ? ( 322 + <EmptyState message="No themes yet. Create one below." /> 323 + ) : ( 324 + <div class="structure-list"> 325 + {adminThemes.map((theme) => { 326 + const themeRkey = theme.uri.split("/").pop() ?? theme.id; 327 + const dialogId = `confirm-delete-theme-${themeRkey}`; 328 + const swatchTokens = [ 329 + "color-bg", 330 + "color-surface", 331 + "color-primary", 332 + "color-secondary", 333 + "color-border", 334 + ] as const; 335 + 336 + return ( 337 + <div class="structure-item"> 338 + <div class="structure-item__header"> 339 + <div class="structure-item__title"> 340 + <label> 341 + <input 342 + type="checkbox" 343 + form="policy-form" 344 + name="availableThemes" 345 + value={theme.uri} 346 + checked={availableUris.has(theme.uri)} 347 + /> 348 + {" "} 349 + {theme.name} 350 + </label> 351 + <span class={`badge badge--${theme.colorScheme}`}> 352 + {theme.colorScheme} 353 + </span> 354 + </div> 355 + 356 + <div class="theme-swatches" aria-hidden="true"> 357 + {swatchTokens.map((token) => { 358 + const value = theme.tokens[token] ?? "#cccccc"; 359 + const safe = 360 + !value.startsWith("var(") && 361 + !value.includes(";") && 362 + !value.includes("<"); 363 + return ( 364 + <span 365 + class="theme-swatch" 366 + style={safe ? `background:${value}` : "background:#cccccc"} 367 + title={token} 368 + /> 369 + ); 370 + })} 371 + </div> 372 + 373 + <div class="structure-item__actions"> 374 + <a href={`/admin/themes/${themeRkey}`} class="btn btn-secondary btn-sm"> 375 + Edit 376 + </a> 377 + 378 + <form 379 + method="post" 380 + action={`/admin/themes/${themeRkey}/duplicate`} 381 + style="display:inline" 382 + > 383 + <button type="submit" class="btn btn-secondary btn-sm"> 384 + Duplicate 385 + </button> 386 + </form> 387 + 388 + <button 389 + type="button" 390 + class="btn btn-danger btn-sm" 391 + onclick={`document.getElementById('${dialogId}').showModal()`} 392 + > 393 + Delete 394 + </button> 395 + </div> 396 + </div> 397 + 398 + <dialog id={dialogId} class="structure-confirm-dialog"> 399 + <p> 400 + Delete theme &quot;{theme.name}&quot;? This cannot be undone. 401 + </p> 402 + <form 403 + method="post" 404 + action={`/admin/themes/${themeRkey}/delete`} 405 + class="dialog-actions" 406 + > 407 + <button type="submit" class="btn btn-danger"> 408 + Delete 409 + </button> 410 + <button 411 + type="button" 412 + class="btn btn-secondary" 413 + onclick={`document.getElementById('${dialogId}').close()`} 414 + > 415 + Cancel 416 + </button> 417 + </form> 418 + </dialog> 419 + </div> 420 + ); 421 + })} 422 + </div> 423 + )} 424 + 425 + {/* Policy form — availability checkboxes on cards associate via form="policy-form" */} 426 + <section class="admin-section"> 427 + <h2>Theme Policy</h2> 428 + <form id="policy-form" method="post" action="/admin/theme-policy"> 429 + <div class="form-group"> 430 + <label for="defaultLightThemeUri">Default Light Theme</label> 431 + <select id="defaultLightThemeUri" name="defaultLightThemeUri"> 432 + <option value="">— none —</option> 433 + {lightThemes.map((t) => ( 434 + <option 435 + value={t.uri} 436 + selected={policy?.defaultLightThemeUri === t.uri} 437 + > 438 + {t.name} 439 + </option> 440 + ))} 441 + </select> 442 + </div> 443 + 444 + <div class="form-group"> 445 + <label for="defaultDarkThemeUri">Default Dark Theme</label> 446 + <select id="defaultDarkThemeUri" name="defaultDarkThemeUri"> 447 + <option value="">— none —</option> 448 + {darkThemes.map((t) => ( 449 + <option 450 + value={t.uri} 451 + selected={policy?.defaultDarkThemeUri === t.uri} 452 + > 453 + {t.name} 454 + </option> 455 + ))} 456 + </select> 457 + </div> 458 + 459 + <div class="form-group"> 460 + <label> 461 + <input 462 + type="checkbox" 463 + name="allowUserChoice" 464 + checked={policy?.allowUserChoice ?? true} 465 + /> 466 + {" "}Allow users to choose their own theme 467 + </label> 468 + </div> 469 + 470 + <p class="form-hint"> 471 + Check themes above to make them available to users. 472 + </p> 473 + <button type="submit" class="btn btn-primary"> 474 + Save Policy 475 + </button> 476 + </form> 477 + </section> 478 + 479 + {/* Create new theme */} 480 + <details class="structure-add-form"> 481 + <summary class="structure-add-form__trigger">+ Create New Theme</summary> 482 + <form 483 + method="post" 484 + action="/admin/themes" 485 + class="structure-edit-form__body" 486 + > 487 + <div class="form-group"> 488 + <label for="new-theme-name">Name</label> 489 + <input 490 + id="new-theme-name" 491 + type="text" 492 + name="name" 493 + required 494 + placeholder="My Custom Theme" 495 + /> 496 + </div> 497 + <div class="form-group"> 498 + <label for="new-theme-scheme">Color Scheme</label> 499 + <select id="new-theme-scheme" name="colorScheme"> 500 + <option value="light">Light</option> 501 + <option value="dark">Dark</option> 502 + </select> 503 + </div> 504 + <div class="form-group"> 505 + <label for="new-theme-preset">Start from Preset</label> 506 + <select id="new-theme-preset" name="preset"> 507 + <option value="neobrutal-light">Neobrutal Light</option> 508 + <option value="neobrutal-dark">Neobrutal Dark</option> 509 + <option value="blank">Blank</option> 510 + </select> 511 + </div> 512 + <button type="submit" class="btn btn-primary"> 513 + Create Theme 514 + </button> 515 + </form> 516 + </details> 517 + </BaseLayout> 518 + ); 519 + }); 520 + 521 + // ── GET /admin/themes/:rkey ──────────────────────────────────────────────── 522 + 523 + app.get("/admin/themes/:rkey", async (c) => { 524 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 525 + if (!auth.authenticated) return c.redirect("/login"); 526 + if (!canManageThemes(auth)) { 527 + return c.html( 528 + <BaseLayout title="Access Denied — atBB Admin" auth={auth}> 529 + <PageHeader title="Access Denied" /> 530 + <p>You don&apos;t have permission to manage themes.</p> 531 + </BaseLayout>, 532 + 403 533 + ); 534 + } 535 + 536 + const themeRkey = c.req.param("rkey"); 537 + const presetParam = c.req.query("preset") ?? null; 538 + const successMsg = c.req.query("success") === "1" ? "Theme saved successfully." : null; 539 + const errorMsg = c.req.query("error") ?? null; 540 + 541 + // Fetch theme from AppView 542 + let theme: AdminThemeEntry | null = null; 543 + try { 544 + const res = await fetch(`${appviewUrl}/api/themes/${themeRkey}`); 545 + if (res.status === 404) { 546 + return c.html( 547 + <BaseLayout title="Theme Not Found — atBB Admin" auth={auth}> 548 + <PageHeader title="Theme Not Found" /> 549 + <p>This theme does not exist.</p> 550 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 551 + </BaseLayout>, 552 + 404 553 + ); 554 + } 555 + if (res.ok) { 556 + try { 557 + theme = (await res.json()) as AdminThemeEntry; 558 + } catch { 559 + logger.error("Failed to parse theme response", { 560 + operation: "GET /admin/themes/:rkey", 561 + themeRkey, 562 + }); 563 + } 564 + } else { 565 + logger.error("AppView returned error loading theme", { 566 + operation: "GET /admin/themes/:rkey", 567 + themeRkey, 568 + status: res.status, 569 + }); 570 + } 571 + } catch (error) { 572 + if (isProgrammingError(error)) throw error; 573 + logger.error("Network error loading theme", { 574 + operation: "GET /admin/themes/:rkey", 575 + themeRkey, 576 + error: error instanceof Error ? error.message : String(error), 577 + }); 578 + } 579 + 580 + if (!theme) { 581 + return c.html( 582 + <BaseLayout title="Theme Unavailable — atBB Admin" auth={auth}> 583 + <PageHeader title="Theme Unavailable" /> 584 + <p>Unable to load theme data. Please try again.</p> 585 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 586 + </BaseLayout>, 587 + 500 588 + ); 589 + } 590 + 591 + // If ?preset is set, override DB tokens with preset tokens 592 + const presetTokens = presetParam ? (THEME_PRESETS[presetParam] ?? null) : null; 593 + const effectiveTokens: Record<string, string> = presetTokens 594 + ? { ...theme.tokens, ...presetTokens } 595 + : { ...theme.tokens }; 596 + 597 + const fontUrlsText = (theme.fontUrls ?? []).join("\n"); 598 + 599 + return c.html( 600 + <BaseLayout title={`Edit Theme: ${theme.name} — atBB Admin`} auth={auth}> 601 + <PageHeader title={`Edit Theme: ${theme.name}`} /> 602 + 603 + {successMsg && <div class="structure-success-banner">{successMsg}</div>} 604 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 605 + 606 + <a href="/admin/themes" class="btn btn-secondary btn-sm" style="margin-bottom: var(--space-md); display: inline-block;"> 607 + ← Back to themes 608 + </a> 609 + 610 + {/* Metadata + tokens form */} 611 + <form 612 + id="editor-form" 613 + method="post" 614 + action={`/admin/themes/${themeRkey}/save`} 615 + class="theme-editor" 616 + > 617 + {/* Metadata */} 618 + <fieldset class="token-group"> 619 + <legend>Theme Metadata</legend> 620 + <div class="token-input"> 621 + <label for="theme-name">Name</label> 622 + <input type="text" id="theme-name" name="name" value={theme.name} required /> 623 + </div> 624 + <div class="token-input"> 625 + <label for="theme-scheme">Color Scheme</label> 626 + <select id="theme-scheme" name="colorScheme"> 627 + <option value="light" selected={theme.colorScheme === "light" ? true : undefined}>Light</option> 628 + <option value="dark" selected={theme.colorScheme === "dark" ? true : undefined}>Dark</option> 629 + </select> 630 + </div> 631 + <div class="token-input"> 632 + <label for="theme-font-urls">Font URLs (one per line)</label> 633 + <textarea id="theme-font-urls" name="fontUrls" rows={3} placeholder="https://fonts.googleapis.com/css2?family=..."> 634 + {fontUrlsText} 635 + </textarea> 636 + </div> 637 + </fieldset> 638 + 639 + {/* Token editor + live preview layout */} 640 + <div class="theme-editor__layout"> 641 + {/* Left: token controls */} 642 + <div 643 + class="theme-editor__controls" 644 + hx-post={`/admin/themes/${themeRkey}/preview`} 645 + hx-trigger="input delay:500ms" 646 + hx-target="#preview-pane" 647 + hx-include="#editor-form" 648 + > 649 + <TokenFieldset 650 + legend="Colors" 651 + tokens={COLOR_TOKENS} 652 + effectiveTokens={effectiveTokens} 653 + isColor={true} 654 + /> 655 + <TokenFieldset 656 + legend="Typography" 657 + tokens={TYPOGRAPHY_TOKENS} 658 + effectiveTokens={effectiveTokens} 659 + isColor={false} 660 + /> 661 + <TokenFieldset 662 + legend="Spacing & Layout" 663 + tokens={SPACING_TOKENS} 664 + effectiveTokens={effectiveTokens} 665 + isColor={false} 666 + /> 667 + <TokenFieldset 668 + legend="Components" 669 + tokens={COMPONENT_TOKENS} 670 + effectiveTokens={effectiveTokens} 671 + isColor={false} 672 + /> 673 + 674 + {/* CSS overrides — disabled until ATB-62 */} 675 + <fieldset class="token-group"> 676 + <legend>CSS Overrides</legend> 677 + <div class="token-input"> 678 + <label for="css-overrides"> 679 + Custom CSS{" "} 680 + <span class="form-hint">(disabled — CSS sanitization not yet implemented)</span> 681 + </label> 682 + <textarea 683 + id="css-overrides" 684 + name="css-overrides" 685 + rows={6} 686 + disabled 687 + aria-describedby="css-overrides-hint" 688 + placeholder="/* Will be enabled in ATB-62 */" 689 + > 690 + {theme.cssOverrides ?? ""} 691 + </textarea> 692 + <p id="css-overrides-hint" class="form-hint"> 693 + Raw CSS overrides will be available after CSS sanitization is implemented (ATB-62). 694 + </p> 695 + </div> 696 + </fieldset> 697 + </div> 698 + 699 + {/* Right: live preview */} 700 + <div class="theme-editor__preview"> 701 + <h3>Live Preview</h3> 702 + <div id="preview-pane" class="preview-pane"> 703 + <ThemePreviewContent tokens={effectiveTokens} /> 704 + </div> 705 + </div> 706 + </div> 707 + 708 + {/* Actions */} 709 + <div class="theme-editor__actions"> 710 + <button type="submit" class="btn btn-primary">Save Theme</button> 711 + 712 + <button 713 + type="button" 714 + class="btn btn-secondary" 715 + onclick="document.getElementById('reset-dialog').showModal()" 716 + > 717 + Reset to Preset 718 + </button> 719 + </div> 720 + </form> 721 + 722 + {/* Reset to preset dialog */} 723 + <dialog id="reset-dialog" class="structure-confirm-dialog"> 724 + <form method="post" action={`/admin/themes/${themeRkey}/reset-to-preset`}> 725 + <p>Reset all token values to a built-in preset? Your unsaved changes will be lost.</p> 726 + <div class="form-group"> 727 + <label for="reset-preset-select">Reset to preset:</label> 728 + <select id="reset-preset-select" name="preset"> 729 + <option value="neobrutal-light">Neobrutal Light</option> 730 + <option value="neobrutal-dark">Neobrutal Dark</option> 731 + <option value="blank">Blank (empty tokens)</option> 732 + </select> 733 + </div> 734 + <div class="dialog-actions"> 735 + <button type="submit" class="btn btn-danger">Reset</button> 736 + <button 737 + type="button" 738 + class="btn btn-secondary" 739 + onclick="document.getElementById('reset-dialog').close()" 740 + > 741 + Cancel 742 + </button> 743 + </div> 744 + </form> 745 + </dialog> 746 + </BaseLayout> 747 + ); 748 + }); 749 + 750 + // ── POST /admin/themes ──────────────────────────────────────────────────── 751 + 752 + app.post("/admin/themes", async (c) => { 753 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 754 + if (!auth.authenticated) return c.redirect("/login"); 755 + if (!canManageThemes(auth)) { 756 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 757 + } 758 + 759 + const cookie = c.req.header("cookie") ?? ""; 760 + 761 + let body: Record<string, string | File>; 762 + try { 763 + body = await c.req.parseBody(); 764 + } catch (error) { 765 + if (isProgrammingError(error)) throw error; 766 + return c.redirect( 767 + `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 768 + 302 769 + ); 770 + } 771 + 772 + const name = typeof body.name === "string" ? body.name.trim() : ""; 773 + const colorScheme = typeof body.colorScheme === "string" ? body.colorScheme : "light"; 774 + const preset = typeof body.preset === "string" ? body.preset : "blank"; 775 + 776 + if (!name) { 777 + return c.redirect( 778 + `/admin/themes?error=${encodeURIComponent("Theme name is required.")}`, 779 + 302 780 + ); 781 + } 782 + 783 + const tokens = THEME_PRESETS[preset] ?? {}; 784 + 785 + let apiRes: Response; 786 + try { 787 + apiRes = await fetch(`${appviewUrl}/api/admin/themes`, { 788 + method: "POST", 789 + headers: { "Content-Type": "application/json", Cookie: cookie }, 790 + body: JSON.stringify({ name, colorScheme, tokens }), 791 + }); 792 + } catch (error) { 793 + if (isProgrammingError(error)) throw error; 794 + logger.error("Network error creating theme", { 795 + operation: "POST /admin/themes", 796 + error: error instanceof Error ? error.message : String(error), 797 + }); 798 + return c.redirect( 799 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 800 + 302 801 + ); 802 + } 803 + 804 + if (!apiRes.ok) { 805 + const msg = await extractAppviewError(apiRes, "Failed to create theme. Please try again."); 806 + return c.redirect( 807 + `/admin/themes?error=${encodeURIComponent(msg)}`, 808 + 302 809 + ); 810 + } 811 + 812 + return c.redirect("/admin/themes", 302); 813 + }); 814 + 815 + // ── POST /admin/themes/:rkey/duplicate ──────────────────────────────────── 816 + 817 + app.post("/admin/themes/:rkey/duplicate", async (c) => { 818 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 819 + if (!auth.authenticated) return c.redirect("/login"); 820 + if (!canManageThemes(auth)) { 821 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 822 + } 823 + 824 + const cookie = c.req.header("cookie") ?? ""; 825 + const themeRkey = c.req.param("rkey"); 826 + 827 + let apiRes: Response; 828 + try { 829 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}/duplicate`, { 830 + method: "POST", 831 + headers: { Cookie: cookie }, 832 + }); 833 + } catch (error) { 834 + if (isProgrammingError(error)) throw error; 835 + logger.error("Network error duplicating theme", { 836 + operation: "POST /admin/themes/:rkey/duplicate", 837 + themeRkey, 838 + error: error instanceof Error ? error.message : String(error), 839 + }); 840 + return c.redirect( 841 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 842 + 302 843 + ); 844 + } 845 + 846 + if (!apiRes.ok) { 847 + const msg = await extractAppviewError(apiRes, "Failed to duplicate theme. Please try again."); 848 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 849 + } 850 + 851 + return c.redirect("/admin/themes", 302); 852 + }); 853 + 854 + // ── POST /admin/themes/:rkey/delete ────────────────────────────────────── 855 + 856 + app.post("/admin/themes/:rkey/delete", async (c) => { 857 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 858 + if (!auth.authenticated) return c.redirect("/login"); 859 + if (!canManageThemes(auth)) { 860 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 861 + } 862 + 863 + const cookie = c.req.header("cookie") ?? ""; 864 + const themeRkey = c.req.param("rkey"); 865 + 866 + let apiRes: Response; 867 + try { 868 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 869 + method: "DELETE", 870 + headers: { Cookie: cookie }, 871 + }); 872 + } catch (error) { 873 + if (isProgrammingError(error)) throw error; 874 + logger.error("Network error deleting theme", { 875 + operation: "POST /admin/themes/:rkey/delete", 876 + themeRkey, 877 + error: error instanceof Error ? error.message : String(error), 878 + }); 879 + return c.redirect( 880 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 881 + 302 882 + ); 883 + } 884 + 885 + if (!apiRes.ok) { 886 + if (apiRes.status === 409) { 887 + return c.redirect( 888 + `/admin/themes?error=${encodeURIComponent("Cannot delete a theme that is currently set as a default.")}`, 889 + 302 890 + ); 891 + } 892 + const msg = await extractAppviewError(apiRes, "Failed to delete theme. Please try again."); 893 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 894 + } 895 + 896 + return c.redirect("/admin/themes", 302); 897 + }); 898 + 899 + // ── POST /admin/theme-policy ────────────────────────────────────────────── 900 + 901 + app.post("/admin/theme-policy", async (c) => { 902 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 903 + if (!auth.authenticated) return c.redirect("/login"); 904 + if (!canManageThemes(auth)) { 905 + return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 906 + } 907 + 908 + const cookie = c.req.header("cookie") ?? ""; 909 + 910 + let rawBody: Record<string, string | string[] | File | File[]>; 911 + try { 912 + rawBody = await c.req.parseBody({ all: true }); 913 + } catch (error) { 914 + if (isProgrammingError(error)) throw error; 915 + return c.redirect( 916 + `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 917 + 302 918 + ); 919 + } 920 + 921 + const defaultLightThemeUri = 922 + typeof rawBody.defaultLightThemeUri === "string" ? rawBody.defaultLightThemeUri : ""; 923 + const defaultDarkThemeUri = 924 + typeof rawBody.defaultDarkThemeUri === "string" ? rawBody.defaultDarkThemeUri : ""; 925 + // Checkbox: present with value "on" when checked, absent when unchecked 926 + const allowUserChoice = rawBody.allowUserChoice === "on"; 927 + 928 + // availableThemes may be a single string, an array, or absent 929 + const rawAvailable = rawBody.availableThemes; 930 + const availableThemes = 931 + rawAvailable === undefined 932 + ? [] 933 + : Array.isArray(rawAvailable) 934 + ? rawAvailable.filter((v): v is string => typeof v === "string") 935 + : typeof rawAvailable === "string" 936 + ? [rawAvailable] 937 + : []; 938 + 939 + let apiRes: Response; 940 + try { 941 + apiRes = await fetch(`${appviewUrl}/api/admin/theme-policy`, { 942 + method: "PUT", 943 + headers: { "Content-Type": "application/json", Cookie: cookie }, 944 + body: JSON.stringify({ 945 + defaultLightThemeUri, 946 + defaultDarkThemeUri, 947 + allowUserChoice, 948 + availableThemes: availableThemes.map((uri) => ({ uri })), 949 + }), 950 + }); 951 + } catch (error) { 952 + if (isProgrammingError(error)) throw error; 953 + logger.error("Network error updating theme policy", { 954 + operation: "POST /admin/theme-policy", 955 + error: error instanceof Error ? error.message : String(error), 956 + }); 957 + return c.redirect( 958 + `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 959 + 302 960 + ); 961 + } 962 + 963 + if (!apiRes.ok) { 964 + const msg = await extractAppviewError(apiRes, "Failed to update theme policy. Please try again."); 965 + return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 966 + } 967 + 968 + return c.redirect("/admin/themes", 302); 969 + }); 970 + 971 + // ── POST /admin/themes/:rkey/preview ───────────────────────────────────── 972 + 973 + app.post("/admin/themes/:rkey/preview", async (c) => { 974 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 975 + if (!auth.authenticated) return c.redirect("/login"); 976 + if (!canManageThemes(auth)) { 977 + return c.html("", 403); 978 + } 979 + 980 + let rawBody: Record<string, string | File>; 981 + try { 982 + rawBody = await c.req.parseBody(); 983 + } catch (error) { 984 + if (isProgrammingError(error)) throw error; 985 + // Return empty preview on parse error — don't break the HTMX swap 986 + return c.html(<ThemePreviewContent tokens={{}} />); 987 + } 988 + 989 + // Only accept known token names — ignore metadata fields like name/colorScheme 990 + const tokens: Record<string, string> = {}; 991 + for (const tokenName of ALL_KNOWN_TOKENS) { 992 + const raw = rawBody[tokenName]; 993 + if (typeof raw !== "string") continue; 994 + const safe = sanitizeTokenValue(raw); 995 + if (safe !== null) { 996 + tokens[tokenName] = safe; 997 + } 998 + } 999 + 1000 + return c.html(<ThemePreviewContent tokens={tokens} />); 1001 + }); 1002 + 1003 + // ── POST /admin/themes/:rkey/save ───────────────────────────────────────── 1004 + 1005 + app.post("/admin/themes/:rkey/save", async (c) => { 1006 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1007 + if (!auth.authenticated) return c.redirect("/login"); 1008 + if (!canManageThemes(auth)) { 1009 + return c.html( 1010 + <BaseLayout title="Access Denied" auth={auth}> 1011 + <p>Access denied.</p> 1012 + </BaseLayout>, 1013 + 403 1014 + ); 1015 + } 1016 + 1017 + const themeRkey = c.req.param("rkey"); 1018 + const cookie = c.req.header("cookie") ?? ""; 1019 + 1020 + let rawBody: Record<string, string | File>; 1021 + try { 1022 + rawBody = await c.req.parseBody(); 1023 + } catch (error) { 1024 + if (isProgrammingError(error)) throw error; 1025 + return c.redirect( 1026 + `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid form submission.")}`, 1027 + 302 1028 + ); 1029 + } 1030 + 1031 + const name = typeof rawBody.name === "string" ? rawBody.name.trim() : ""; 1032 + if (!name) { 1033 + return c.redirect( 1034 + `/admin/themes/${themeRkey}?error=${encodeURIComponent("Theme name is required.")}`, 1035 + 302 1036 + ); 1037 + } 1038 + const colorScheme = typeof rawBody.colorScheme === "string" ? rawBody.colorScheme : "light"; 1039 + const fontUrlsRaw = typeof rawBody.fontUrls === "string" ? rawBody.fontUrls : ""; 1040 + const fontUrls = fontUrlsRaw 1041 + .split("\n") 1042 + .map((u) => u.trim()) 1043 + .filter(Boolean); 1044 + 1045 + // Extract token values from form fields 1046 + const tokens: Record<string, string> = {}; 1047 + for (const tokenName of ALL_KNOWN_TOKENS) { 1048 + const raw = rawBody[tokenName]; 1049 + if (typeof raw !== "string") continue; 1050 + const safe = sanitizeTokenValue(raw.trim()); 1051 + if (safe !== null && safe !== "") { 1052 + tokens[tokenName] = safe; 1053 + } 1054 + } 1055 + 1056 + let apiRes: Response; 1057 + try { 1058 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 1059 + method: "PUT", 1060 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1061 + body: JSON.stringify({ name, colorScheme, tokens, fontUrls }), 1062 + }); 1063 + } catch (error) { 1064 + if (isProgrammingError(error)) throw error; 1065 + logger.error("Network error saving theme", { 1066 + operation: "POST /admin/themes/:rkey/save", 1067 + themeRkey, 1068 + error: error instanceof Error ? error.message : String(error), 1069 + }); 1070 + return c.redirect( 1071 + `/admin/themes/${themeRkey}?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1072 + 302 1073 + ); 1074 + } 1075 + 1076 + if (!apiRes.ok) { 1077 + const msg = await extractAppviewError(apiRes, "Failed to save theme. Please try again."); 1078 + return c.redirect( 1079 + `/admin/themes/${themeRkey}?error=${encodeURIComponent(msg)}`, 1080 + 302 1081 + ); 1082 + } 1083 + 1084 + return c.redirect(`/admin/themes/${themeRkey}?success=1`, 302); 1085 + }); 1086 + 1087 + // ── POST /admin/themes/:rkey/reset-to-preset ────────────────────────────── 1088 + 1089 + app.post("/admin/themes/:rkey/reset-to-preset", async (c) => { 1090 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1091 + if (!auth.authenticated) return c.redirect("/login"); 1092 + if (!canManageThemes(auth)) { 1093 + return c.html( 1094 + <BaseLayout title="Access Denied" auth={auth}> 1095 + <p>Access denied.</p> 1096 + </BaseLayout>, 1097 + 403 1098 + ); 1099 + } 1100 + 1101 + const themeRkey = c.req.param("rkey"); 1102 + 1103 + let body: Record<string, string | File>; 1104 + try { 1105 + body = await c.req.parseBody(); 1106 + } catch (error) { 1107 + if (isProgrammingError(error)) throw error; 1108 + return c.redirect( 1109 + `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid form submission.")}`, 1110 + 302 1111 + ); 1112 + } 1113 + 1114 + const preset = typeof body.preset === "string" ? body.preset : ""; 1115 + if (!(preset in THEME_PRESETS)) { 1116 + return c.redirect( 1117 + `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid preset name.")}`, 1118 + 302 1119 + ); 1120 + } 1121 + 1122 + return c.redirect(`/admin/themes/${themeRkey}?preset=${encodeURIComponent(preset)}`, 302); 1123 + }); 1124 + 1125 + return app; 1126 + } 1127 +
+2 -528
apps/web/src/routes/admin.tsx
··· 12 12 } from "../lib/session.js"; 13 13 import { isProgrammingError } from "../lib/errors.js"; 14 14 import { logger } from "../lib/logger.js"; 15 - import neobrutalLight from "../styles/presets/neobrutal-light.json"; 16 - import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 15 + import { createAdminThemeRoutes } from "./admin-themes.js"; 17 16 18 17 // ─── Types ───────────────────────────────────────────────────────────────── 19 18 ··· 61 60 reason: string | null; 62 61 createdAt: string; 63 62 } 64 - 65 - interface AdminThemeEntry { 66 - id: string; 67 - uri: string; 68 - name: string; 69 - colorScheme: string; 70 - tokens: Record<string, string>; 71 - cssOverrides: string | null; 72 - fontUrls: string[] | null; 73 - createdAt: string; 74 - indexedAt: string; 75 - } 76 - 77 - interface ThemePolicy { 78 - defaultLightThemeUri: string | null; 79 - defaultDarkThemeUri: string | null; 80 - allowUserChoice: boolean; 81 - availableThemes: Array<{ uri: string; cid: string }>; 82 - } 83 - 84 - // Preset token maps — used by POST /admin/themes to seed tokens on creation 85 - const THEME_PRESETS: Record<string, Record<string, string>> = { 86 - "neobrutal-light": neobrutalLight as Record<string, string>, 87 - "neobrutal-dark": neobrutalDark as Record<string, string>, 88 - "blank": {}, 89 - }; 90 63 91 64 const ACTION_LABELS: Record<string, string> = { 92 65 "space.atbb.modAction.ban": "Ban", ··· 1490 1463 1491 1464 // ─── Themes ──────────────────────────────────────────────────────────────── 1492 1465 1493 - app.get("/admin/themes", async (c) => { 1494 - const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1495 - 1496 - if (!auth.authenticated) { 1497 - return c.redirect("/login"); 1498 - } 1499 - 1500 - if (!canManageThemes(auth)) { 1501 - return c.html( 1502 - <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 1503 - <PageHeader title="Themes" /> 1504 - <p>You don&apos;t have permission to manage themes.</p> 1505 - </BaseLayout>, 1506 - 403 1507 - ); 1508 - } 1509 - 1510 - const cookie = c.req.header("cookie") ?? ""; 1511 - const errorMsg = c.req.query("error") ?? null; 1512 - 1513 - let adminThemes: AdminThemeEntry[] = []; 1514 - let policy: ThemePolicy | null = null; 1515 - 1516 - try { 1517 - const [themesRes, policyRes] = await Promise.all([ 1518 - fetch(`${appviewUrl}/api/admin/themes`, { headers: { Cookie: cookie } }), 1519 - fetch(`${appviewUrl}/api/theme-policy`, { headers: { Cookie: cookie } }), 1520 - ]); 1521 - 1522 - if (themesRes.ok) { 1523 - try { 1524 - const data = (await themesRes.json()) as { themes: AdminThemeEntry[] }; 1525 - adminThemes = data.themes; 1526 - } catch { 1527 - logger.error("Failed to parse admin themes response", { 1528 - operation: "GET /admin/themes", 1529 - status: themesRes.status, 1530 - }); 1531 - } 1532 - } else { 1533 - logger.error("Failed to fetch admin themes list", { 1534 - operation: "GET /admin/themes", 1535 - status: themesRes.status, 1536 - }); 1537 - } 1538 - 1539 - if (policyRes.ok) { 1540 - try { 1541 - policy = (await policyRes.json()) as ThemePolicy; 1542 - } catch { 1543 - logger.error("Failed to parse theme policy response", { 1544 - operation: "GET /admin/themes", 1545 - status: policyRes.status, 1546 - }); 1547 - } 1548 - } else if (policyRes.status !== 404) { 1549 - logger.error("Failed to fetch theme policy", { 1550 - operation: "GET /admin/themes", 1551 - status: policyRes.status, 1552 - }); 1553 - } 1554 - // 404 = no policy yet — render page with empty policy (not an error) 1555 - } catch (error) { 1556 - if (isProgrammingError(error)) throw error; 1557 - logger.error("Network error fetching themes data", { 1558 - operation: "GET /admin/themes", 1559 - error: error instanceof Error ? error.message : String(error), 1560 - }); 1561 - } 1562 - 1563 - const availableUris = new Set((policy?.availableThemes ?? []).map((t) => t.uri)); 1564 - const lightThemes = adminThemes.filter((t) => t.colorScheme === "light"); 1565 - const darkThemes = adminThemes.filter((t) => t.colorScheme === "dark"); 1566 - 1567 - return c.html( 1568 - <BaseLayout title="Themes — atBB Admin" auth={auth}> 1569 - <PageHeader title="Themes" /> 1570 - 1571 - {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 1572 - 1573 - {adminThemes.length === 0 ? ( 1574 - <EmptyState message="No themes yet. Create one below." /> 1575 - ) : ( 1576 - <div class="structure-list"> 1577 - {adminThemes.map((theme) => { 1578 - const themeRkey = theme.uri.split("/").pop() ?? theme.id; 1579 - const dialogId = `confirm-delete-theme-${themeRkey}`; 1580 - const swatchTokens = [ 1581 - "color-bg", 1582 - "color-surface", 1583 - "color-primary", 1584 - "color-secondary", 1585 - "color-border", 1586 - ] as const; 1587 - 1588 - return ( 1589 - <div class="structure-item"> 1590 - <div class="structure-item__header"> 1591 - <div class="structure-item__title"> 1592 - <label> 1593 - <input 1594 - type="checkbox" 1595 - form="policy-form" 1596 - name="availableThemes" 1597 - value={theme.uri} 1598 - checked={availableUris.has(theme.uri)} 1599 - /> 1600 - {" "} 1601 - {theme.name} 1602 - </label> 1603 - <span class={`badge badge--${theme.colorScheme}`}> 1604 - {theme.colorScheme} 1605 - </span> 1606 - </div> 1607 - 1608 - <div class="theme-swatches" aria-hidden="true"> 1609 - {swatchTokens.map((token) => { 1610 - const value = theme.tokens[token] ?? "#cccccc"; 1611 - const safe = 1612 - !value.startsWith("var(") && 1613 - !value.includes(";") && 1614 - !value.includes("<"); 1615 - return ( 1616 - <span 1617 - class="theme-swatch" 1618 - style={safe ? `background:${value}` : "background:#cccccc"} 1619 - title={token} 1620 - /> 1621 - ); 1622 - })} 1623 - </div> 1624 - 1625 - <div class="structure-item__actions"> 1626 - <span class="btn btn-secondary btn-sm" aria-disabled="true"> 1627 - Edit 1628 - </span> 1629 - 1630 - <form 1631 - method="post" 1632 - action={`/admin/themes/${themeRkey}/duplicate`} 1633 - style="display:inline" 1634 - > 1635 - <button type="submit" class="btn btn-secondary btn-sm"> 1636 - Duplicate 1637 - </button> 1638 - </form> 1639 - 1640 - <button 1641 - type="button" 1642 - class="btn btn-danger btn-sm" 1643 - onclick={`document.getElementById('${dialogId}').showModal()`} 1644 - > 1645 - Delete 1646 - </button> 1647 - </div> 1648 - </div> 1649 - 1650 - <dialog id={dialogId} class="structure-confirm-dialog"> 1651 - <p> 1652 - Delete theme &quot;{theme.name}&quot;? This cannot be undone. 1653 - </p> 1654 - <form 1655 - method="post" 1656 - action={`/admin/themes/${themeRkey}/delete`} 1657 - class="dialog-actions" 1658 - > 1659 - <button type="submit" class="btn btn-danger"> 1660 - Delete 1661 - </button> 1662 - <button 1663 - type="button" 1664 - class="btn btn-secondary" 1665 - onclick={`document.getElementById('${dialogId}').close()`} 1666 - > 1667 - Cancel 1668 - </button> 1669 - </form> 1670 - </dialog> 1671 - </div> 1672 - ); 1673 - })} 1674 - </div> 1675 - )} 1676 - 1677 - {/* Policy form — availability checkboxes on cards associate via form="policy-form" */} 1678 - <section class="admin-section"> 1679 - <h2>Theme Policy</h2> 1680 - <form id="policy-form" method="post" action="/admin/theme-policy"> 1681 - <div class="form-group"> 1682 - <label for="defaultLightThemeUri">Default Light Theme</label> 1683 - <select id="defaultLightThemeUri" name="defaultLightThemeUri"> 1684 - <option value="">— none —</option> 1685 - {lightThemes.map((t) => ( 1686 - <option 1687 - value={t.uri} 1688 - selected={policy?.defaultLightThemeUri === t.uri} 1689 - > 1690 - {t.name} 1691 - </option> 1692 - ))} 1693 - </select> 1694 - </div> 1695 - 1696 - <div class="form-group"> 1697 - <label for="defaultDarkThemeUri">Default Dark Theme</label> 1698 - <select id="defaultDarkThemeUri" name="defaultDarkThemeUri"> 1699 - <option value="">— none —</option> 1700 - {darkThemes.map((t) => ( 1701 - <option 1702 - value={t.uri} 1703 - selected={policy?.defaultDarkThemeUri === t.uri} 1704 - > 1705 - {t.name} 1706 - </option> 1707 - ))} 1708 - </select> 1709 - </div> 1710 - 1711 - <div class="form-group"> 1712 - <label> 1713 - <input 1714 - type="checkbox" 1715 - name="allowUserChoice" 1716 - checked={policy?.allowUserChoice ?? true} 1717 - /> 1718 - {" "}Allow users to choose their own theme 1719 - </label> 1720 - </div> 1721 - 1722 - <p class="form-hint"> 1723 - Check themes above to make them available to users. 1724 - </p> 1725 - <button type="submit" class="btn btn-primary"> 1726 - Save Policy 1727 - </button> 1728 - </form> 1729 - </section> 1730 - 1731 - {/* Create new theme */} 1732 - <details class="structure-add-form"> 1733 - <summary class="structure-add-form__trigger">+ Create New Theme</summary> 1734 - <form 1735 - method="post" 1736 - action="/admin/themes" 1737 - class="structure-edit-form__body" 1738 - > 1739 - <div class="form-group"> 1740 - <label for="new-theme-name">Name</label> 1741 - <input 1742 - id="new-theme-name" 1743 - type="text" 1744 - name="name" 1745 - required 1746 - placeholder="My Custom Theme" 1747 - /> 1748 - </div> 1749 - <div class="form-group"> 1750 - <label for="new-theme-scheme">Color Scheme</label> 1751 - <select id="new-theme-scheme" name="colorScheme"> 1752 - <option value="light">Light</option> 1753 - <option value="dark">Dark</option> 1754 - </select> 1755 - </div> 1756 - <div class="form-group"> 1757 - <label for="new-theme-preset">Start from Preset</label> 1758 - <select id="new-theme-preset" name="preset"> 1759 - <option value="neobrutal-light">Neobrutal Light</option> 1760 - <option value="neobrutal-dark">Neobrutal Dark</option> 1761 - <option value="blank">Blank</option> 1762 - </select> 1763 - </div> 1764 - <button type="submit" class="btn btn-primary"> 1765 - Create Theme 1766 - </button> 1767 - </form> 1768 - </details> 1769 - </BaseLayout> 1770 - ); 1771 - }); 1772 - 1773 - // ── POST /admin/themes ──────────────────────────────────────────────────── 1774 - 1775 - app.post("/admin/themes", async (c) => { 1776 - const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1777 - if (!auth.authenticated) return c.redirect("/login"); 1778 - if (!canManageThemes(auth)) { 1779 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1780 - } 1781 - 1782 - const cookie = c.req.header("cookie") ?? ""; 1783 - 1784 - let body: Record<string, string | File>; 1785 - try { 1786 - body = await c.req.parseBody(); 1787 - } catch (error) { 1788 - if (isProgrammingError(error)) throw error; 1789 - return c.redirect( 1790 - `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 1791 - 302 1792 - ); 1793 - } 1794 - 1795 - const name = typeof body.name === "string" ? body.name.trim() : ""; 1796 - const colorScheme = typeof body.colorScheme === "string" ? body.colorScheme : "light"; 1797 - const preset = typeof body.preset === "string" ? body.preset : "blank"; 1798 - 1799 - if (!name) { 1800 - return c.redirect( 1801 - `/admin/themes?error=${encodeURIComponent("Theme name is required.")}`, 1802 - 302 1803 - ); 1804 - } 1805 - 1806 - const tokens = THEME_PRESETS[preset] ?? {}; 1807 - 1808 - let apiRes: Response; 1809 - try { 1810 - apiRes = await fetch(`${appviewUrl}/api/admin/themes`, { 1811 - method: "POST", 1812 - headers: { "Content-Type": "application/json", Cookie: cookie }, 1813 - body: JSON.stringify({ name, colorScheme, tokens }), 1814 - }); 1815 - } catch (error) { 1816 - if (isProgrammingError(error)) throw error; 1817 - logger.error("Network error creating theme", { 1818 - operation: "POST /admin/themes", 1819 - error: error instanceof Error ? error.message : String(error), 1820 - }); 1821 - return c.redirect( 1822 - `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1823 - 302 1824 - ); 1825 - } 1826 - 1827 - if (!apiRes.ok) { 1828 - const msg = await extractAppviewError(apiRes, "Failed to create theme. Please try again."); 1829 - return c.redirect( 1830 - `/admin/themes?error=${encodeURIComponent(msg)}`, 1831 - 302 1832 - ); 1833 - } 1834 - 1835 - return c.redirect("/admin/themes", 302); 1836 - }); 1837 - 1838 - // ── POST /admin/themes/:rkey/duplicate ──────────────────────────────────── 1839 - 1840 - app.post("/admin/themes/:rkey/duplicate", async (c) => { 1841 - const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1842 - if (!auth.authenticated) return c.redirect("/login"); 1843 - if (!canManageThemes(auth)) { 1844 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1845 - } 1846 - 1847 - const cookie = c.req.header("cookie") ?? ""; 1848 - const themeRkey = c.req.param("rkey"); 1849 - 1850 - let apiRes: Response; 1851 - try { 1852 - apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}/duplicate`, { 1853 - method: "POST", 1854 - headers: { Cookie: cookie }, 1855 - }); 1856 - } catch (error) { 1857 - if (isProgrammingError(error)) throw error; 1858 - logger.error("Network error duplicating theme", { 1859 - operation: "POST /admin/themes/:rkey/duplicate", 1860 - themeRkey, 1861 - error: error instanceof Error ? error.message : String(error), 1862 - }); 1863 - return c.redirect( 1864 - `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1865 - 302 1866 - ); 1867 - } 1868 - 1869 - if (!apiRes.ok) { 1870 - const msg = await extractAppviewError(apiRes, "Failed to duplicate theme. Please try again."); 1871 - return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1872 - } 1873 - 1874 - return c.redirect("/admin/themes", 302); 1875 - }); 1876 - 1877 - // ── POST /admin/themes/:rkey/delete ────────────────────────────────────── 1878 - 1879 - app.post("/admin/themes/:rkey/delete", async (c) => { 1880 - const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1881 - if (!auth.authenticated) return c.redirect("/login"); 1882 - if (!canManageThemes(auth)) { 1883 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1884 - } 1885 - 1886 - const cookie = c.req.header("cookie") ?? ""; 1887 - const themeRkey = c.req.param("rkey"); 1888 - 1889 - let apiRes: Response; 1890 - try { 1891 - apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 1892 - method: "DELETE", 1893 - headers: { Cookie: cookie }, 1894 - }); 1895 - } catch (error) { 1896 - if (isProgrammingError(error)) throw error; 1897 - logger.error("Network error deleting theme", { 1898 - operation: "POST /admin/themes/:rkey/delete", 1899 - themeRkey, 1900 - error: error instanceof Error ? error.message : String(error), 1901 - }); 1902 - return c.redirect( 1903 - `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1904 - 302 1905 - ); 1906 - } 1907 - 1908 - if (!apiRes.ok) { 1909 - if (apiRes.status === 409) { 1910 - return c.redirect( 1911 - `/admin/themes?error=${encodeURIComponent("Cannot delete a theme that is currently set as a default.")}`, 1912 - 302 1913 - ); 1914 - } 1915 - const msg = await extractAppviewError(apiRes, "Failed to delete theme. Please try again."); 1916 - return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1917 - } 1918 - 1919 - return c.redirect("/admin/themes", 302); 1920 - }); 1921 - 1922 - // ── POST /admin/theme-policy ────────────────────────────────────────────── 1923 - 1924 - app.post("/admin/theme-policy", async (c) => { 1925 - const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1926 - if (!auth.authenticated) return c.redirect("/login"); 1927 - if (!canManageThemes(auth)) { 1928 - return c.html(<BaseLayout title="Access Denied" auth={auth}><p>Access denied.</p></BaseLayout>, 403); 1929 - } 1930 - 1931 - const cookie = c.req.header("cookie") ?? ""; 1932 - 1933 - let rawBody: Record<string, string | string[] | File | File[]>; 1934 - try { 1935 - rawBody = await c.req.parseBody({ all: true }); 1936 - } catch (error) { 1937 - if (isProgrammingError(error)) throw error; 1938 - return c.redirect( 1939 - `/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`, 1940 - 302 1941 - ); 1942 - } 1943 - 1944 - const defaultLightThemeUri = 1945 - typeof rawBody.defaultLightThemeUri === "string" ? rawBody.defaultLightThemeUri : ""; 1946 - const defaultDarkThemeUri = 1947 - typeof rawBody.defaultDarkThemeUri === "string" ? rawBody.defaultDarkThemeUri : ""; 1948 - // Checkbox: present with value "on" when checked, absent when unchecked 1949 - const allowUserChoice = rawBody.allowUserChoice === "on"; 1950 - 1951 - // availableThemes may be a single string, an array, or absent 1952 - const rawAvailable = rawBody.availableThemes; 1953 - const availableThemes = 1954 - rawAvailable === undefined 1955 - ? [] 1956 - : Array.isArray(rawAvailable) 1957 - ? rawAvailable.filter((v): v is string => typeof v === "string") 1958 - : typeof rawAvailable === "string" 1959 - ? [rawAvailable] 1960 - : []; 1961 - 1962 - let apiRes: Response; 1963 - try { 1964 - apiRes = await fetch(`${appviewUrl}/api/admin/theme-policy`, { 1965 - method: "PUT", 1966 - headers: { "Content-Type": "application/json", Cookie: cookie }, 1967 - body: JSON.stringify({ 1968 - defaultLightThemeUri, 1969 - defaultDarkThemeUri, 1970 - allowUserChoice, 1971 - availableThemes: availableThemes.map((uri) => ({ uri })), 1972 - }), 1973 - }); 1974 - } catch (error) { 1975 - if (isProgrammingError(error)) throw error; 1976 - logger.error("Network error updating theme policy", { 1977 - operation: "POST /admin/theme-policy", 1978 - error: error instanceof Error ? error.message : String(error), 1979 - }); 1980 - return c.redirect( 1981 - `/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1982 - 302 1983 - ); 1984 - } 1985 - 1986 - if (!apiRes.ok) { 1987 - const msg = await extractAppviewError(apiRes, "Failed to update theme policy. Please try again."); 1988 - return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302); 1989 - } 1990 - 1991 - return c.redirect("/admin/themes", 302); 1992 - }); 1466 + app.route("/", createAdminThemeRoutes(appviewUrl)); 1993 1467 1994 1468 return app; 1995 1469 }
+1531
docs/plans/complete/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/complete/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