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
at main 1531 lines 53 kB view raw view rendered
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 18export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 19pnpm --filter @atbb/web test 2>&1 | tail -20 20``` 21 22Expected: 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``` 34COLOR: 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 37TYPOGRAPHY: 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 40SPACING: space-xs, space-sm, space-md, space-lg, space-xl, 41 radius, border-width, shadow-offset, content-width 42COMPONENTS: 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 53This task moves existing code. No new functionality yet. The file will: 541. Re-define the `extractAppviewError` helper (it stays in `admin.tsx` too, for structure routes) 552. Re-define `AdminThemeEntry`, `ThemePolicy` types (only used by theme handlers) 563. Move `THEME_PRESETS` constant and the two JSON imports 574. 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 64import { Hono } from "hono"; 65import { BaseLayout } from "../layouts/base.js"; 66import { PageHeader, EmptyState } from "../components/index.js"; 67import { 68 getSessionWithPermissions, 69 canManageThemes, 70} from "../lib/session.js"; 71import { isProgrammingError } from "../lib/errors.js"; 72import { logger } from "../lib/logger.js"; 73import { tokensToCss } from "../lib/theme.js"; 74import neobrutalLight from "../styles/presets/neobrutal-light.json" assert { type: "json" }; 75import neobrutalDark from "../styles/presets/neobrutal-dark.json" assert { type: "json" }; 76 77// ─── Types ───────────────────────────────────────────────────────────────── 78 79interface 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 91interface ThemePolicy { 92 defaultLightThemeUri: string | null; 93 defaultDarkThemeUri: string | null; 94 allowUserChoice: boolean; 95 availableThemes: Array<{ uri: string; cid: string }>; 96} 97 98// ─── Constants ────────────────────────────────────────────────────────────── 99 100const 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 106const 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 113const 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 121const 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 126const 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 134const ALL_KNOWN_TOKENS: readonly string[] = [ 135 ...COLOR_TOKENS, ...TYPOGRAPHY_TOKENS, ...SPACING_TOKENS, ...COMPONENT_TOKENS, 136]; 137 138// ─── Helpers ──────────────────────────────────────────────────────────────── 139 140async 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. */ 150function 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 158function 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 185function 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 194function 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 219function 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 293export 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 319Open `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 327export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 328pnpm --filter @atbb/web test 2>&1 | tail -20 329``` 330 331Expected: same pass count as baseline. (The admin-themes tests don't exist yet — that's OK.) 332 333**Step 4: Commit** 334 335```bash 336git add apps/web/src/routes/admin-themes.tsx 337git 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 349After the existing imports, add: 350```typescript 351import { createAdminThemeRoutes } from "./admin-themes.js"; 352``` 353 354Also 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 359Just 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 366Delete lines 1491–1992 (the `// ─── Themes ───` section and all theme handler blocks). Leave `return app;` in place. 367 368Also 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 376export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 377pnpm --filter @atbb/web test 2>&1 | tail -20 378``` 379 380All 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 385git add apps/web/src/routes/admin.tsx 386git 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 399import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; 400 401const mockFetch = vi.fn(); 402 403describe("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 556export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 557pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "admin-themes" 558``` 559 560Expected: 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 571Add 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 810export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 811pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "admin-themes" 812``` 813 814Expected: All 8 GET /admin/themes/:rkey tests pass. 815 816**Step 3: Also fix the Edit button in the theme list** 817 818In `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 825Replace 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 835export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 836pnpm --filter @atbb/web test 2>&1 | tail -20 837``` 838 839All tests must pass. 840 841**Step 5: Commit** 842 843```bash 844git add apps/web/src/routes/admin-themes.tsx 845git 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 858describe("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 983export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 984pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -E "preview|FAIL|PASS" | head -20 985``` 986 987Expected: 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 1032export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1033pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "preview" 1034``` 1035 1036Expected: All preview tests pass. 1037 1038**Step 3: Commit** 1039 1040```bash 1041git add apps/web/src/routes/admin-themes.tsx 1042git 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 1055describe("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 1177export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1178pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "save" 1179``` 1180 1181Expected: 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 1273export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1274pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "save" 1275``` 1276 1277Expected: All save tests pass. 1278 1279**Step 3: Commit** 1280 1281```bash 1282git add apps/web/src/routes/admin-themes.tsx 1283git 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 1296describe("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 1414export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1415pnpm --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 1464export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1465pnpm --filter @atbb/web test 2>&1 | tail -20 1466``` 1467 1468Expected: All tests pass. 1469 1470**Step 3: Commit** 1471 1472```bash 1473git add apps/web/src/routes/admin-themes.tsx apps/web/src/routes/__tests__/admin-themes.test.tsx 1474git 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 1484export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1485pnpm test 2>&1 | tail -30 1486``` 1487 1488Expected: All packages pass. 1489 1490**Step 2: Run lint fix** 1491 1492```bash 1493export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH" 1494pnpm --filter @atbb/web lint:fix 1495``` 1496 1497Fix any lint errors that appear. 1498 1499**Step 3: If any lint fixes were needed, commit them** 1500 1501```bash 1502git add -A 1503git 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 1523If 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: 1527import neobrutalLight from "../styles/presets/neobrutal-light.json"; 1528import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 1529``` 1530 1531Use the same syntax (without `assert { type: "json" }`) if that's what the project's tsconfig supports.