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

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.

+1531
+1531
docs/plans/2026-03-03-atb59-theme-editor-implementation.md
··· 1 + # ATB-59 Theme Token Editor — Implementation Plan 2 + 3 + > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. 4 + 5 + **Goal:** Build the admin theme token editor at `GET /admin/themes/:rkey` with HTMX live preview, save to PDS, and preset reset. 6 + 7 + **Architecture:** Extract all theme-admin route handlers from `admin.tsx` into a new `admin-themes.tsx` module, then add the editor page, HTMX preview endpoint, save handler, and reset-to-preset handler. The preview endpoint is web-server-only (calls `tokensToCss()`, never touches AppView), with tokens scoped to `.preview-pane-inner` so the editor UI doesn't update live. 8 + 9 + **Tech Stack:** Hono, Hono JSX, HTMX, Vitest, TypeScript. No new dependencies. 10 + 11 + --- 12 + 13 + ## Before You Start 14 + 15 + **Run the test suite first to establish a baseline:** 16 + 17 + ```bash 18 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 19 + pnpm --filter @atbb/web test 2>&1 | tail -20 20 + ``` 21 + 22 + Expected: all tests pass. If not, stop and investigate before proceeding. 23 + 24 + **Key files:** 25 + - `apps/web/src/routes/admin.tsx` — source of truth for theme handlers to extract (lines 1491–1992) 26 + - `apps/web/src/routes/__tests__/admin.test.tsx` — existing tests; do NOT break them 27 + - `apps/web/src/lib/theme.ts` — `tokensToCss()` utility 28 + - `apps/web/src/styles/presets/neobrutal-light.json` — 46-token preset (light) 29 + - `apps/web/src/styles/presets/neobrutal-dark.json` — 46-token preset (dark) 30 + - `apps/web/public/static/css/theme.css` — CSS class reference for preview HTML 31 + 32 + **Token names (all 46, from preset JSON):** 33 + ``` 34 + COLOR: color-bg, color-surface, color-text, color-text-muted, color-primary, 35 + color-primary-hover, color-secondary, color-border, color-shadow, 36 + color-success, color-warning, color-danger, color-code-bg, color-code-text 37 + TYPOGRAPHY: font-body, font-heading, font-mono, font-size-base, font-size-sm, 38 + font-size-xs, font-size-lg, font-size-xl, font-size-2xl, 39 + font-weight-normal, font-weight-bold, line-height-body, line-height-heading 40 + SPACING: space-xs, space-sm, space-md, space-lg, space-xl, 41 + radius, border-width, shadow-offset, content-width 42 + COMPONENTS: button-radius, button-shadow, card-radius, card-shadow, 43 + btn-press-hover, btn-press-active, input-radius, input-border, nav-height 44 + ``` 45 + 46 + --- 47 + 48 + ## Task 1: Create `admin-themes.tsx` with extracted handlers 49 + 50 + **Files:** 51 + - Create: `apps/web/src/routes/admin-themes.tsx` 52 + 53 + This task moves existing code. No new functionality yet. The file will: 54 + 1. Re-define the `extractAppviewError` helper (it stays in `admin.tsx` too, for structure routes) 55 + 2. Re-define `AdminThemeEntry`, `ThemePolicy` types (only used by theme handlers) 56 + 3. Move `THEME_PRESETS` constant and the two JSON imports 57 + 4. Export `createAdminThemeRoutes(appviewUrl: string)` factory containing all 5 existing handlers 58 + 59 + **Step 1: Create the file** 60 + 61 + `apps/web/src/routes/admin-themes.tsx`: 62 + 63 + ```tsx 64 + import { Hono } from "hono"; 65 + import { BaseLayout } from "../layouts/base.js"; 66 + import { PageHeader, EmptyState } from "../components/index.js"; 67 + import { 68 + getSessionWithPermissions, 69 + canManageThemes, 70 + } from "../lib/session.js"; 71 + import { isProgrammingError } from "../lib/errors.js"; 72 + import { logger } from "../lib/logger.js"; 73 + import { tokensToCss } from "../lib/theme.js"; 74 + import neobrutalLight from "../styles/presets/neobrutal-light.json" assert { type: "json" }; 75 + import neobrutalDark from "../styles/presets/neobrutal-dark.json" assert { type: "json" }; 76 + 77 + // ─── Types ───────────────────────────────────────────────────────────────── 78 + 79 + interface AdminThemeEntry { 80 + id: string; 81 + uri: string; 82 + name: string; 83 + colorScheme: string; 84 + tokens: Record<string, string>; 85 + cssOverrides: string | null; 86 + fontUrls: string[] | null; 87 + createdAt: string; 88 + indexedAt: string; 89 + } 90 + 91 + interface ThemePolicy { 92 + defaultLightThemeUri: string | null; 93 + defaultDarkThemeUri: string | null; 94 + allowUserChoice: boolean; 95 + availableThemes: Array<{ uri: string; cid: string }>; 96 + } 97 + 98 + // ─── Constants ────────────────────────────────────────────────────────────── 99 + 100 + const THEME_PRESETS: Record<string, Record<string, string>> = { 101 + "neobrutal-light": neobrutalLight as Record<string, string>, 102 + "neobrutal-dark": neobrutalDark as Record<string, string>, 103 + "blank": {}, 104 + }; 105 + 106 + const COLOR_TOKENS = [ 107 + "color-bg", "color-surface", "color-text", "color-text-muted", 108 + "color-primary", "color-primary-hover", "color-secondary", "color-border", 109 + "color-shadow", "color-success", "color-warning", "color-danger", 110 + "color-code-bg", "color-code-text", 111 + ] as const; 112 + 113 + const TYPOGRAPHY_TOKENS = [ 114 + "font-body", "font-heading", "font-mono", 115 + "font-size-base", "font-size-sm", "font-size-xs", "font-size-lg", 116 + "font-size-xl", "font-size-2xl", 117 + "font-weight-normal", "font-weight-bold", 118 + "line-height-body", "line-height-heading", 119 + ] as const; 120 + 121 + const SPACING_TOKENS = [ 122 + "space-xs", "space-sm", "space-md", "space-lg", "space-xl", 123 + "radius", "border-width", "shadow-offset", "content-width", 124 + ] as const; 125 + 126 + const COMPONENT_TOKENS = [ 127 + "button-radius", "button-shadow", 128 + "card-radius", "card-shadow", 129 + "btn-press-hover", "btn-press-active", 130 + "input-radius", "input-border", 131 + "nav-height", 132 + ] as const; 133 + 134 + const ALL_KNOWN_TOKENS: readonly string[] = [ 135 + ...COLOR_TOKENS, ...TYPOGRAPHY_TOKENS, ...SPACING_TOKENS, ...COMPONENT_TOKENS, 136 + ]; 137 + 138 + // ─── Helpers ──────────────────────────────────────────────────────────────── 139 + 140 + async function extractAppviewError(res: Response, fallback: string): Promise<string> { 141 + try { 142 + const data = (await res.json()) as { error?: string }; 143 + return data.error ?? fallback; 144 + } catch { 145 + return fallback; 146 + } 147 + } 148 + 149 + /** Drop token values that could break the CSS style block. */ 150 + function sanitizeTokenValue(value: unknown): string | null { 151 + if (typeof value !== "string") return null; 152 + if (value.includes("<") || value.includes(";") || value.includes("</")) return null; 153 + return value; 154 + } 155 + 156 + // ─── Components ───────────────────────────────────────────────────────────── 157 + 158 + function ColorTokenInput({ name, value }: { name: string; value: string }) { 159 + const safeValue = 160 + !value.startsWith("var(") && !value.includes(";") && !value.includes("<") 161 + ? value 162 + : "#cccccc"; 163 + return ( 164 + <div class="token-input token-input--color"> 165 + <label for={`token-${name}`}>{name}</label> 166 + <div class="token-input__controls"> 167 + <input 168 + type="color" 169 + value={safeValue} 170 + aria-label={`${name} color picker`} 171 + oninput="this.nextElementSibling.value=this.value;this.nextElementSibling.dispatchEvent(new Event('change',{bubbles:true}))" 172 + /> 173 + <input 174 + type="text" 175 + id={`token-${name}`} 176 + name={name} 177 + value={safeValue} 178 + oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))this.previousElementSibling.value=this.value" 179 + /> 180 + </div> 181 + </div> 182 + ); 183 + } 184 + 185 + function TextTokenInput({ name, value }: { name: string; value: string }) { 186 + return ( 187 + <div class="token-input"> 188 + <label for={`token-${name}`}>{name}</label> 189 + <input type="text" id={`token-${name}`} name={name} value={value} /> 190 + </div> 191 + ); 192 + } 193 + 194 + function TokenFieldset({ 195 + legend, 196 + tokens, 197 + effectiveTokens, 198 + isColor, 199 + }: { 200 + legend: string; 201 + tokens: readonly string[]; 202 + effectiveTokens: Record<string, string>; 203 + isColor: boolean; 204 + }) { 205 + return ( 206 + <fieldset class="token-group"> 207 + <legend>{legend}</legend> 208 + {tokens.map((name) => 209 + isColor ? ( 210 + <ColorTokenInput name={name} value={effectiveTokens[name] ?? ""} /> 211 + ) : ( 212 + <TextTokenInput name={name} value={effectiveTokens[name] ?? ""} /> 213 + ) 214 + )} 215 + </fieldset> 216 + ); 217 + } 218 + 219 + function ThemePreviewContent({ tokens }: { tokens: Record<string, string> }) { 220 + const css = tokensToCss(tokens); 221 + return ( 222 + <> 223 + <style>{`.preview-pane-inner{${css}}`}</style> 224 + <div class="preview-pane-inner"> 225 + <div 226 + style="background:var(--color-surface);border-bottom:var(--border-width) solid var(--color-border);padding:var(--space-sm) var(--space-md);display:flex;align-items:center;font-family:var(--font-heading);font-weight:var(--font-weight-bold);font-size:var(--font-size-lg);color:var(--color-text);" 227 + role="navigation" 228 + aria-label="Preview navigation" 229 + > 230 + atBB Forum Preview 231 + </div> 232 + <div style="padding:var(--space-md);"> 233 + <div 234 + style="background:var(--color-surface);border:var(--border-width) solid var(--color-border);border-radius:var(--card-radius);box-shadow:var(--card-shadow);padding:var(--space-md);margin-bottom:var(--space-md);" 235 + > 236 + <h2 237 + style="font-family:var(--font-heading);font-size:var(--font-size-xl);font-weight:var(--font-weight-bold);line-height:var(--line-height-heading);color:var(--color-text);margin:0 0 var(--space-sm) 0;" 238 + > 239 + Sample Thread Title 240 + </h2> 241 + <p style="font-family:var(--font-body);font-size:var(--font-size-base);line-height:var(--line-height-body);color:var(--color-text);margin:0 0 var(--space-md) 0;"> 242 + Body text showing font, color, and spacing at work.{" "} 243 + <a href="#" style="color:var(--color-primary);">A sample link</a> 244 + </p> 245 + <pre 246 + style="font-family:var(--font-mono);font-size:var(--font-size-sm);background:var(--color-code-bg);color:var(--color-code-text);padding:var(--space-sm) var(--space-md);border-radius:var(--radius);margin:0 0 var(--space-md) 0;overflow-x:auto;" 247 + > 248 + {`const greeting = "hello forum";`} 249 + </pre> 250 + <input 251 + type="text" 252 + placeholder="Reply…" 253 + style="font-family:var(--font-body);font-size:var(--font-size-base);border:var(--input-border);border-radius:var(--input-radius);padding:var(--space-sm) var(--space-md);width:100%;box-sizing:border-box;background:var(--color-bg);color:var(--color-text);margin-bottom:var(--space-sm);" 254 + /> 255 + <div style="display:flex;gap:var(--space-sm);flex-wrap:wrap;"> 256 + <button 257 + type="button" 258 + style="background:var(--color-primary);color:var(--color-surface);border:var(--border-width) solid var(--color-border);border-radius:var(--button-radius);box-shadow:var(--button-shadow);font-family:var(--font-body);font-weight:var(--font-weight-bold);padding:var(--space-sm) var(--space-md);cursor:pointer;" 259 + > 260 + Post Reply 261 + </button> 262 + <button 263 + type="button" 264 + style="background:var(--color-surface);color:var(--color-text);border:var(--border-width) solid var(--color-border);border-radius:var(--button-radius);box-shadow:var(--button-shadow);font-family:var(--font-body);font-weight:var(--font-weight-bold);padding:var(--space-sm) var(--space-md);cursor:pointer;" 265 + > 266 + Cancel 267 + </button> 268 + <span 269 + style="display:inline-block;background:var(--color-success);color:var(--color-surface);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);" 270 + > 271 + success 272 + </span> 273 + <span 274 + style="display:inline-block;background:var(--color-warning);color:var(--color-text);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);" 275 + > 276 + warning 277 + </span> 278 + <span 279 + style="display:inline-block;background:var(--color-danger);color:var(--color-surface);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);" 280 + > 281 + danger 282 + </span> 283 + </div> 284 + </div> 285 + </div> 286 + </div> 287 + </> 288 + ); 289 + } 290 + 291 + // ─── Route factory ────────────────────────────────────────────────────────── 292 + 293 + export function createAdminThemeRoutes(appviewUrl: string) { 294 + const app = new Hono(); 295 + 296 + // ── GET /admin/themes ────────────────────────────────────────────────────── 297 + // PASTE the GET /admin/themes handler from admin.tsx here (lines 1493–1771) 298 + // Change app.get to app.get (it's already on app in admin.tsx) 299 + // Update all fetch URLs to use appviewUrl parameter instead of the module-level variable 300 + 301 + // ── POST /admin/themes ───────────────────────────────────────────────────── 302 + // PASTE the POST /admin/themes handler from admin.tsx here (lines 1773–1836) 303 + 304 + // ── POST /admin/themes/:rkey/duplicate ───────────────────────────────────── 305 + // PASTE the handler from admin.tsx here (lines 1838–1875) 306 + 307 + // ── POST /admin/themes/:rkey/delete ──────────────────────────────────────── 308 + // PASTE the handler from admin.tsx here (lines 1877–1920) 309 + 310 + // ── POST /admin/theme-policy ─────────────────────────────────────────────── 311 + // PASTE the handler from admin.tsx here (lines 1922–1992) 312 + 313 + return app; 314 + } 315 + ``` 316 + 317 + **Step 2: Fill in the extracted handlers** 318 + 319 + Open `apps/web/src/routes/admin.tsx`. Copy the body of each theme handler (lines 1493–1992) into the corresponding slot in `admin-themes.tsx`. Important differences: 320 + - In `admin.tsx`, the handlers reference the module-level `appviewUrl` variable. In the new factory, use the `appviewUrl` parameter instead. 321 + - The imports already exist in the new file (`logger`, `isProgrammingError`, etc.). 322 + - Remove the `// PASTE...` placeholder comments as you fill each one in. 323 + 324 + **Step 3: Run tests to verify no regressions** 325 + 326 + ```bash 327 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 328 + pnpm --filter @atbb/web test 2>&1 | tail -20 329 + ``` 330 + 331 + Expected: same pass count as baseline. (The admin-themes tests don't exist yet — that's OK.) 332 + 333 + **Step 4: Commit** 334 + 335 + ```bash 336 + git add apps/web/src/routes/admin-themes.tsx 337 + git commit -m "refactor(web): extract theme admin handlers into admin-themes.tsx (ATB-59)" 338 + ``` 339 + 340 + --- 341 + 342 + ## Task 2: Update `admin.tsx` to delegate to `admin-themes.tsx` 343 + 344 + **Files:** 345 + - Modify: `apps/web/src/routes/admin.tsx` 346 + 347 + **Step 1: Add import at the top of admin.tsx** 348 + 349 + After the existing imports, add: 350 + ```typescript 351 + import { createAdminThemeRoutes } from "./admin-themes.js"; 352 + ``` 353 + 354 + Also remove (now unused) imports from admin.tsx: 355 + - `neobrutalLight` and `neobrutalDark` JSON imports (lines 15–16 — only used by THEME_PRESETS) 356 + 357 + **Step 2: Mount the theme routes** 358 + 359 + Just before `return app;` at the end of `createAdminRoutes` (around line 1993), add: 360 + ```typescript 361 + app.route("/", createAdminThemeRoutes(appviewUrl)); 362 + ``` 363 + 364 + **Step 3: Remove the extracted code from admin.tsx** 365 + 366 + Delete lines 1491–1992 (the `// ─── Themes ───` section and all theme handler blocks). Leave `return app;` in place. 367 + 368 + Also delete from admin.tsx: 369 + - `AdminThemeEntry` interface (lines 65–75) 370 + - `ThemePolicy` interface (lines 77–82) 371 + - `THEME_PRESETS` constant and JSON imports (lines 15–16, 84–89) 372 + 373 + **Step 4: Run tests — must still pass** 374 + 375 + ```bash 376 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 377 + pnpm --filter @atbb/web test 2>&1 | tail -20 378 + ``` 379 + 380 + All existing tests must pass. The theme list routes are now handled by admin-themes.tsx but mounted at the same paths. 381 + 382 + **Step 5: Commit** 383 + 384 + ```bash 385 + git add apps/web/src/routes/admin.tsx 386 + git commit -m "refactor(web): mount admin-themes routes, remove extracted code from admin.tsx (ATB-59)" 387 + ``` 388 + 389 + --- 390 + 391 + ## Task 3: Write tests for `GET /admin/themes/:rkey` (TDD) 392 + 393 + **Files:** 394 + - Create: `apps/web/src/routes/__tests__/admin-themes.test.tsx` 395 + 396 + **Step 1: Create the test file with scaffolding** 397 + 398 + ```tsx 399 + import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 400 + 401 + const mockFetch = vi.fn(); 402 + 403 + describe("createAdminThemeRoutes — GET /admin/themes/:rkey", () => { 404 + beforeEach(() => { 405 + vi.stubGlobal("fetch", mockFetch); 406 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 407 + vi.resetModules(); 408 + }); 409 + 410 + afterEach(() => { 411 + vi.unstubAllGlobals(); 412 + vi.unstubAllEnvs(); 413 + mockFetch.mockReset(); 414 + }); 415 + 416 + function mockResponse(body: unknown, ok = true, status = 200) { 417 + return { 418 + ok, 419 + status, 420 + statusText: ok ? "OK" : "Error", 421 + json: () => Promise.resolve(body), 422 + }; 423 + } 424 + 425 + /** Session check: 2 fetches — /api/auth/session, then /api/admin/members/me */ 426 + function setupAuth(permissions: string[]) { 427 + mockFetch.mockResolvedValueOnce( 428 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" }) 429 + ); 430 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 431 + } 432 + 433 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 434 + 435 + const sampleTheme = { 436 + id: "1", 437 + uri: "at://did:plc:forum/space.atbb.forum.theme/abc123", 438 + name: "My Theme", 439 + colorScheme: "light", 440 + tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" }, 441 + cssOverrides: null, 442 + fontUrls: null, 443 + createdAt: "2026-01-01T00:00:00.000Z", 444 + indexedAt: "2026-01-01T00:00:00.000Z", 445 + }; 446 + 447 + async function loadThemeRoutes() { 448 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 449 + return createAdminThemeRoutes("http://localhost:3000"); 450 + } 451 + 452 + it("redirects unauthenticated users to /login", async () => { 453 + // No session cookie — no fetch calls made 454 + const routes = await loadThemeRoutes(); 455 + const res = await routes.request("/admin/themes/abc123"); 456 + expect(res.status).toBe(302); 457 + expect(res.headers.get("location")).toBe("/login"); 458 + }); 459 + 460 + it("returns 403 for users without manageThemes permission", async () => { 461 + setupAuth([]); 462 + const routes = await loadThemeRoutes(); 463 + const res = await routes.request("/admin/themes/abc123", { 464 + headers: { cookie: "atbb_session=token" }, 465 + }); 466 + expect(res.status).toBe(403); 467 + const html = await res.text(); 468 + expect(html).toContain("Access Denied"); 469 + }); 470 + 471 + it("returns 404 when theme not found (AppView returns 404)", async () => { 472 + setupAuth([MANAGE_THEMES]); 473 + mockFetch.mockResolvedValueOnce(mockResponse({ error: "Theme not found" }, false, 404)); 474 + 475 + const routes = await loadThemeRoutes(); 476 + const res = await routes.request("/admin/themes/notexist", { 477 + headers: { cookie: "atbb_session=token" }, 478 + }); 479 + expect(res.status).toBe(404); 480 + }); 481 + 482 + it("renders editor with theme name, colorScheme, and token inputs", async () => { 483 + setupAuth([MANAGE_THEMES]); 484 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 485 + 486 + const routes = await loadThemeRoutes(); 487 + const res = await routes.request("/admin/themes/abc123", { 488 + headers: { cookie: "atbb_session=token" }, 489 + }); 490 + expect(res.status).toBe(200); 491 + const html = await res.text(); 492 + expect(html).toContain("My Theme"); 493 + expect(html).toContain('value="light"'); 494 + expect(html).toContain("#f5f0e8"); // color-bg token 495 + expect(html).toContain("#1a1a1a"); // color-text token 496 + expect(html).toContain('name="color-bg"'); 497 + }); 498 + 499 + it("shows success banner when ?success=1 is present", async () => { 500 + setupAuth([MANAGE_THEMES]); 501 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 502 + 503 + const routes = await loadThemeRoutes(); 504 + const res = await routes.request("/admin/themes/abc123?success=1", { 505 + headers: { cookie: "atbb_session=token" }, 506 + }); 507 + const html = await res.text(); 508 + expect(html).toContain("saved"); // some form of success text 509 + }); 510 + 511 + it("shows error banner when ?error=<msg> is present", async () => { 512 + setupAuth([MANAGE_THEMES]); 513 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 514 + 515 + const routes = await loadThemeRoutes(); 516 + const res = await routes.request("/admin/themes/abc123?error=Something+went+wrong", { 517 + headers: { cookie: "atbb_session=token" }, 518 + }); 519 + const html = await res.text(); 520 + expect(html).toContain("Something went wrong"); 521 + }); 522 + 523 + it("uses preset tokens when ?preset=neobrutal-light is present", async () => { 524 + setupAuth([MANAGE_THEMES]); 525 + // Theme has no tokens; preset should override 526 + const emptyTheme = { ...sampleTheme, tokens: {} }; 527 + mockFetch.mockResolvedValueOnce(mockResponse(emptyTheme)); 528 + 529 + const routes = await loadThemeRoutes(); 530 + const res = await routes.request("/admin/themes/abc123?preset=neobrutal-light", { 531 + headers: { cookie: "atbb_session=token" }, 532 + }); 533 + const html = await res.text(); 534 + // neobrutal-light has color-bg: #f5f0e8 535 + expect(html).toContain("#f5f0e8"); 536 + }); 537 + 538 + it("renders CSS overrides field as disabled (awaiting ATB-62)", async () => { 539 + setupAuth([MANAGE_THEMES]); 540 + mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme)); 541 + 542 + const routes = await loadThemeRoutes(); 543 + const res = await routes.request("/admin/themes/abc123", { 544 + headers: { cookie: "atbb_session=token" }, 545 + }); 546 + const html = await res.text(); 547 + expect(html).toContain("css-overrides"); 548 + expect(html).toContain("disabled"); 549 + }); 550 + }); 551 + ``` 552 + 553 + **Step 2: Run the tests to verify they fail** 554 + 555 + ```bash 556 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 557 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "admin-themes" 558 + ``` 559 + 560 + Expected: All new tests fail with "createAdminThemeRoutes has no route matching GET /admin/themes/:rkey". 561 + 562 + --- 563 + 564 + ## Task 4: Implement `GET /admin/themes/:rkey` 565 + 566 + **Files:** 567 + - Modify: `apps/web/src/routes/admin-themes.tsx` 568 + 569 + **Step 1: Add the route handler inside `createAdminThemeRoutes`** 570 + 571 + Add this after the existing POST /admin/theme-policy handler, before `return app;`: 572 + 573 + ```tsx 574 + // ── GET /admin/themes/:rkey ──────────────────────────────────────────────── 575 + 576 + app.get("/admin/themes/:rkey", async (c) => { 577 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 578 + if (!auth.authenticated) return c.redirect("/login"); 579 + if (!canManageThemes(auth)) { 580 + return c.html( 581 + <BaseLayout title="Access Denied — atBB Forum" auth={auth}> 582 + <PageHeader title="Access Denied" /> 583 + <p>You don&apos;t have permission to manage themes.</p> 584 + </BaseLayout>, 585 + 403 586 + ); 587 + } 588 + 589 + const themeRkey = c.req.param("rkey"); 590 + const cookie = c.req.header("cookie") ?? ""; 591 + const presetParam = c.req.query("preset") ?? null; 592 + const successMsg = c.req.query("success") === "1" ? "Theme saved successfully." : null; 593 + const errorMsg = c.req.query("error") ?? null; 594 + 595 + // Fetch theme from AppView 596 + let theme: AdminThemeEntry | null = null; 597 + try { 598 + const res = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 599 + headers: { Cookie: cookie }, 600 + }); 601 + if (res.status === 404) { 602 + return c.html( 603 + <BaseLayout title="Theme Not Found — atBB Admin" auth={auth}> 604 + <PageHeader title="Theme Not Found" /> 605 + <p>This theme does not exist.</p> 606 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 607 + </BaseLayout>, 608 + 404 609 + ); 610 + } 611 + if (res.ok) { 612 + try { 613 + theme = (await res.json()) as AdminThemeEntry; 614 + } catch { 615 + logger.error("Failed to parse theme response", { 616 + operation: "GET /admin/themes/:rkey", 617 + themeRkey, 618 + }); 619 + } 620 + } else { 621 + logger.error("AppView returned error loading theme", { 622 + operation: "GET /admin/themes/:rkey", 623 + themeRkey, 624 + status: res.status, 625 + }); 626 + } 627 + } catch (error) { 628 + if (isProgrammingError(error)) throw error; 629 + logger.error("Network error loading theme", { 630 + operation: "GET /admin/themes/:rkey", 631 + themeRkey, 632 + error: error instanceof Error ? error.message : String(error), 633 + }); 634 + } 635 + 636 + if (!theme) { 637 + return c.html( 638 + <BaseLayout title="Theme Unavailable — atBB Admin" auth={auth}> 639 + <PageHeader title="Theme Unavailable" /> 640 + <p>Unable to load theme data. Please try again.</p> 641 + <a href="/admin/themes" class="btn btn-secondary">← Back to themes</a> 642 + </BaseLayout>, 643 + 500 644 + ); 645 + } 646 + 647 + // If ?preset is set, override DB tokens with preset tokens 648 + const presetTokens = presetParam ? (THEME_PRESETS[presetParam] ?? null) : null; 649 + const effectiveTokens: Record<string, string> = presetTokens 650 + ? { ...theme.tokens, ...presetTokens } 651 + : { ...theme.tokens }; 652 + 653 + const fontUrlsText = (theme.fontUrls ?? []).join("\n"); 654 + 655 + return c.html( 656 + <BaseLayout title={`Edit Theme: ${theme.name} — atBB Admin`} auth={auth}> 657 + <PageHeader title={`Edit Theme: ${theme.name}`} /> 658 + 659 + {successMsg && <div class="structure-success-banner">{successMsg}</div>} 660 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 661 + 662 + <a href="/admin/themes" class="btn btn-secondary btn-sm" style="margin-bottom: var(--space-md); display: inline-block;"> 663 + ← Back to themes 664 + </a> 665 + 666 + {/* Metadata + tokens form */} 667 + <form 668 + id="editor-form" 669 + method="post" 670 + action={`/admin/themes/${themeRkey}/save`} 671 + class="theme-editor" 672 + > 673 + {/* Metadata */} 674 + <fieldset class="token-group"> 675 + <legend>Theme Metadata</legend> 676 + <div class="token-input"> 677 + <label for="theme-name">Name</label> 678 + <input type="text" id="theme-name" name="name" value={theme.name} required /> 679 + </div> 680 + <div class="token-input"> 681 + <label for="theme-scheme">Color Scheme</label> 682 + <select id="theme-scheme" name="colorScheme"> 683 + <option value="light" selected={theme.colorScheme === "light"}>Light</option> 684 + <option value="dark" selected={theme.colorScheme === "dark"}>Dark</option> 685 + </select> 686 + </div> 687 + <div class="token-input"> 688 + <label for="theme-font-urls">Font URLs (one per line)</label> 689 + <textarea id="theme-font-urls" name="fontUrls" rows={3} placeholder="https://fonts.googleapis.com/css2?family=..."> 690 + {fontUrlsText} 691 + </textarea> 692 + </div> 693 + </fieldset> 694 + 695 + {/* Token editor + live preview layout */} 696 + <div class="theme-editor__layout"> 697 + {/* Left: token controls */} 698 + <div 699 + class="theme-editor__controls" 700 + hx-post={`/admin/themes/${themeRkey}/preview`} 701 + hx-trigger="input delay:500ms" 702 + hx-target="#preview-pane" 703 + hx-include="#editor-form" 704 + > 705 + <TokenFieldset 706 + legend="Colors" 707 + tokens={COLOR_TOKENS} 708 + effectiveTokens={effectiveTokens} 709 + isColor={true} 710 + /> 711 + <TokenFieldset 712 + legend="Typography" 713 + tokens={TYPOGRAPHY_TOKENS} 714 + effectiveTokens={effectiveTokens} 715 + isColor={false} 716 + /> 717 + <TokenFieldset 718 + legend="Spacing & Layout" 719 + tokens={SPACING_TOKENS} 720 + effectiveTokens={effectiveTokens} 721 + isColor={false} 722 + /> 723 + <TokenFieldset 724 + legend="Components" 725 + tokens={COMPONENT_TOKENS} 726 + effectiveTokens={effectiveTokens} 727 + isColor={false} 728 + /> 729 + 730 + {/* CSS overrides — disabled until ATB-62 */} 731 + <fieldset class="token-group"> 732 + <legend>CSS Overrides</legend> 733 + <div class="token-input"> 734 + <label for="css-overrides"> 735 + Custom CSS{" "} 736 + <span class="form-hint">(disabled — CSS sanitization not yet implemented)</span> 737 + </label> 738 + <textarea 739 + id="css-overrides" 740 + name="cssOverrides" 741 + rows={6} 742 + disabled 743 + aria-describedby="css-overrides-hint" 744 + placeholder="/* Will be enabled in ATB-62 */" 745 + > 746 + {theme.cssOverrides ?? ""} 747 + </textarea> 748 + <p id="css-overrides-hint" class="form-hint"> 749 + Raw CSS overrides will be available after CSS sanitization is implemented (ATB-62). 750 + </p> 751 + </div> 752 + </fieldset> 753 + </div> 754 + 755 + {/* Right: live preview */} 756 + <div class="theme-editor__preview"> 757 + <h3>Live Preview</h3> 758 + <div id="preview-pane" class="preview-pane"> 759 + <ThemePreviewContent tokens={effectiveTokens} /> 760 + </div> 761 + </div> 762 + </div> 763 + 764 + {/* Actions */} 765 + <div class="theme-editor__actions"> 766 + <button type="submit" class="btn btn-primary">Save Theme</button> 767 + 768 + <button 769 + type="button" 770 + class="btn btn-secondary" 771 + onclick="document.getElementById('reset-dialog').showModal()" 772 + > 773 + Reset to Preset 774 + </button> 775 + </div> 776 + </form> 777 + 778 + {/* Reset to preset dialog */} 779 + <dialog id="reset-dialog" class="structure-confirm-dialog"> 780 + <form method="post" action={`/admin/themes/${themeRkey}/reset-to-preset`}> 781 + <p>Reset all token values to a built-in preset? Your unsaved changes will be lost.</p> 782 + <div class="form-group"> 783 + <label for="reset-preset-select">Reset to preset:</label> 784 + <select id="reset-preset-select" name="preset"> 785 + <option value="neobrutal-light">Neobrutal Light</option> 786 + <option value="neobrutal-dark">Neobrutal Dark</option> 787 + <option value="blank">Blank (empty tokens)</option> 788 + </select> 789 + </div> 790 + <div class="dialog-actions"> 791 + <button type="submit" class="btn btn-danger">Reset</button> 792 + <button 793 + type="button" 794 + class="btn btn-secondary" 795 + onclick="document.getElementById('reset-dialog').close()" 796 + > 797 + Cancel 798 + </button> 799 + </div> 800 + </form> 801 + </dialog> 802 + </BaseLayout> 803 + ); 804 + }); 805 + ``` 806 + 807 + **Step 2: Run the tests** 808 + 809 + ```bash 810 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 811 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "admin-themes" 812 + ``` 813 + 814 + Expected: All 8 GET /admin/themes/:rkey tests pass. 815 + 816 + **Step 3: Also fix the Edit button in the theme list** 817 + 818 + In `admin-themes.tsx`, find the `GET /admin/themes` list handler. Find this line: 819 + ```tsx 820 + <span class="btn btn-secondary btn-sm" aria-disabled="true"> 821 + Edit 822 + </span> 823 + ``` 824 + 825 + Replace with: 826 + ```tsx 827 + <a href={`/admin/themes/${themeRkey}`} class="btn btn-secondary btn-sm"> 828 + Edit 829 + </a> 830 + ``` 831 + 832 + **Step 4: Run tests again** 833 + 834 + ```bash 835 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 836 + pnpm --filter @atbb/web test 2>&1 | tail -20 837 + ``` 838 + 839 + All tests must pass. 840 + 841 + **Step 5: Commit** 842 + 843 + ```bash 844 + git add apps/web/src/routes/admin-themes.tsx 845 + git commit -m "feat(web): GET /admin/themes/:rkey token editor page + fix Edit button (ATB-59)" 846 + ``` 847 + 848 + --- 849 + 850 + ## Task 5: Write tests for `POST /admin/themes/:rkey/preview` 851 + 852 + **Files:** 853 + - Modify: `apps/web/src/routes/__tests__/admin-themes.test.tsx` 854 + 855 + **Step 1: Add a new describe block** 856 + 857 + ```tsx 858 + describe("createAdminThemeRoutes — POST /admin/themes/:rkey/preview", () => { 859 + beforeEach(() => { 860 + vi.stubGlobal("fetch", mockFetch); 861 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 862 + vi.resetModules(); 863 + }); 864 + 865 + afterEach(() => { 866 + vi.unstubAllGlobals(); 867 + vi.unstubAllEnvs(); 868 + mockFetch.mockReset(); 869 + }); 870 + 871 + function mockResponse(body: unknown, ok = true, status = 200) { 872 + return { 873 + ok, status, 874 + statusText: ok ? "OK" : "Error", 875 + json: () => Promise.resolve(body), 876 + }; 877 + } 878 + 879 + function setupAuth(permissions: string[]) { 880 + mockFetch.mockResolvedValueOnce( 881 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" }) 882 + ); 883 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 884 + } 885 + 886 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 887 + 888 + async function loadThemeRoutes() { 889 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 890 + return createAdminThemeRoutes("http://localhost:3000"); 891 + } 892 + 893 + it("redirects unauthenticated users to /login", async () => { 894 + const routes = await loadThemeRoutes(); 895 + const body = new URLSearchParams({ "color-bg": "#ff0000" }); 896 + const res = await routes.request("/admin/themes/abc123/preview", { 897 + method: "POST", 898 + headers: { "content-type": "application/x-www-form-urlencoded" }, 899 + body: body.toString(), 900 + }); 901 + expect(res.status).toBe(302); 902 + expect(res.headers.get("location")).toBe("/login"); 903 + }); 904 + 905 + it("returns an HTML fragment with a scoped <style> block containing submitted token values", async () => { 906 + setupAuth([MANAGE_THEMES]); 907 + 908 + const routes = await loadThemeRoutes(); 909 + const body = new URLSearchParams({ 910 + "color-bg": "#ff0000", 911 + "color-text": "#0000ff", 912 + }); 913 + const res = await routes.request("/admin/themes/abc123/preview", { 914 + method: "POST", 915 + headers: { 916 + "content-type": "application/x-www-form-urlencoded", 917 + cookie: "atbb_session=token", 918 + }, 919 + body: body.toString(), 920 + }); 921 + 922 + expect(res.status).toBe(200); 923 + const html = await res.text(); 924 + expect(html).toContain("--color-bg"); 925 + expect(html).toContain("#ff0000"); 926 + expect(html).toContain("--color-text"); 927 + expect(html).toContain("#0000ff"); 928 + expect(html).toContain(".preview-pane-inner"); 929 + // Should NOT have full page HTML — this is a fragment 930 + expect(html).not.toContain("<html"); 931 + expect(html).not.toContain("<BaseLayout"); 932 + }); 933 + 934 + it("drops token values containing '<' (sanitization)", async () => { 935 + setupAuth([MANAGE_THEMES]); 936 + 937 + const routes = await loadThemeRoutes(); 938 + const body = new URLSearchParams({ 939 + "color-bg": "<script>alert(1)</script>", 940 + "color-text": "#1a1a1a", 941 + }); 942 + const res = await routes.request("/admin/themes/abc123/preview", { 943 + method: "POST", 944 + headers: { 945 + "content-type": "application/x-www-form-urlencoded", 946 + cookie: "atbb_session=token", 947 + }, 948 + body: body.toString(), 949 + }); 950 + 951 + expect(res.status).toBe(200); 952 + const html = await res.text(); 953 + // The malicious value must not appear 954 + expect(html).not.toContain("<script>"); 955 + expect(html).not.toContain("alert(1)"); 956 + }); 957 + 958 + it("drops token values containing ';' (sanitization)", async () => { 959 + setupAuth([MANAGE_THEMES]); 960 + 961 + const routes = await loadThemeRoutes(); 962 + const body = new URLSearchParams({ 963 + "color-bg": "red; --injected: 1", 964 + }); 965 + const res = await routes.request("/admin/themes/abc123/preview", { 966 + method: "POST", 967 + headers: { 968 + "content-type": "application/x-www-form-urlencoded", 969 + cookie: "atbb_session=token", 970 + }, 971 + body: body.toString(), 972 + }); 973 + 974 + const html = await res.text(); 975 + expect(html).not.toContain("--injected"); 976 + }); 977 + }); 978 + ``` 979 + 980 + **Step 2: Run tests to verify they fail** 981 + 982 + ```bash 983 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 984 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -E "preview|FAIL|PASS" | head -20 985 + ``` 986 + 987 + Expected: New preview tests fail. 988 + 989 + --- 990 + 991 + ## Task 6: Implement `POST /admin/themes/:rkey/preview` 992 + 993 + **Files:** 994 + - Modify: `apps/web/src/routes/admin-themes.tsx` 995 + 996 + **Step 1: Add the route handler inside `createAdminThemeRoutes`, after the GET handler** 997 + 998 + ```tsx 999 + // ── POST /admin/themes/:rkey/preview ───────────────────────────────────── 1000 + 1001 + app.post("/admin/themes/:rkey/preview", async (c) => { 1002 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1003 + if (!auth.authenticated) return c.redirect("/login"); 1004 + 1005 + let rawBody: Record<string, string | File>; 1006 + try { 1007 + rawBody = await c.req.parseBody(); 1008 + } catch (error) { 1009 + if (isProgrammingError(error)) throw error; 1010 + // Return empty preview on parse error — don't break the HTMX swap 1011 + return c.html(<ThemePreviewContent tokens={{}} />); 1012 + } 1013 + 1014 + // Build token map from only known token names (ignore unknown fields like name/colorScheme) 1015 + const tokens: Record<string, string> = {}; 1016 + for (const tokenName of ALL_KNOWN_TOKENS) { 1017 + const raw = rawBody[tokenName]; 1018 + if (typeof raw !== "string") continue; 1019 + const safe = sanitizeTokenValue(raw); 1020 + if (safe !== null) { 1021 + tokens[tokenName] = safe; 1022 + } 1023 + } 1024 + 1025 + return c.html(<ThemePreviewContent tokens={tokens} />); 1026 + }); 1027 + ``` 1028 + 1029 + **Step 2: Run the tests** 1030 + 1031 + ```bash 1032 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1033 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "preview" 1034 + ``` 1035 + 1036 + Expected: All preview tests pass. 1037 + 1038 + **Step 3: Commit** 1039 + 1040 + ```bash 1041 + git add apps/web/src/routes/admin-themes.tsx 1042 + git commit -m "feat(web): POST /admin/themes/:rkey/preview — HTMX live preview endpoint (ATB-59)" 1043 + ``` 1044 + 1045 + --- 1046 + 1047 + ## Task 7: Write tests for `POST /admin/themes/:rkey/save` 1048 + 1049 + **Files:** 1050 + - Modify: `apps/web/src/routes/__tests__/admin-themes.test.tsx` 1051 + 1052 + **Step 1: Add describe block** 1053 + 1054 + ```tsx 1055 + describe("createAdminThemeRoutes — POST /admin/themes/:rkey/save", () => { 1056 + beforeEach(() => { 1057 + vi.stubGlobal("fetch", mockFetch); 1058 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1059 + vi.resetModules(); 1060 + }); 1061 + 1062 + afterEach(() => { 1063 + vi.unstubAllGlobals(); 1064 + vi.unstubAllEnvs(); 1065 + mockFetch.mockReset(); 1066 + }); 1067 + 1068 + function mockResponse(body: unknown, ok = true, status = 200) { 1069 + return { 1070 + ok, status, 1071 + statusText: ok ? "OK" : "Error", 1072 + json: () => Promise.resolve(body), 1073 + }; 1074 + } 1075 + 1076 + function setupAuth(permissions: string[]) { 1077 + mockFetch.mockResolvedValueOnce( 1078 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" }) 1079 + ); 1080 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1081 + } 1082 + 1083 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 1084 + 1085 + async function loadThemeRoutes() { 1086 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 1087 + return createAdminThemeRoutes("http://localhost:3000"); 1088 + } 1089 + 1090 + function makeFormBody(overrides: Record<string, string> = {}): string { 1091 + return new URLSearchParams({ 1092 + name: "My Theme", 1093 + colorScheme: "light", 1094 + fontUrls: "", 1095 + "color-bg": "#f5f0e8", 1096 + ...overrides, 1097 + }).toString(); 1098 + } 1099 + 1100 + it("redirects to ?success=1 on AppView 200", async () => { 1101 + setupAuth([MANAGE_THEMES]); 1102 + mockFetch.mockResolvedValueOnce(mockResponse({ id: "1", name: "My Theme" })); 1103 + 1104 + const routes = await loadThemeRoutes(); 1105 + const res = await routes.request("/admin/themes/abc123/save", { 1106 + method: "POST", 1107 + headers: { 1108 + "content-type": "application/x-www-form-urlencoded", 1109 + cookie: "atbb_session=token", 1110 + }, 1111 + body: makeFormBody(), 1112 + }); 1113 + 1114 + expect(res.status).toBe(302); 1115 + expect(res.headers.get("location")).toContain("/admin/themes/abc123"); 1116 + expect(res.headers.get("location")).toContain("success=1"); 1117 + }); 1118 + 1119 + it("redirects with ?error=<msg> when AppView returns 400", async () => { 1120 + setupAuth([MANAGE_THEMES]); 1121 + mockFetch.mockResolvedValueOnce( 1122 + mockResponse({ error: "Name is required" }, false, 400) 1123 + ); 1124 + 1125 + const routes = await loadThemeRoutes(); 1126 + const res = await routes.request("/admin/themes/abc123/save", { 1127 + method: "POST", 1128 + headers: { 1129 + "content-type": "application/x-www-form-urlencoded", 1130 + cookie: "atbb_session=token", 1131 + }, 1132 + body: makeFormBody({ name: "" }), 1133 + }); 1134 + 1135 + expect(res.status).toBe(302); 1136 + const location = res.headers.get("location") ?? ""; 1137 + expect(location).toContain("error="); 1138 + expect(decodeURIComponent(location)).toContain("Name is required"); 1139 + }); 1140 + 1141 + it("redirects with generic error on network failure", async () => { 1142 + setupAuth([MANAGE_THEMES]); 1143 + mockFetch.mockRejectedValueOnce(new Error("fetch failed")); 1144 + 1145 + const routes = await loadThemeRoutes(); 1146 + const res = await routes.request("/admin/themes/abc123/save", { 1147 + method: "POST", 1148 + headers: { 1149 + "content-type": "application/x-www-form-urlencoded", 1150 + cookie: "atbb_session=token", 1151 + }, 1152 + body: makeFormBody(), 1153 + }); 1154 + 1155 + expect(res.status).toBe(302); 1156 + const location = res.headers.get("location") ?? ""; 1157 + expect(location).toContain("error="); 1158 + expect(decodeURIComponent(location).toLowerCase()).toContain("unavailable"); 1159 + }); 1160 + 1161 + it("redirects unauthenticated users to /login", async () => { 1162 + const routes = await loadThemeRoutes(); 1163 + const res = await routes.request("/admin/themes/abc123/save", { 1164 + method: "POST", 1165 + headers: { "content-type": "application/x-www-form-urlencoded" }, 1166 + body: makeFormBody(), 1167 + }); 1168 + expect(res.status).toBe(302); 1169 + expect(res.headers.get("location")).toBe("/login"); 1170 + }); 1171 + }); 1172 + ``` 1173 + 1174 + **Step 2: Verify tests fail** 1175 + 1176 + ```bash 1177 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1178 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "save" 1179 + ``` 1180 + 1181 + Expected: New save tests fail. 1182 + 1183 + --- 1184 + 1185 + ## Task 8: Implement `POST /admin/themes/:rkey/save` 1186 + 1187 + **Files:** 1188 + - Modify: `apps/web/src/routes/admin-themes.tsx` 1189 + 1190 + **Step 1: Add the route handler after the preview handler** 1191 + 1192 + ```tsx 1193 + // ── POST /admin/themes/:rkey/save ───────────────────────────────────────── 1194 + 1195 + app.post("/admin/themes/:rkey/save", async (c) => { 1196 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1197 + if (!auth.authenticated) return c.redirect("/login"); 1198 + if (!canManageThemes(auth)) { 1199 + return c.html( 1200 + <BaseLayout title="Access Denied" auth={auth}> 1201 + <p>Access denied.</p> 1202 + </BaseLayout>, 1203 + 403 1204 + ); 1205 + } 1206 + 1207 + const themeRkey = c.req.param("rkey"); 1208 + const cookie = c.req.header("cookie") ?? ""; 1209 + 1210 + let rawBody: Record<string, string | File>; 1211 + try { 1212 + rawBody = await c.req.parseBody(); 1213 + } catch (error) { 1214 + if (isProgrammingError(error)) throw error; 1215 + return c.redirect( 1216 + `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid form submission.")}`, 1217 + 302 1218 + ); 1219 + } 1220 + 1221 + const name = typeof rawBody.name === "string" ? rawBody.name.trim() : ""; 1222 + const colorScheme = typeof rawBody.colorScheme === "string" ? rawBody.colorScheme : "light"; 1223 + const fontUrlsRaw = typeof rawBody.fontUrls === "string" ? rawBody.fontUrls : ""; 1224 + const fontUrls = fontUrlsRaw 1225 + .split("\n") 1226 + .map((u) => u.trim()) 1227 + .filter(Boolean); 1228 + 1229 + // Extract token values from form fields 1230 + const tokens: Record<string, string> = {}; 1231 + for (const tokenName of ALL_KNOWN_TOKENS) { 1232 + const raw = rawBody[tokenName]; 1233 + if (typeof raw === "string" && raw.trim()) { 1234 + tokens[tokenName] = raw.trim(); 1235 + } 1236 + } 1237 + 1238 + let apiRes: Response; 1239 + try { 1240 + apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, { 1241 + method: "PUT", 1242 + headers: { "Content-Type": "application/json", Cookie: cookie }, 1243 + body: JSON.stringify({ name, colorScheme, tokens, fontUrls }), 1244 + }); 1245 + } catch (error) { 1246 + if (isProgrammingError(error)) throw error; 1247 + logger.error("Network error saving theme", { 1248 + operation: "POST /admin/themes/:rkey/save", 1249 + themeRkey, 1250 + error: error instanceof Error ? error.message : String(error), 1251 + }); 1252 + return c.redirect( 1253 + `/admin/themes/${themeRkey}?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`, 1254 + 302 1255 + ); 1256 + } 1257 + 1258 + if (!apiRes.ok) { 1259 + const msg = await extractAppviewError(apiRes, "Failed to save theme. Please try again."); 1260 + return c.redirect( 1261 + `/admin/themes/${themeRkey}?error=${encodeURIComponent(msg)}`, 1262 + 302 1263 + ); 1264 + } 1265 + 1266 + return c.redirect(`/admin/themes/${themeRkey}?success=1`, 302); 1267 + }); 1268 + ``` 1269 + 1270 + **Step 2: Run the tests** 1271 + 1272 + ```bash 1273 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1274 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "save" 1275 + ``` 1276 + 1277 + Expected: All save tests pass. 1278 + 1279 + **Step 3: Commit** 1280 + 1281 + ```bash 1282 + git add apps/web/src/routes/admin-themes.tsx 1283 + git commit -m "feat(web): POST /admin/themes/:rkey/save — persist token edits to AppView (ATB-59)" 1284 + ``` 1285 + 1286 + --- 1287 + 1288 + ## Task 9: Write tests for `POST /admin/themes/:rkey/reset-to-preset` 1289 + 1290 + **Files:** 1291 + - Modify: `apps/web/src/routes/__tests__/admin-themes.test.tsx` 1292 + 1293 + **Step 1: Add describe block** 1294 + 1295 + ```tsx 1296 + describe("createAdminThemeRoutes — POST /admin/themes/:rkey/reset-to-preset", () => { 1297 + beforeEach(() => { 1298 + vi.stubGlobal("fetch", mockFetch); 1299 + vi.stubEnv("APPVIEW_URL", "http://localhost:3000"); 1300 + vi.resetModules(); 1301 + }); 1302 + 1303 + afterEach(() => { 1304 + vi.unstubAllGlobals(); 1305 + vi.unstubAllEnvs(); 1306 + mockFetch.mockReset(); 1307 + }); 1308 + 1309 + function mockResponse(body: unknown, ok = true, status = 200) { 1310 + return { 1311 + ok, status, 1312 + statusText: ok ? "OK" : "Error", 1313 + json: () => Promise.resolve(body), 1314 + }; 1315 + } 1316 + 1317 + function setupAuth(permissions: string[]) { 1318 + mockFetch.mockResolvedValueOnce( 1319 + mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" }) 1320 + ); 1321 + mockFetch.mockResolvedValueOnce(mockResponse({ permissions })); 1322 + } 1323 + 1324 + const MANAGE_THEMES = "space.atbb.permission.manageThemes"; 1325 + 1326 + async function loadThemeRoutes() { 1327 + const { createAdminThemeRoutes } = await import("../admin-themes.js"); 1328 + return createAdminThemeRoutes("http://localhost:3000"); 1329 + } 1330 + 1331 + it("redirects to ?preset=neobrutal-light for valid preset", async () => { 1332 + setupAuth([MANAGE_THEMES]); 1333 + 1334 + const routes = await loadThemeRoutes(); 1335 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 1336 + method: "POST", 1337 + headers: { 1338 + "content-type": "application/x-www-form-urlencoded", 1339 + cookie: "atbb_session=token", 1340 + }, 1341 + body: new URLSearchParams({ preset: "neobrutal-light" }).toString(), 1342 + }); 1343 + 1344 + expect(res.status).toBe(302); 1345 + expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=neobrutal-light"); 1346 + }); 1347 + 1348 + it("redirects to ?preset=neobrutal-dark for dark preset", async () => { 1349 + setupAuth([MANAGE_THEMES]); 1350 + 1351 + const routes = await loadThemeRoutes(); 1352 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 1353 + method: "POST", 1354 + headers: { 1355 + "content-type": "application/x-www-form-urlencoded", 1356 + cookie: "atbb_session=token", 1357 + }, 1358 + body: new URLSearchParams({ preset: "neobrutal-dark" }).toString(), 1359 + }); 1360 + 1361 + expect(res.status).toBe(302); 1362 + expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=neobrutal-dark"); 1363 + }); 1364 + 1365 + it("redirects to ?preset=blank for blank preset", async () => { 1366 + setupAuth([MANAGE_THEMES]); 1367 + 1368 + const routes = await loadThemeRoutes(); 1369 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 1370 + method: "POST", 1371 + headers: { 1372 + "content-type": "application/x-www-form-urlencoded", 1373 + cookie: "atbb_session=token", 1374 + }, 1375 + body: new URLSearchParams({ preset: "blank" }).toString(), 1376 + }); 1377 + 1378 + expect(res.status).toBe(302); 1379 + expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=blank"); 1380 + }); 1381 + 1382 + it("returns 400 for unknown preset name", async () => { 1383 + setupAuth([MANAGE_THEMES]); 1384 + 1385 + const routes = await loadThemeRoutes(); 1386 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 1387 + method: "POST", 1388 + headers: { 1389 + "content-type": "application/x-www-form-urlencoded", 1390 + cookie: "atbb_session=token", 1391 + }, 1392 + body: new URLSearchParams({ preset: "hacked" }).toString(), 1393 + }); 1394 + 1395 + expect(res.status).toBe(400); 1396 + }); 1397 + 1398 + it("redirects unauthenticated users to /login", async () => { 1399 + const routes = await loadThemeRoutes(); 1400 + const res = await routes.request("/admin/themes/abc123/reset-to-preset", { 1401 + method: "POST", 1402 + headers: { "content-type": "application/x-www-form-urlencoded" }, 1403 + body: new URLSearchParams({ preset: "neobrutal-light" }).toString(), 1404 + }); 1405 + expect(res.status).toBe(302); 1406 + expect(res.headers.get("location")).toBe("/login"); 1407 + }); 1408 + }); 1409 + ``` 1410 + 1411 + **Step 2: Verify tests fail** 1412 + 1413 + ```bash 1414 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1415 + pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "reset" 1416 + ``` 1417 + 1418 + --- 1419 + 1420 + ## Task 10: Implement `POST /admin/themes/:rkey/reset-to-preset` 1421 + 1422 + **Files:** 1423 + - Modify: `apps/web/src/routes/admin-themes.tsx` 1424 + 1425 + **Step 1: Add the route handler** 1426 + 1427 + ```tsx 1428 + // ── POST /admin/themes/:rkey/reset-to-preset ────────────────────────────── 1429 + 1430 + app.post("/admin/themes/:rkey/reset-to-preset", async (c) => { 1431 + const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie")); 1432 + if (!auth.authenticated) return c.redirect("/login"); 1433 + if (!canManageThemes(auth)) { 1434 + return c.html( 1435 + <BaseLayout title="Access Denied" auth={auth}> 1436 + <p>Access denied.</p> 1437 + </BaseLayout>, 1438 + 403 1439 + ); 1440 + } 1441 + 1442 + const themeRkey = c.req.param("rkey"); 1443 + 1444 + let body: Record<string, string | File>; 1445 + try { 1446 + body = await c.req.parseBody(); 1447 + } catch (error) { 1448 + if (isProgrammingError(error)) throw error; 1449 + return c.json({ error: "Invalid form submission." }, 400); 1450 + } 1451 + 1452 + const preset = typeof body.preset === "string" ? body.preset : ""; 1453 + if (!(preset in THEME_PRESETS)) { 1454 + return c.json({ error: `Unknown preset: ${preset}` }, 400); 1455 + } 1456 + 1457 + return c.redirect(`/admin/themes/${themeRkey}?preset=${encodeURIComponent(preset)}`, 302); 1458 + }); 1459 + ``` 1460 + 1461 + **Step 2: Run all tests** 1462 + 1463 + ```bash 1464 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1465 + pnpm --filter @atbb/web test 2>&1 | tail -20 1466 + ``` 1467 + 1468 + Expected: All tests pass. 1469 + 1470 + **Step 3: Commit** 1471 + 1472 + ```bash 1473 + git add apps/web/src/routes/admin-themes.tsx apps/web/src/routes/__tests__/admin-themes.test.tsx 1474 + git commit -m "feat(web): POST /admin/themes/:rkey/reset-to-preset + all tests (ATB-59)" 1475 + ``` 1476 + 1477 + --- 1478 + 1479 + ## Task 11: Full test suite + lint + Linear update 1480 + 1481 + **Step 1: Run the full test suite** 1482 + 1483 + ```bash 1484 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1485 + pnpm test 2>&1 | tail -30 1486 + ``` 1487 + 1488 + Expected: All packages pass. 1489 + 1490 + **Step 2: Run lint fix** 1491 + 1492 + ```bash 1493 + export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1494 + pnpm --filter @atbb/web lint:fix 1495 + ``` 1496 + 1497 + Fix any lint errors that appear. 1498 + 1499 + **Step 3: If any lint fixes were needed, commit them** 1500 + 1501 + ```bash 1502 + git add -A 1503 + git commit -m "style(web): lint fixes for ATB-59 theme editor" 1504 + ``` 1505 + 1506 + **Step 4: Update Linear issue ATB-59** 1507 + - Change status to **In Progress** → **In Review** (or Done after review) 1508 + - Add a comment listing what was implemented 1509 + 1510 + --- 1511 + 1512 + ## What We Did NOT Implement (per spec) 1513 + 1514 + - `cssOverrides` editor — disabled, awaiting ATB-62 (CSS sanitization) 1515 + - Font URL validation — currently stored as-is; proper HTTPS URL validation can be added in ATB-62 1516 + - Import/export JSON — listed in theming-plan.md Phase 3/5 but not in ATB-59 scope 1517 + - User theme picker — ATB-60 scope 1518 + 1519 + --- 1520 + 1521 + ## Known Token JSON Import Note 1522 + 1523 + If TypeScript complains about `import ... assert { type: "json" }`, check how it was done in the existing `admin.tsx`: 1524 + 1525 + ```typescript 1526 + // admin.tsx already uses: 1527 + import neobrutalLight from "../styles/presets/neobrutal-light.json"; 1528 + import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 1529 + ``` 1530 + 1531 + Use the same syntax (without `assert { type: "json" }`) if that's what the project's tsconfig supports.