ATB-59 Theme Token Editor — Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build the admin theme token editor at GET /admin/themes/:rkey with HTMX live preview, save to PDS, and preset reset.
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.
Tech Stack: Hono, Hono JSX, HTMX, Vitest, TypeScript. No new dependencies.
Before You Start#
Run the test suite first to establish a baseline:
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test 2>&1 | tail -20
Expected: all tests pass. If not, stop and investigate before proceeding.
Key files:
apps/web/src/routes/admin.tsx— source of truth for theme handlers to extract (lines 1491–1992)apps/web/src/routes/__tests__/admin.test.tsx— existing tests; do NOT break themapps/web/src/lib/theme.ts—tokensToCss()utilityapps/web/src/styles/presets/neobrutal-light.json— 46-token preset (light)apps/web/src/styles/presets/neobrutal-dark.json— 46-token preset (dark)apps/web/public/static/css/theme.css— CSS class reference for preview HTML
Token names (all 46, from preset JSON):
COLOR: color-bg, color-surface, color-text, color-text-muted, color-primary,
color-primary-hover, color-secondary, color-border, color-shadow,
color-success, color-warning, color-danger, color-code-bg, color-code-text
TYPOGRAPHY: font-body, font-heading, font-mono, font-size-base, font-size-sm,
font-size-xs, font-size-lg, font-size-xl, font-size-2xl,
font-weight-normal, font-weight-bold, line-height-body, line-height-heading
SPACING: space-xs, space-sm, space-md, space-lg, space-xl,
radius, border-width, shadow-offset, content-width
COMPONENTS: button-radius, button-shadow, card-radius, card-shadow,
btn-press-hover, btn-press-active, input-radius, input-border, nav-height
Task 1: Create admin-themes.tsx with extracted handlers#
Files:
- Create:
apps/web/src/routes/admin-themes.tsx
This task moves existing code. No new functionality yet. The file will:
- Re-define the
extractAppviewErrorhelper (it stays inadmin.tsxtoo, for structure routes) - Re-define
AdminThemeEntry,ThemePolicytypes (only used by theme handlers) - Move
THEME_PRESETSconstant and the two JSON imports - Export
createAdminThemeRoutes(appviewUrl: string)factory containing all 5 existing handlers
Step 1: Create the file
apps/web/src/routes/admin-themes.tsx:
import { Hono } from "hono";
import { BaseLayout } from "../layouts/base.js";
import { PageHeader, EmptyState } from "../components/index.js";
import {
getSessionWithPermissions,
canManageThemes,
} from "../lib/session.js";
import { isProgrammingError } from "../lib/errors.js";
import { logger } from "../lib/logger.js";
import { tokensToCss } from "../lib/theme.js";
import neobrutalLight from "../styles/presets/neobrutal-light.json" assert { type: "json" };
import neobrutalDark from "../styles/presets/neobrutal-dark.json" assert { type: "json" };
// ─── Types ─────────────────────────────────────────────────────────────────
interface AdminThemeEntry {
id: string;
uri: string;
name: string;
colorScheme: string;
tokens: Record<string, string>;
cssOverrides: string | null;
fontUrls: string[] | null;
createdAt: string;
indexedAt: string;
}
interface ThemePolicy {
defaultLightThemeUri: string | null;
defaultDarkThemeUri: string | null;
allowUserChoice: boolean;
availableThemes: Array<{ uri: string; cid: string }>;
}
// ─── Constants ──────────────────────────────────────────────────────────────
const THEME_PRESETS: Record<string, Record<string, string>> = {
"neobrutal-light": neobrutalLight as Record<string, string>,
"neobrutal-dark": neobrutalDark as Record<string, string>,
"blank": {},
};
const COLOR_TOKENS = [
"color-bg", "color-surface", "color-text", "color-text-muted",
"color-primary", "color-primary-hover", "color-secondary", "color-border",
"color-shadow", "color-success", "color-warning", "color-danger",
"color-code-bg", "color-code-text",
] as const;
const TYPOGRAPHY_TOKENS = [
"font-body", "font-heading", "font-mono",
"font-size-base", "font-size-sm", "font-size-xs", "font-size-lg",
"font-size-xl", "font-size-2xl",
"font-weight-normal", "font-weight-bold",
"line-height-body", "line-height-heading",
] as const;
const SPACING_TOKENS = [
"space-xs", "space-sm", "space-md", "space-lg", "space-xl",
"radius", "border-width", "shadow-offset", "content-width",
] as const;
const COMPONENT_TOKENS = [
"button-radius", "button-shadow",
"card-radius", "card-shadow",
"btn-press-hover", "btn-press-active",
"input-radius", "input-border",
"nav-height",
] as const;
const ALL_KNOWN_TOKENS: readonly string[] = [
...COLOR_TOKENS, ...TYPOGRAPHY_TOKENS, ...SPACING_TOKENS, ...COMPONENT_TOKENS,
];
// ─── Helpers ────────────────────────────────────────────────────────────────
async function extractAppviewError(res: Response, fallback: string): Promise<string> {
try {
const data = (await res.json()) as { error?: string };
return data.error ?? fallback;
} catch {
return fallback;
}
}
/** Drop token values that could break the CSS style block. */
function sanitizeTokenValue(value: unknown): string | null {
if (typeof value !== "string") return null;
if (value.includes("<") || value.includes(";") || value.includes("</")) return null;
return value;
}
// ─── Components ─────────────────────────────────────────────────────────────
function ColorTokenInput({ name, value }: { name: string; value: string }) {
const safeValue =
!value.startsWith("var(") && !value.includes(";") && !value.includes("<")
? value
: "#cccccc";
return (
<div class="token-input token-input--color">
<label for={`token-${name}`}>{name}</label>
<div class="token-input__controls">
<input
type="color"
value={safeValue}
aria-label={`${name} color picker`}
oninput="this.nextElementSibling.value=this.value;this.nextElementSibling.dispatchEvent(new Event('change',{bubbles:true}))"
/>
<input
type="text"
id={`token-${name}`}
name={name}
value={safeValue}
oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))this.previousElementSibling.value=this.value"
/>
</div>
</div>
);
}
function TextTokenInput({ name, value }: { name: string; value: string }) {
return (
<div class="token-input">
<label for={`token-${name}`}>{name}</label>
<input type="text" id={`token-${name}`} name={name} value={value} />
</div>
);
}
function TokenFieldset({
legend,
tokens,
effectiveTokens,
isColor,
}: {
legend: string;
tokens: readonly string[];
effectiveTokens: Record<string, string>;
isColor: boolean;
}) {
return (
<fieldset class="token-group">
<legend>{legend}</legend>
{tokens.map((name) =>
isColor ? (
<ColorTokenInput name={name} value={effectiveTokens[name] ?? ""} />
) : (
<TextTokenInput name={name} value={effectiveTokens[name] ?? ""} />
)
)}
</fieldset>
);
}
function ThemePreviewContent({ tokens }: { tokens: Record<string, string> }) {
const css = tokensToCss(tokens);
return (
<>
<style>{`.preview-pane-inner{${css}}`}</style>
<div class="preview-pane-inner">
<div
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);"
role="navigation"
aria-label="Preview navigation"
>
atBB Forum Preview
</div>
<div style="padding:var(--space-md);">
<div
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);"
>
<h2
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;"
>
Sample Thread Title
</h2>
<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;">
Body text showing font, color, and spacing at work.{" "}
<a href="#" style="color:var(--color-primary);">A sample link</a>
</p>
<pre
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;"
>
{`const greeting = "hello forum";`}
</pre>
<input
type="text"
placeholder="Reply…"
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);"
/>
<div style="display:flex;gap:var(--space-sm);flex-wrap:wrap;">
<button
type="button"
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;"
>
Post Reply
</button>
<button
type="button"
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;"
>
Cancel
</button>
<span
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);"
>
success
</span>
<span
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);"
>
warning
</span>
<span
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);"
>
danger
</span>
</div>
</div>
</div>
</div>
</>
);
}
// ─── Route factory ──────────────────────────────────────────────────────────
export function createAdminThemeRoutes(appviewUrl: string) {
const app = new Hono();
// ── GET /admin/themes ──────────────────────────────────────────────────────
// PASTE the GET /admin/themes handler from admin.tsx here (lines 1493–1771)
// Change app.get to app.get (it's already on app in admin.tsx)
// Update all fetch URLs to use appviewUrl parameter instead of the module-level variable
// ── POST /admin/themes ─────────────────────────────────────────────────────
// PASTE the POST /admin/themes handler from admin.tsx here (lines 1773–1836)
// ── POST /admin/themes/:rkey/duplicate ─────────────────────────────────────
// PASTE the handler from admin.tsx here (lines 1838–1875)
// ── POST /admin/themes/:rkey/delete ────────────────────────────────────────
// PASTE the handler from admin.tsx here (lines 1877–1920)
// ── POST /admin/theme-policy ───────────────────────────────────────────────
// PASTE the handler from admin.tsx here (lines 1922–1992)
return app;
}
Step 2: Fill in the extracted handlers
Open apps/web/src/routes/admin.tsx. Copy the body of each theme handler (lines 1493–1992) into the corresponding slot in admin-themes.tsx. Important differences:
- In
admin.tsx, the handlers reference the module-levelappviewUrlvariable. In the new factory, use theappviewUrlparameter instead. - The imports already exist in the new file (
logger,isProgrammingError, etc.). - Remove the
// PASTE...placeholder comments as you fill each one in.
Step 3: Run tests to verify no regressions
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test 2>&1 | tail -20
Expected: same pass count as baseline. (The admin-themes tests don't exist yet — that's OK.)
Step 4: Commit
git add apps/web/src/routes/admin-themes.tsx
git commit -m "refactor(web): extract theme admin handlers into admin-themes.tsx (ATB-59)"
Task 2: Update admin.tsx to delegate to admin-themes.tsx#
Files:
- Modify:
apps/web/src/routes/admin.tsx
Step 1: Add import at the top of admin.tsx
After the existing imports, add:
import { createAdminThemeRoutes } from "./admin-themes.js";
Also remove (now unused) imports from admin.tsx:
neobrutalLightandneobrutalDarkJSON imports (lines 15–16 — only used by THEME_PRESETS)
Step 2: Mount the theme routes
Just before return app; at the end of createAdminRoutes (around line 1993), add:
app.route("/", createAdminThemeRoutes(appviewUrl));
Step 3: Remove the extracted code from admin.tsx
Delete lines 1491–1992 (the // ─── Themes ─── section and all theme handler blocks). Leave return app; in place.
Also delete from admin.tsx:
AdminThemeEntryinterface (lines 65–75)ThemePolicyinterface (lines 77–82)THEME_PRESETSconstant and JSON imports (lines 15–16, 84–89)
Step 4: Run tests — must still pass
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test 2>&1 | tail -20
All existing tests must pass. The theme list routes are now handled by admin-themes.tsx but mounted at the same paths.
Step 5: Commit
git add apps/web/src/routes/admin.tsx
git commit -m "refactor(web): mount admin-themes routes, remove extracted code from admin.tsx (ATB-59)"
Task 3: Write tests for GET /admin/themes/:rkey (TDD)#
Files:
- Create:
apps/web/src/routes/__tests__/admin-themes.test.tsx
Step 1: Create the test file with scaffolding
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const mockFetch = vi.fn();
describe("createAdminThemeRoutes — GET /admin/themes/:rkey", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
mockFetch.mockReset();
});
function mockResponse(body: unknown, ok = true, status = 200) {
return {
ok,
status,
statusText: ok ? "OK" : "Error",
json: () => Promise.resolve(body),
};
}
/** Session check: 2 fetches — /api/auth/session, then /api/admin/members/me */
function setupAuth(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
const MANAGE_THEMES = "space.atbb.permission.manageThemes";
const sampleTheme = {
id: "1",
uri: "at://did:plc:forum/space.atbb.forum.theme/abc123",
name: "My Theme",
colorScheme: "light",
tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" },
cssOverrides: null,
fontUrls: null,
createdAt: "2026-01-01T00:00:00.000Z",
indexedAt: "2026-01-01T00:00:00.000Z",
};
async function loadThemeRoutes() {
const { createAdminThemeRoutes } = await import("../admin-themes.js");
return createAdminThemeRoutes("http://localhost:3000");
}
it("redirects unauthenticated users to /login", async () => {
// No session cookie — no fetch calls made
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
it("returns 403 for users without manageThemes permission", async () => {
setupAuth([]);
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(403);
const html = await res.text();
expect(html).toContain("Access Denied");
});
it("returns 404 when theme not found (AppView returns 404)", async () => {
setupAuth([MANAGE_THEMES]);
mockFetch.mockResolvedValueOnce(mockResponse({ error: "Theme not found" }, false, 404));
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/notexist", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(404);
});
it("renders editor with theme name, colorScheme, and token inputs", async () => {
setupAuth([MANAGE_THEMES]);
mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme));
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("My Theme");
expect(html).toContain('value="light"');
expect(html).toContain("#f5f0e8"); // color-bg token
expect(html).toContain("#1a1a1a"); // color-text token
expect(html).toContain('name="color-bg"');
});
it("shows success banner when ?success=1 is present", async () => {
setupAuth([MANAGE_THEMES]);
mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme));
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123?success=1", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("saved"); // some form of success text
});
it("shows error banner when ?error=<msg> is present", async () => {
setupAuth([MANAGE_THEMES]);
mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme));
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123?error=Something+went+wrong", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("Something went wrong");
});
it("uses preset tokens when ?preset=neobrutal-light is present", async () => {
setupAuth([MANAGE_THEMES]);
// Theme has no tokens; preset should override
const emptyTheme = { ...sampleTheme, tokens: {} };
mockFetch.mockResolvedValueOnce(mockResponse(emptyTheme));
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123?preset=neobrutal-light", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
// neobrutal-light has color-bg: #f5f0e8
expect(html).toContain("#f5f0e8");
});
it("renders CSS overrides field as disabled (awaiting ATB-62)", async () => {
setupAuth([MANAGE_THEMES]);
mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme));
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123", {
headers: { cookie: "atbb_session=token" },
});
const html = await res.text();
expect(html).toContain("css-overrides");
expect(html).toContain("disabled");
});
});
Step 2: Run the tests to verify they fail
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "admin-themes"
Expected: All new tests fail with "createAdminThemeRoutes has no route matching GET /admin/themes/:rkey".
Task 4: Implement GET /admin/themes/:rkey#
Files:
- Modify:
apps/web/src/routes/admin-themes.tsx
Step 1: Add the route handler inside createAdminThemeRoutes
Add this after the existing POST /admin/theme-policy handler, before return app;:
// ── GET /admin/themes/:rkey ────────────────────────────────────────────────
app.get("/admin/themes/:rkey", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) return c.redirect("/login");
if (!canManageThemes(auth)) {
return c.html(
<BaseLayout title="Access Denied — atBB Forum" auth={auth}>
<PageHeader title="Access Denied" />
<p>You don't have permission to manage themes.</p>
</BaseLayout>,
403
);
}
const themeRkey = c.req.param("rkey");
const cookie = c.req.header("cookie") ?? "";
const presetParam = c.req.query("preset") ?? null;
const successMsg = c.req.query("success") === "1" ? "Theme saved successfully." : null;
const errorMsg = c.req.query("error") ?? null;
// Fetch theme from AppView
let theme: AdminThemeEntry | null = null;
try {
const res = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, {
headers: { Cookie: cookie },
});
if (res.status === 404) {
return c.html(
<BaseLayout title="Theme Not Found — atBB Admin" auth={auth}>
<PageHeader title="Theme Not Found" />
<p>This theme does not exist.</p>
<a href="/admin/themes" class="btn btn-secondary">← Back to themes</a>
</BaseLayout>,
404
);
}
if (res.ok) {
try {
theme = (await res.json()) as AdminThemeEntry;
} catch {
logger.error("Failed to parse theme response", {
operation: "GET /admin/themes/:rkey",
themeRkey,
});
}
} else {
logger.error("AppView returned error loading theme", {
operation: "GET /admin/themes/:rkey",
themeRkey,
status: res.status,
});
}
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error loading theme", {
operation: "GET /admin/themes/:rkey",
themeRkey,
error: error instanceof Error ? error.message : String(error),
});
}
if (!theme) {
return c.html(
<BaseLayout title="Theme Unavailable — atBB Admin" auth={auth}>
<PageHeader title="Theme Unavailable" />
<p>Unable to load theme data. Please try again.</p>
<a href="/admin/themes" class="btn btn-secondary">← Back to themes</a>
</BaseLayout>,
500
);
}
// If ?preset is set, override DB tokens with preset tokens
const presetTokens = presetParam ? (THEME_PRESETS[presetParam] ?? null) : null;
const effectiveTokens: Record<string, string> = presetTokens
? { ...theme.tokens, ...presetTokens }
: { ...theme.tokens };
const fontUrlsText = (theme.fontUrls ?? []).join("\n");
return c.html(
<BaseLayout title={`Edit Theme: ${theme.name} — atBB Admin`} auth={auth}>
<PageHeader title={`Edit Theme: ${theme.name}`} />
{successMsg && <div class="structure-success-banner">{successMsg}</div>}
{errorMsg && <div class="structure-error-banner">{errorMsg}</div>}
<a href="/admin/themes" class="btn btn-secondary btn-sm" style="margin-bottom: var(--space-md); display: inline-block;">
← Back to themes
</a>
{/* Metadata + tokens form */}
<form
id="editor-form"
method="post"
action={`/admin/themes/${themeRkey}/save`}
class="theme-editor"
>
{/* Metadata */}
<fieldset class="token-group">
<legend>Theme Metadata</legend>
<div class="token-input">
<label for="theme-name">Name</label>
<input type="text" id="theme-name" name="name" value={theme.name} required />
</div>
<div class="token-input">
<label for="theme-scheme">Color Scheme</label>
<select id="theme-scheme" name="colorScheme">
<option value="light" selected={theme.colorScheme === "light"}>Light</option>
<option value="dark" selected={theme.colorScheme === "dark"}>Dark</option>
</select>
</div>
<div class="token-input">
<label for="theme-font-urls">Font URLs (one per line)</label>
<textarea id="theme-font-urls" name="fontUrls" rows={3} placeholder="https://fonts.googleapis.com/css2?family=...">
{fontUrlsText}
</textarea>
</div>
</fieldset>
{/* Token editor + live preview layout */}
<div class="theme-editor__layout">
{/* Left: token controls */}
<div
class="theme-editor__controls"
hx-post={`/admin/themes/${themeRkey}/preview`}
hx-trigger="input delay:500ms"
hx-target="#preview-pane"
hx-include="#editor-form"
>
<TokenFieldset
legend="Colors"
tokens={COLOR_TOKENS}
effectiveTokens={effectiveTokens}
isColor={true}
/>
<TokenFieldset
legend="Typography"
tokens={TYPOGRAPHY_TOKENS}
effectiveTokens={effectiveTokens}
isColor={false}
/>
<TokenFieldset
legend="Spacing & Layout"
tokens={SPACING_TOKENS}
effectiveTokens={effectiveTokens}
isColor={false}
/>
<TokenFieldset
legend="Components"
tokens={COMPONENT_TOKENS}
effectiveTokens={effectiveTokens}
isColor={false}
/>
{/* CSS overrides — disabled until ATB-62 */}
<fieldset class="token-group">
<legend>CSS Overrides</legend>
<div class="token-input">
<label for="css-overrides">
Custom CSS{" "}
<span class="form-hint">(disabled — CSS sanitization not yet implemented)</span>
</label>
<textarea
id="css-overrides"
name="cssOverrides"
rows={6}
disabled
aria-describedby="css-overrides-hint"
placeholder="/* Will be enabled in ATB-62 */"
>
{theme.cssOverrides ?? ""}
</textarea>
<p id="css-overrides-hint" class="form-hint">
Raw CSS overrides will be available after CSS sanitization is implemented (ATB-62).
</p>
</div>
</fieldset>
</div>
{/* Right: live preview */}
<div class="theme-editor__preview">
<h3>Live Preview</h3>
<div id="preview-pane" class="preview-pane">
<ThemePreviewContent tokens={effectiveTokens} />
</div>
</div>
</div>
{/* Actions */}
<div class="theme-editor__actions">
<button type="submit" class="btn btn-primary">Save Theme</button>
<button
type="button"
class="btn btn-secondary"
onclick="document.getElementById('reset-dialog').showModal()"
>
Reset to Preset
</button>
</div>
</form>
{/* Reset to preset dialog */}
<dialog id="reset-dialog" class="structure-confirm-dialog">
<form method="post" action={`/admin/themes/${themeRkey}/reset-to-preset`}>
<p>Reset all token values to a built-in preset? Your unsaved changes will be lost.</p>
<div class="form-group">
<label for="reset-preset-select">Reset to preset:</label>
<select id="reset-preset-select" name="preset">
<option value="neobrutal-light">Neobrutal Light</option>
<option value="neobrutal-dark">Neobrutal Dark</option>
<option value="blank">Blank (empty tokens)</option>
</select>
</div>
<div class="dialog-actions">
<button type="submit" class="btn btn-danger">Reset</button>
<button
type="button"
class="btn btn-secondary"
onclick="document.getElementById('reset-dialog').close()"
>
Cancel
</button>
</div>
</form>
</dialog>
</BaseLayout>
);
});
Step 2: Run the tests
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "admin-themes"
Expected: All 8 GET /admin/themes/:rkey tests pass.
Step 3: Also fix the Edit button in the theme list
In admin-themes.tsx, find the GET /admin/themes list handler. Find this line:
<span class="btn btn-secondary btn-sm" aria-disabled="true">
Edit
</span>
Replace with:
<a href={`/admin/themes/${themeRkey}`} class="btn btn-secondary btn-sm">
Edit
</a>
Step 4: Run tests again
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test 2>&1 | tail -20
All tests must pass.
Step 5: Commit
git add apps/web/src/routes/admin-themes.tsx
git commit -m "feat(web): GET /admin/themes/:rkey token editor page + fix Edit button (ATB-59)"
Task 5: Write tests for POST /admin/themes/:rkey/preview#
Files:
- Modify:
apps/web/src/routes/__tests__/admin-themes.test.tsx
Step 1: Add a new describe block
describe("createAdminThemeRoutes — POST /admin/themes/:rkey/preview", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
mockFetch.mockReset();
});
function mockResponse(body: unknown, ok = true, status = 200) {
return {
ok, status,
statusText: ok ? "OK" : "Error",
json: () => Promise.resolve(body),
};
}
function setupAuth(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
const MANAGE_THEMES = "space.atbb.permission.manageThemes";
async function loadThemeRoutes() {
const { createAdminThemeRoutes } = await import("../admin-themes.js");
return createAdminThemeRoutes("http://localhost:3000");
}
it("redirects unauthenticated users to /login", async () => {
const routes = await loadThemeRoutes();
const body = new URLSearchParams({ "color-bg": "#ff0000" });
const res = await routes.request("/admin/themes/abc123/preview", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: body.toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
it("returns an HTML fragment with a scoped <style> block containing submitted token values", async () => {
setupAuth([MANAGE_THEMES]);
const routes = await loadThemeRoutes();
const body = new URLSearchParams({
"color-bg": "#ff0000",
"color-text": "#0000ff",
});
const res = await routes.request("/admin/themes/abc123/preview", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: "atbb_session=token",
},
body: body.toString(),
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("--color-bg");
expect(html).toContain("#ff0000");
expect(html).toContain("--color-text");
expect(html).toContain("#0000ff");
expect(html).toContain(".preview-pane-inner");
// Should NOT have full page HTML — this is a fragment
expect(html).not.toContain("<html");
expect(html).not.toContain("<BaseLayout");
});
it("drops token values containing '<' (sanitization)", async () => {
setupAuth([MANAGE_THEMES]);
const routes = await loadThemeRoutes();
const body = new URLSearchParams({
"color-bg": "<script>alert(1)</script>",
"color-text": "#1a1a1a",
});
const res = await routes.request("/admin/themes/abc123/preview", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: "atbb_session=token",
},
body: body.toString(),
});
expect(res.status).toBe(200);
const html = await res.text();
// The malicious value must not appear
expect(html).not.toContain("<script>");
expect(html).not.toContain("alert(1)");
});
it("drops token values containing ';' (sanitization)", async () => {
setupAuth([MANAGE_THEMES]);
const routes = await loadThemeRoutes();
const body = new URLSearchParams({
"color-bg": "red; --injected: 1",
});
const res = await routes.request("/admin/themes/abc123/preview", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: "atbb_session=token",
},
body: body.toString(),
});
const html = await res.text();
expect(html).not.toContain("--injected");
});
});
Step 2: Run tests to verify they fail
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -E "preview|FAIL|PASS" | head -20
Expected: New preview tests fail.
Task 6: Implement POST /admin/themes/:rkey/preview#
Files:
- Modify:
apps/web/src/routes/admin-themes.tsx
Step 1: Add the route handler inside createAdminThemeRoutes, after the GET handler
// ── POST /admin/themes/:rkey/preview ─────────────────────────────────────
app.post("/admin/themes/:rkey/preview", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) return c.redirect("/login");
let rawBody: Record<string, string | File>;
try {
rawBody = await c.req.parseBody();
} catch (error) {
if (isProgrammingError(error)) throw error;
// Return empty preview on parse error — don't break the HTMX swap
return c.html(<ThemePreviewContent tokens={{}} />);
}
// Build token map from only known token names (ignore unknown fields like name/colorScheme)
const tokens: Record<string, string> = {};
for (const tokenName of ALL_KNOWN_TOKENS) {
const raw = rawBody[tokenName];
if (typeof raw !== "string") continue;
const safe = sanitizeTokenValue(raw);
if (safe !== null) {
tokens[tokenName] = safe;
}
}
return c.html(<ThemePreviewContent tokens={tokens} />);
});
Step 2: Run the tests
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "preview"
Expected: All preview tests pass.
Step 3: Commit
git add apps/web/src/routes/admin-themes.tsx
git commit -m "feat(web): POST /admin/themes/:rkey/preview — HTMX live preview endpoint (ATB-59)"
Task 7: Write tests for POST /admin/themes/:rkey/save#
Files:
- Modify:
apps/web/src/routes/__tests__/admin-themes.test.tsx
Step 1: Add describe block
describe("createAdminThemeRoutes — POST /admin/themes/:rkey/save", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
mockFetch.mockReset();
});
function mockResponse(body: unknown, ok = true, status = 200) {
return {
ok, status,
statusText: ok ? "OK" : "Error",
json: () => Promise.resolve(body),
};
}
function setupAuth(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
const MANAGE_THEMES = "space.atbb.permission.manageThemes";
async function loadThemeRoutes() {
const { createAdminThemeRoutes } = await import("../admin-themes.js");
return createAdminThemeRoutes("http://localhost:3000");
}
function makeFormBody(overrides: Record<string, string> = {}): string {
return new URLSearchParams({
name: "My Theme",
colorScheme: "light",
fontUrls: "",
"color-bg": "#f5f0e8",
...overrides,
}).toString();
}
it("redirects to ?success=1 on AppView 200", async () => {
setupAuth([MANAGE_THEMES]);
mockFetch.mockResolvedValueOnce(mockResponse({ id: "1", name: "My Theme" }));
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123/save", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: "atbb_session=token",
},
body: makeFormBody(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/admin/themes/abc123");
expect(res.headers.get("location")).toContain("success=1");
});
it("redirects with ?error=<msg> when AppView returns 400", async () => {
setupAuth([MANAGE_THEMES]);
mockFetch.mockResolvedValueOnce(
mockResponse({ error: "Name is required" }, false, 400)
);
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123/save", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: "atbb_session=token",
},
body: makeFormBody({ name: "" }),
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toContain("error=");
expect(decodeURIComponent(location)).toContain("Name is required");
});
it("redirects with generic error on network failure", async () => {
setupAuth([MANAGE_THEMES]);
mockFetch.mockRejectedValueOnce(new Error("fetch failed"));
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123/save", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: "atbb_session=token",
},
body: makeFormBody(),
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toContain("error=");
expect(decodeURIComponent(location).toLowerCase()).toContain("unavailable");
});
it("redirects unauthenticated users to /login", async () => {
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123/save", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: makeFormBody(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
});
Step 2: Verify tests fail
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "save"
Expected: New save tests fail.
Task 8: Implement POST /admin/themes/:rkey/save#
Files:
- Modify:
apps/web/src/routes/admin-themes.tsx
Step 1: Add the route handler after the preview handler
// ── POST /admin/themes/:rkey/save ─────────────────────────────────────────
app.post("/admin/themes/:rkey/save", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) return c.redirect("/login");
if (!canManageThemes(auth)) {
return c.html(
<BaseLayout title="Access Denied" auth={auth}>
<p>Access denied.</p>
</BaseLayout>,
403
);
}
const themeRkey = c.req.param("rkey");
const cookie = c.req.header("cookie") ?? "";
let rawBody: Record<string, string | File>;
try {
rawBody = await c.req.parseBody();
} catch (error) {
if (isProgrammingError(error)) throw error;
return c.redirect(
`/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid form submission.")}`,
302
);
}
const name = typeof rawBody.name === "string" ? rawBody.name.trim() : "";
const colorScheme = typeof rawBody.colorScheme === "string" ? rawBody.colorScheme : "light";
const fontUrlsRaw = typeof rawBody.fontUrls === "string" ? rawBody.fontUrls : "";
const fontUrls = fontUrlsRaw
.split("\n")
.map((u) => u.trim())
.filter(Boolean);
// Extract token values from form fields
const tokens: Record<string, string> = {};
for (const tokenName of ALL_KNOWN_TOKENS) {
const raw = rawBody[tokenName];
if (typeof raw === "string" && raw.trim()) {
tokens[tokenName] = raw.trim();
}
}
let apiRes: Response;
try {
apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, {
method: "PUT",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ name, colorScheme, tokens, fontUrls }),
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error saving theme", {
operation: "POST /admin/themes/:rkey/save",
themeRkey,
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/themes/${themeRkey}?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
302
);
}
if (!apiRes.ok) {
const msg = await extractAppviewError(apiRes, "Failed to save theme. Please try again.");
return c.redirect(
`/admin/themes/${themeRkey}?error=${encodeURIComponent(msg)}`,
302
);
}
return c.redirect(`/admin/themes/${themeRkey}?success=1`, 302);
});
Step 2: Run the tests
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "save"
Expected: All save tests pass.
Step 3: Commit
git add apps/web/src/routes/admin-themes.tsx
git commit -m "feat(web): POST /admin/themes/:rkey/save — persist token edits to AppView (ATB-59)"
Task 9: Write tests for POST /admin/themes/:rkey/reset-to-preset#
Files:
- Modify:
apps/web/src/routes/__tests__/admin-themes.test.tsx
Step 1: Add describe block
describe("createAdminThemeRoutes — POST /admin/themes/:rkey/reset-to-preset", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.unstubAllEnvs();
mockFetch.mockReset();
});
function mockResponse(body: unknown, ok = true, status = 200) {
return {
ok, status,
statusText: ok ? "OK" : "Error",
json: () => Promise.resolve(body),
};
}
function setupAuth(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
const MANAGE_THEMES = "space.atbb.permission.manageThemes";
async function loadThemeRoutes() {
const { createAdminThemeRoutes } = await import("../admin-themes.js");
return createAdminThemeRoutes("http://localhost:3000");
}
it("redirects to ?preset=neobrutal-light for valid preset", async () => {
setupAuth([MANAGE_THEMES]);
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123/reset-to-preset", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: "atbb_session=token",
},
body: new URLSearchParams({ preset: "neobrutal-light" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=neobrutal-light");
});
it("redirects to ?preset=neobrutal-dark for dark preset", async () => {
setupAuth([MANAGE_THEMES]);
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123/reset-to-preset", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: "atbb_session=token",
},
body: new URLSearchParams({ preset: "neobrutal-dark" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=neobrutal-dark");
});
it("redirects to ?preset=blank for blank preset", async () => {
setupAuth([MANAGE_THEMES]);
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123/reset-to-preset", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: "atbb_session=token",
},
body: new URLSearchParams({ preset: "blank" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=blank");
});
it("returns 400 for unknown preset name", async () => {
setupAuth([MANAGE_THEMES]);
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123/reset-to-preset", {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
cookie: "atbb_session=token",
},
body: new URLSearchParams({ preset: "hacked" }).toString(),
});
expect(res.status).toBe(400);
});
it("redirects unauthenticated users to /login", async () => {
const routes = await loadThemeRoutes();
const res = await routes.request("/admin/themes/abc123/reset-to-preset", {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ preset: "neobrutal-light" }).toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
});
Step 2: Verify tests fail
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "reset"
Task 10: Implement POST /admin/themes/:rkey/reset-to-preset#
Files:
- Modify:
apps/web/src/routes/admin-themes.tsx
Step 1: Add the route handler
// ── POST /admin/themes/:rkey/reset-to-preset ──────────────────────────────
app.post("/admin/themes/:rkey/reset-to-preset", async (c) => {
const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
if (!auth.authenticated) return c.redirect("/login");
if (!canManageThemes(auth)) {
return c.html(
<BaseLayout title="Access Denied" auth={auth}>
<p>Access denied.</p>
</BaseLayout>,
403
);
}
const themeRkey = c.req.param("rkey");
let body: Record<string, string | File>;
try {
body = await c.req.parseBody();
} catch (error) {
if (isProgrammingError(error)) throw error;
return c.json({ error: "Invalid form submission." }, 400);
}
const preset = typeof body.preset === "string" ? body.preset : "";
if (!(preset in THEME_PRESETS)) {
return c.json({ error: `Unknown preset: ${preset}` }, 400);
}
return c.redirect(`/admin/themes/${themeRkey}?preset=${encodeURIComponent(preset)}`, 302);
});
Step 2: Run all tests
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test 2>&1 | tail -20
Expected: All tests pass.
Step 3: Commit
git add apps/web/src/routes/admin-themes.tsx apps/web/src/routes/__tests__/admin-themes.test.tsx
git commit -m "feat(web): POST /admin/themes/:rkey/reset-to-preset + all tests (ATB-59)"
Task 11: Full test suite + lint + Linear update#
Step 1: Run the full test suite
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm test 2>&1 | tail -30
Expected: All packages pass.
Step 2: Run lint fix
export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web lint:fix
Fix any lint errors that appear.
Step 3: If any lint fixes were needed, commit them
git add -A
git commit -m "style(web): lint fixes for ATB-59 theme editor"
Step 4: Update Linear issue ATB-59
- Change status to In Progress → In Review (or Done after review)
- Add a comment listing what was implemented
What We Did NOT Implement (per spec)#
cssOverrideseditor — disabled, awaiting ATB-62 (CSS sanitization)- Font URL validation — currently stored as-is; proper HTTPS URL validation can be added in ATB-62
- Import/export JSON — listed in theming-plan.md Phase 3/5 but not in ATB-59 scope
- User theme picker — ATB-60 scope
Known Token JSON Import Note#
If TypeScript complains about import ... assert { type: "json" }, check how it was done in the existing admin.tsx:
// admin.tsx already uses:
import neobrutalLight from "../styles/presets/neobrutal-light.json";
import neobrutalDark from "../styles/presets/neobrutal-dark.json";
Use the same syntax (without assert { type: "json" }) if that's what the project's tsconfig supports.