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