ATB-58: Admin Theme List Page — Implementation Plan#
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: Build GET /admin/themes — an admin page to view, create, duplicate, and delete themes, and manage theme policy — plus two new AppView API endpoints that support it.
Architecture: Two new AppView admin endpoints (GET /api/admin/themes for unfiltered theme listing, POST /api/admin/themes/:rkey/duplicate for cloning); session.ts gets canManageThemes; the web admin page uses the HTML form attribute to associate per-card availability checkboxes with a policy <form> without nesting.
Tech Stack: Hono (web + appview), Drizzle ORM (postgres.js), Vitest, HTMX-free server-rendered JSX, AT Proto agent (@atproto/common-web TID), JSON preset files.
Before You Start#
Read these files to understand existing patterns before touching any code:
apps/appview/src/routes/admin.tslines 983–1059 (POST /api/admin/themes — exact pattern to follow)apps/appview/src/routes/__tests__/admin.test.tslines 1–80 (test setup: mock structure,createTestContext,describe.sequential)apps/web/src/routes/admin.tsxlines 1–210 (imports, types, helper functions,createAdminRoutessignature)apps/web/src/routes/__tests__/admin.test.tsxlines 1–120 (setupAuthenticatedSession,mockFetchpattern,loadAdminRoutes)apps/web/src/lib/session.tslines 155–220 (ADMIN_PERMISSIONSarray,canManageRoles— the pattern to clone forcanManageThemes)
Test command for appview: PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/appview exec vitest run
Test command for web: PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm --filter @atbb/web exec vitest run
Run all: PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm turbo test
Task 1: AppView — GET /api/admin/themes#
Returns all themes for the forum DID (unfiltered by policy) with full token data.
Files:
- Modify:
apps/appview/src/routes/admin.ts - Modify:
apps/appview/src/routes/__tests__/admin.test.ts
Step 1: Write the failing tests#
Add a new describe block near the existing theme tests in admin.test.ts. Find the section for theme routes (search for "POST /api/admin/themes") and add before it:
describe("GET /api/admin/themes", () => {
it("returns empty array when no themes exist", async () => {
const res = await app.request("/api/admin/themes");
expect(res.status).toBe(200);
const body = await res.json();
expect(body).toHaveProperty("themes");
expect(body.themes).toEqual([]);
});
it("returns all themes regardless of policy availability", async () => {
// Insert two themes but only add one to policy
await ctx.db.insert(themes).values([
{
did: ctx.config.forumDid,
rkey: "3lbltheme1aa",
cid: "bafytheme1",
name: "Neobrutal Light",
colorScheme: "light",
tokens: { "color-bg": "#f5f0e8" },
createdAt: new Date(),
indexedAt: new Date(),
},
{
did: ctx.config.forumDid,
rkey: "3lbltheme2bb",
cid: "bafytheme2",
name: "Neobrutal Dark",
colorScheme: "dark",
tokens: { "color-bg": "#1a1a1a" },
createdAt: new Date(),
indexedAt: new Date(),
},
]);
const res = await app.request("/api/admin/themes");
expect(res.status).toBe(200);
const body = await res.json();
// Returns BOTH themes — not filtered by policy
expect(body.themes).toHaveLength(2);
expect(body.themes[0]).toMatchObject({
name: "Neobrutal Light",
colorScheme: "light",
});
expect(body.themes[0]).toHaveProperty("tokens");
expect(body.themes[0]).toHaveProperty("uri");
expect(body.themes[0].uri).toContain("space.atbb.forum.theme");
});
it("returns 401 when not authenticated", async () => {
mockUser = null;
const res = await app.request("/api/admin/themes");
expect(res.status).toBe(401);
});
});
Step 2: Run tests to verify they fail#
pnpm --filter @atbb/appview exec vitest run --reporter verbose 2>&1 | grep -A3 "GET /api/admin/themes"
Expected: 3 failures — route does not exist yet.
Step 3: Implement GET /api/admin/themes in apps/appview/src/routes/admin.ts#
First, add serializeBigInt and serializeDate to the helpers import at the top of admin.ts:
// Find this line:
import { parseBigIntParam } from "./helpers.js";
// Change to:
import { parseBigIntParam, serializeBigInt, serializeDate } from "./helpers.js";
Then add the route inside createAdminRoutes, immediately before the POST /themes route (before line 991). Find the JSDoc comment /** POST /api/admin/themes and insert before it:
/**
* GET /api/admin/themes
*
* Returns all themes for this forum — no policy filtering.
* Admins need to see all themes, including drafts not yet in the policy.
*/
app.get(
"/themes",
requireAuth(ctx),
requirePermission(ctx, "space.atbb.permission.manageThemes"),
async (c) => {
try {
const themeList = await ctx.db
.select()
.from(themes)
.where(eq(themes.did, ctx.config.forumDid))
.limit(100);
return c.json({
themes: themeList.map((theme) => ({
id: serializeBigInt(theme.id),
uri: `at://${theme.did}/space.atbb.forum.theme/${theme.rkey}`,
name: theme.name,
colorScheme: theme.colorScheme,
tokens: theme.tokens,
cssOverrides: theme.cssOverrides ?? null,
fontUrls: (theme.fontUrls as string[] | null) ?? null,
createdAt: serializeDate(theme.createdAt),
indexedAt: serializeDate(theme.indexedAt),
})),
});
} catch (error) {
return handleRouteError(c, error, "Failed to retrieve themes", {
operation: "GET /api/admin/themes",
logger: ctx.logger,
});
}
}
);
Step 4: Run tests to verify they pass#
pnpm --filter @atbb/appview exec vitest run --reporter verbose 2>&1 | grep -A3 "GET /api/admin/themes"
Expected: 3 passing.
Step 5: Commit#
git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "$(cat <<'EOF'
feat(appview): add GET /api/admin/themes — unfiltered theme list for admin UI (ATB-58)
EOF
)"
Task 2: AppView — POST /api/admin/themes/:rkey/duplicate#
Clones an existing theme with " (Copy)" appended to the name, using a fresh TID rkey.
Files:
- Modify:
apps/appview/src/routes/admin.ts - Modify:
apps/appview/src/routes/__tests__/admin.test.ts
Step 1: Write the failing tests#
Add a new describe block in admin.test.ts near the other theme tests:
describe("POST /api/admin/themes/:rkey/duplicate", () => {
beforeEach(async () => {
await ctx.db.insert(themes).values({
did: ctx.config.forumDid,
rkey: "3lblsource1aa",
cid: "bafysource1",
name: "Neobrutal Light",
colorScheme: "light",
tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00" },
createdAt: new Date(),
indexedAt: new Date(),
});
});
it("calls putRecord with a new rkey and '(Copy)' name", async () => {
const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", {
method: "POST",
});
expect(res.status).toBe(201);
const body = await res.json();
expect(body.name).toBe("Neobrutal Light (Copy)");
expect(body.rkey).toBeDefined();
expect(body.rkey).not.toBe("3lblsource1aa");
expect(body.uri).toContain("space.atbb.forum.theme");
expect(mockPutRecord).toHaveBeenCalledOnce();
const putCall = mockPutRecord.mock.calls[0][0];
expect(putCall.record.name).toBe("Neobrutal Light (Copy)");
expect(putCall.record.colorScheme).toBe("light");
expect(putCall.record.tokens).toEqual({ "color-bg": "#f5f0e8", "color-primary": "#ff5c00" });
expect(putCall.collection).toBe("space.atbb.forum.theme");
});
it("returns 404 when source rkey does not exist", async () => {
const res = await app.request("/api/admin/themes/nonexistent/duplicate", {
method: "POST",
});
expect(res.status).toBe(404);
});
it("returns 401 when not authenticated", async () => {
mockUser = null;
const res = await app.request("/api/admin/themes/3lblsource1aa/duplicate", {
method: "POST",
});
expect(res.status).toBe(401);
});
});
Step 2: Run tests to verify they fail#
pnpm --filter @atbb/appview exec vitest run --reporter verbose 2>&1 | grep -A3 "duplicate"
Expected: 3 failures.
Step 3: Implement POST /api/admin/themes/:rkey/duplicate in apps/appview/src/routes/admin.ts#
Add immediately after the DELETE /themes/:rkey route (after the closing of the delete handler, around line 1248). Find the comment /** PUT /api/admin/theme-policy and insert before it:
/**
* POST /api/admin/themes/:rkey/duplicate
*
* Clones an existing theme record with " (Copy)" appended to the name.
* Uses a fresh TID as the new record key.
* The firehose indexer will create the DB row asynchronously.
*/
app.post(
"/themes/:rkey/duplicate",
requireAuth(ctx),
requirePermission(ctx, "space.atbb.permission.manageThemes"),
async (c) => {
const sourceRkey = c.req.param("rkey").trim();
let source: typeof themes.$inferSelect;
try {
const [row] = await ctx.db
.select()
.from(themes)
.where(and(eq(themes.did, ctx.config.forumDid), eq(themes.rkey, sourceRkey)))
.limit(1);
if (!row) {
return c.json({ error: "Theme not found" }, 404);
}
source = row;
} catch (error) {
return handleRouteError(c, error, "Failed to look up source theme", {
operation: "POST /api/admin/themes/:rkey/duplicate",
logger: ctx.logger,
sourceRkey,
});
}
const { agent, error: agentError } = getForumAgentOrError(
ctx,
c,
"POST /api/admin/themes/:rkey/duplicate"
);
if (agentError) return agentError;
const newRkey = TID.nextStr();
const newName = `${source.name} (Copy)`;
const now = new Date().toISOString();
try {
const result = await agent.com.atproto.repo.putRecord({
repo: ctx.config.forumDid,
collection: "space.atbb.forum.theme",
rkey: newRkey,
record: {
$type: "space.atbb.forum.theme",
name: newName,
colorScheme: source.colorScheme,
tokens: source.tokens,
...(source.cssOverrides && { cssOverrides: source.cssOverrides }),
...(source.fontUrls && { fontUrls: source.fontUrls }),
createdAt: now,
},
});
return c.json({ uri: result.data.uri, rkey: newRkey, name: newName }, 201);
} catch (error) {
return handleRouteError(c, error, "Failed to duplicate theme", {
operation: "POST /api/admin/themes/:rkey/duplicate",
logger: ctx.logger,
sourceRkey,
newRkey,
});
}
}
);
Step 4: Run tests to verify they pass#
pnpm --filter @atbb/appview exec vitest run --reporter verbose 2>&1 | grep -A3 "duplicate"
Expected: 3 passing.
Step 5: Commit#
git add apps/appview/src/routes/admin.ts apps/appview/src/routes/__tests__/admin.test.ts
git commit -m "$(cat <<'EOF'
feat(appview): add POST /api/admin/themes/:rkey/duplicate — clone theme with new TID (ATB-58)
EOF
)"
Task 3: Web — Session Layer + Admin Landing Card#
Add canManageThemes, add it to ADMIN_PERMISSIONS, and add the Themes card to the /admin landing page.
Files:
- Modify:
apps/web/src/lib/session.ts - Modify:
apps/web/src/routes/admin.tsx - Modify:
apps/web/src/routes/__tests__/admin.test.tsx
Step 1: Write the failing tests#
In apps/web/src/routes/__tests__/admin.test.tsx, find the existing test "grants access and shows all cards for wildcard (*) permission" and add new tests after the landing page block:
it("shows Themes card for user with manageThemes permission", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('href="/admin/themes"');
expect(html).toContain("🎨");
});
it("does not show Themes card for user with only manageMembers permission", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).not.toContain('href="/admin/themes"');
});
it("shows Themes card for wildcard (*) permission user", async () => {
setupAuthenticatedSession(["*"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain('href="/admin/themes"');
});
Step 2: Run tests to verify they fail#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "Themes card"
Expected: 3 failures.
Step 3: Add canManageThemes to apps/web/src/lib/session.ts#
Find the ADMIN_PERMISSIONS array (around line 160) and add the new permission:
// Find:
const ADMIN_PERMISSIONS = [
"space.atbb.permission.manageMembers",
"space.atbb.permission.manageCategories",
"space.atbb.permission.moderatePosts",
// ... other permissions
];
// Add "space.atbb.permission.manageThemes" to the array.
Then find the last export function can... function (should be canManageRoles) and add after it:
/** Returns true if the session grants permission to manage forum themes. */
export function canManageThemes(auth: WebSessionWithPermissions): boolean {
return (
auth.authenticated &&
(auth.permissions.has("space.atbb.permission.manageThemes") ||
auth.permissions.has("*"))
);
}
Step 4: Add Themes card to apps/web/src/routes/admin.tsx#
Import canManageThemes — find the import block for session functions and add canManageThemes:
// Find:
import {
getSessionWithPermissions,
hasAnyAdminPermission,
canManageMembers,
canManageCategories,
canViewModLog,
canManageRoles,
} from "../lib/session.js";
// Add canManageThemes to the import.
In the GET /admin route handler, find where canManageRoles is called to gate a card and add after it:
const showThemes = canManageThemes(auth);
In the card grid JSX, find the last admin card and add the Themes card after it:
{showThemes && (
<a href="/admin/themes" class="admin-nav-card">
<Card>
<p class="admin-nav-card__icon" aria-hidden="true">🎨</p>
<p class="admin-nav-card__title">Themes</p>
<p class="admin-nav-card__description">
Customize forum appearance and color schemes
</p>
</Card>
</a>
)}
Step 5: Run tests to verify they pass#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "Themes card"
Expected: 3 passing.
Step 6: Commit#
git add apps/web/src/lib/session.ts apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "$(cat <<'EOF'
feat(web): add canManageThemes permission check and Themes card on admin landing (ATB-58)
EOF
)"
Task 4: Web — GET /admin/themes Page#
The main themes page: auth-gated, fetches all themes + policy, renders cards with swatches and controls.
Files:
- Modify:
apps/web/src/routes/admin.tsx - Modify:
apps/web/src/routes/__tests__/admin.test.tsx
Step 1: Write the failing tests#
Add a new describe block in admin.test.tsx:
describe("createAdminRoutes — GET /admin/themes", () => {
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 setupAuthenticatedSession(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
async function loadAdminRoutes() {
const { createAdminRoutes } = await import("../admin.js");
return createAdminRoutes("http://localhost:3000");
}
it("redirects unauthenticated users to /login", async () => {
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes");
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/login");
});
it("returns 403 for users without manageThemes permission", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageMembers"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(403);
});
it("renders theme cards with name, colorScheme badge, and swatches", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
// GET /api/admin/themes
mockFetch.mockResolvedValueOnce(
mockResponse({
themes: [
{
id: "1",
uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa",
name: "Neobrutal Light",
colorScheme: "light",
tokens: { "color-bg": "#f5f0e8", "color-primary": "#ff5c00", "color-surface": "#ffffff", "color-secondary": "#3a86ff", "color-border": "#1a1a1a" },
cssOverrides: null,
fontUrls: null,
createdAt: "2026-01-01T00:00:00.000Z",
indexedAt: "2026-01-01T00:00:00.000Z",
},
],
})
);
// GET /api/theme-policy
mockFetch.mockResolvedValueOnce(
mockResponse({
defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa",
defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa",
allowUserChoice: true,
availableThemes: [
{ uri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1aa", cid: "bafytheme1" },
],
})
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Neobrutal Light");
expect(html).toContain("light"); // colorScheme badge
expect(html).toContain("#f5f0e8"); // color-bg swatch
expect(html).toContain("#ff5c00"); // color-primary swatch
expect(html).toContain("policy-form"); // policy form id
expect(html).toContain("availableThemes"); // checkbox name
});
it("shows error banner when ?error= query param is present", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(mockResponse({ themes: [] }));
mockFetch.mockResolvedValueOnce(mockResponse(null, false, 404)); // no policy yet
const routes = await loadAdminRoutes();
const res = await routes.request(
"/admin/themes?error=" + encodeURIComponent("Cannot delete a default theme"),
{ headers: { cookie: "atbb_session=token" } }
);
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("Cannot delete a default theme");
expect(html).toContain("structure-error-banner");
});
it("renders create form with preset options", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(mockResponse({ themes: [] }));
mockFetch.mockResolvedValueOnce(mockResponse(null, false, 404));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes", {
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("neobrutal-light");
expect(html).toContain("neobrutal-dark");
expect(html).toContain("blank");
});
});
Step 2: Run tests to verify they fail#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "GET /admin/themes"
Expected: 5 failures.
Step 3: Add types and imports to apps/web/src/routes/admin.tsx#
At the top of admin.tsx, add the JSON imports (after existing imports):
import neobrutalLight from "../styles/presets/neobrutal-light.json";
import neobrutalDark from "../styles/presets/neobrutal-dark.json";
Add these interface types near the other interface definitions at the top of admin.tsx:
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 }>;
}
Add the preset map constant (after the imports, before the helper functions):
const THEME_PRESETS: Record<string, Record<string, string>> = {
"neobrutal-light": neobrutalLight as Record<string, string>,
"neobrutal-dark": neobrutalDark as Record<string, string>,
"blank": {},
};
Step 4: Implement GET /admin/themes in apps/web/src/routes/admin.tsx#
Find the end of the GET /admin/roles route (or whichever is last before the POST routes) and add:
// ─── Themes ────────────────────────────────────────────────────────────────
app.get("/admin/themes", 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="Themes" />
<p>You don't have permission to manage themes.</p>
</BaseLayout>,
403
);
}
const cookie = c.req.header("cookie") ?? "";
const errorMsg = c.req.query("error") ?? null;
let adminThemes: AdminThemeEntry[] = [];
let policy: ThemePolicy | null = null;
try {
const [themesRes, policyRes] = await Promise.all([
fetch(`${appviewUrl}/api/admin/themes`, { headers: { Cookie: cookie } }),
fetch(`${appviewUrl}/api/theme-policy`, { headers: { Cookie: cookie } }),
]);
if (themesRes.ok) {
const data = (await themesRes.json()) as { themes: AdminThemeEntry[] };
adminThemes = data.themes;
} else {
logger.error("Failed to fetch admin themes list", {
operation: "GET /admin/themes",
status: themesRes.status,
});
}
if (policyRes.ok) {
policy = (await policyRes.json()) as ThemePolicy;
}
// 404 = no policy yet — render page with empty policy (not an error)
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error fetching themes data", {
operation: "GET /admin/themes",
error: error instanceof Error ? error.message : String(error),
});
}
const availableUris = new Set((policy?.availableThemes ?? []).map((t) => t.uri));
const lightThemes = adminThemes.filter((t) => t.colorScheme === "light");
const darkThemes = adminThemes.filter((t) => t.colorScheme === "dark");
return c.html(
<BaseLayout title="Themes — atBB Admin" auth={auth}>
<PageHeader title="Themes" />
{errorMsg && <div class="structure-error-banner">{errorMsg}</div>}
{adminThemes.length === 0 ? (
<EmptyState message="No themes yet. Create one below." />
) : (
<div class="structure-list">
{adminThemes.map((theme) => {
const dialogId = `confirm-delete-theme-${theme.rkey ?? theme.id}`;
const themeRkey = theme.uri.split("/").pop() ?? theme.id;
const swatchTokens = [
"color-bg",
"color-surface",
"color-primary",
"color-secondary",
"color-border",
] as const;
return (
<div class="structure-item">
<div class="structure-item__header">
<div class="structure-item__title">
<label>
<input
type="checkbox"
form="policy-form"
name="availableThemes"
value={theme.uri}
checked={availableUris.has(theme.uri)}
/>
{" "}
{theme.name}
</label>
<span class={`badge badge--${theme.colorScheme}`}>
{theme.colorScheme}
</span>
</div>
<div class="theme-swatches" aria-hidden="true">
{swatchTokens.map((token) => {
const value = theme.tokens[token] ?? "#cccccc";
const safe =
!value.startsWith("var(") &&
!value.includes(";") &&
!value.includes("<");
return (
<span
class="theme-swatch"
style={safe ? `background:${value}` : "background:#cccccc"}
title={token}
/>
);
})}
</div>
<div class="structure-item__actions">
<span class="btn btn-secondary btn-sm" aria-disabled="true">
Edit
</span>
<form
method="post"
action={`/admin/themes/${themeRkey}/duplicate`}
style="display:inline"
>
<button type="submit" class="btn btn-secondary btn-sm">
Duplicate
</button>
</form>
<button
type="button"
class="btn btn-danger btn-sm"
onclick={`document.getElementById('${dialogId}').showModal()`}
>
Delete
</button>
</div>
</div>
<dialog id={dialogId} class="structure-confirm-dialog">
<p>
Delete theme "{theme.name}"? This cannot be undone.
</p>
<form
method="post"
action={`/admin/themes/${themeRkey}/delete`}
class="dialog-actions"
>
<button type="submit" class="btn btn-danger">
Delete
</button>
<button
type="button"
class="btn btn-secondary"
onclick={`document.getElementById('${dialogId}').close()`}
>
Cancel
</button>
</form>
</dialog>
</div>
);
})}
</div>
)}
{/* Policy form — availability checkboxes on cards associate via form="policy-form" */}
<section class="admin-section">
<h2>Theme Policy</h2>
<form id="policy-form" method="post" action="/admin/theme-policy">
<div class="form-group">
<label for="defaultLightThemeUri">Default Light Theme</label>
<select id="defaultLightThemeUri" name="defaultLightThemeUri">
<option value="">— none —</option>
{lightThemes.map((t) => (
<option
value={t.uri}
selected={policy?.defaultLightThemeUri === t.uri}
>
{t.name}
</option>
))}
</select>
</div>
<div class="form-group">
<label for="defaultDarkThemeUri">Default Dark Theme</label>
<select id="defaultDarkThemeUri" name="defaultDarkThemeUri">
<option value="">— none —</option>
{darkThemes.map((t) => (
<option
value={t.uri}
selected={policy?.defaultDarkThemeUri === t.uri}
>
{t.name}
</option>
))}
</select>
</div>
<div class="form-group">
<label>
<input
type="checkbox"
name="allowUserChoice"
checked={policy?.allowUserChoice ?? true}
/>
{" "}Allow users to choose their own theme
</label>
</div>
<p class="form-hint">
Check themes above to make them available to users.
</p>
<button type="submit" class="btn btn-primary">
Save Policy
</button>
</form>
</section>
{/* Create new theme */}
<details class="structure-add-form">
<summary class="structure-add-form__trigger">+ Create New Theme</summary>
<form
method="post"
action="/admin/themes"
class="structure-edit-form__body"
>
<div class="form-group">
<label for="new-theme-name">Name</label>
<input
id="new-theme-name"
type="text"
name="name"
required
placeholder="My Custom Theme"
/>
</div>
<div class="form-group">
<label for="new-theme-scheme">Color Scheme</label>
<select id="new-theme-scheme" name="colorScheme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>
<div class="form-group">
<label for="new-theme-preset">Start from Preset</label>
<select id="new-theme-preset" name="preset">
<option value="neobrutal-light">Neobrutal Light</option>
<option value="neobrutal-dark">Neobrutal Dark</option>
<option value="blank">Blank</option>
</select>
</div>
<button type="submit" class="btn btn-primary">
Create Theme
</button>
</form>
</details>
</BaseLayout>
);
});
Note on theme.rkey: The AdminThemeEntry type doesn't have rkey — the rkey is embedded in the URI as the last segment (uri.split("/").pop()). The code above uses that. Double-check this is correct before submitting.
Step 5: Run tests to verify they pass#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "GET /admin/themes"
Expected: 5 passing.
Step 6: Commit#
git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "$(cat <<'EOF'
feat(web): implement GET /admin/themes page — theme cards, policy form, create form (ATB-58)
EOF
)"
Task 5: Web — POST /admin/themes (Create with Preset)#
Files:
- Modify:
apps/web/src/routes/admin.tsx - Modify:
apps/web/src/routes/__tests__/admin.test.tsx
Step 1: Write the failing tests#
Add to the themes test describe block in admin.test.tsx:
describe("createAdminRoutes — POST /admin/themes", () => {
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, json: () => Promise.resolve(body) };
}
function setupAuthenticatedSession(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
async function loadAdminRoutes() {
const { createAdminRoutes } = await import("../admin.js");
return createAdminRoutes("http://localhost:3000");
}
it("creates theme and redirects to /admin/themes on success", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ uri: "at://did:plc:forum/space.atbb.forum.theme/newrkey", cid: "bafy" }, true, 201)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes", {
method: "POST",
headers: {
cookie: "atbb_session=token",
"content-type": "application/x-www-form-urlencoded",
},
body: "name=My+Theme&colorScheme=light&preset=neobrutal-light",
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/themes");
});
it("sends preset tokens to API when preset is neobrutal-light", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ uri: "at://...", cid: "bafy" }, true, 201)
);
const routes = await loadAdminRoutes();
await routes.request("/admin/themes", {
method: "POST",
headers: {
cookie: "atbb_session=token",
"content-type": "application/x-www-form-urlencoded",
},
body: "name=Neo&colorScheme=light&preset=neobrutal-light",
});
// The API call should contain the preset tokens
const apiCall = mockFetch.mock.calls[2]; // calls 0+1 = auth, call 2 = POST /api/admin/themes
const body = JSON.parse(apiCall[1].body);
expect(body.tokens).toHaveProperty("color-bg");
expect(body.tokens["color-bg"]).toBe("#f5f0e8");
expect(body.name).toBe("Neo");
expect(body.colorScheme).toBe("light");
});
it("sends empty tokens for blank preset", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ uri: "at://...", cid: "bafy" }, true, 201)
);
const routes = await loadAdminRoutes();
await routes.request("/admin/themes", {
method: "POST",
headers: {
cookie: "atbb_session=token",
"content-type": "application/x-www-form-urlencoded",
},
body: "name=Blank+Theme&colorScheme=light&preset=blank",
});
const apiCall = mockFetch.mock.calls[2];
const body = JSON.parse(apiCall[1].body);
expect(body.tokens).toEqual({});
});
it("redirects with error when name is missing", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes", {
method: "POST",
headers: {
cookie: "atbb_session=token",
"content-type": "application/x-www-form-urlencoded",
},
body: "colorScheme=light&preset=blank",
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/admin/themes?error=");
expect(res.headers.get("location")).toContain("required");
});
it("redirects with error on AppView API failure", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ error: "Theme creation failed" }, false, 500)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes", {
method: "POST",
headers: {
cookie: "atbb_session=token",
"content-type": "application/x-www-form-urlencoded",
},
body: "name=My+Theme&colorScheme=light&preset=blank",
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/admin/themes?error=");
});
});
Step 2: Run tests to verify they fail#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "POST /admin/themes"
Expected: 5 failures.
Step 3: Implement POST /admin/themes in apps/web/src/routes/admin.tsx#
Add after the GET /admin/themes handler:
app.post("/admin/themes", 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 cookie = c.req.header("cookie") ?? "";
let body: Record<string, string | File>;
try {
body = await c.req.parseBody();
} catch (error) {
if (isProgrammingError(error)) throw error;
return c.redirect(
`/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`,
302
);
}
const name = typeof body.name === "string" ? body.name.trim() : "";
const colorScheme = typeof body.colorScheme === "string" ? body.colorScheme : "light";
const preset = typeof body.preset === "string" ? body.preset : "blank";
if (!name) {
return c.redirect(
`/admin/themes?error=${encodeURIComponent("Theme name is required.")}`,
302
);
}
const tokens = THEME_PRESETS[preset] ?? {};
let apiRes: Response;
try {
apiRes = await fetch(`${appviewUrl}/api/admin/themes`, {
method: "POST",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({ name, colorScheme, tokens }),
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error creating theme", {
operation: "POST /admin/themes",
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
302
);
}
if (!apiRes.ok) {
const msg = await extractAppviewError(apiRes, "Failed to create theme. Please try again.");
return c.redirect(
`/admin/themes?error=${encodeURIComponent(msg)}`,
302
);
}
return c.redirect("/admin/themes", 302);
});
Step 4: Run tests to verify they pass#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "POST /admin/themes"
Expected: 5 passing.
Step 5: Commit#
git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "$(cat <<'EOF'
feat(web): POST /admin/themes — create theme from preset and redirect (ATB-58)
EOF
)"
Task 6: Web — POST /admin/themes/:rkey/duplicate#
Files:
- Modify:
apps/web/src/routes/admin.tsx - Modify:
apps/web/src/routes/__tests__/admin.test.tsx
Step 1: Write the failing tests#
describe("createAdminRoutes — POST /admin/themes/:rkey/duplicate", () => {
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, json: () => Promise.resolve(body) };
}
function setupAuthenticatedSession(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
async function loadAdminRoutes() {
const { createAdminRoutes } = await import("../admin.js");
return createAdminRoutes("http://localhost:3000");
}
it("duplicates theme and redirects to /admin/themes on success", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(
mockResponse(
{ uri: "at://...", rkey: "newrkey", name: "Neobrutal Light (Copy)" },
true,
201
)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes/3lbltheme1aa/duplicate", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/themes");
});
it("redirects with error on AppView failure", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ error: "Theme not found" }, false, 404)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes/nonexistent/duplicate", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/admin/themes?error=");
});
});
Step 2: Run tests to verify they fail#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "duplicate"
Step 3: Implement#
Add after POST /admin/themes:
app.post("/admin/themes/:rkey/duplicate", 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 cookie = c.req.header("cookie") ?? "";
const themeRkey = c.req.param("rkey");
let apiRes: Response;
try {
apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}/duplicate`, {
method: "POST",
headers: { Cookie: cookie },
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error duplicating theme", {
operation: "POST /admin/themes/:rkey/duplicate",
themeRkey,
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
302
);
}
if (!apiRes.ok) {
const msg = await extractAppviewError(apiRes, "Failed to duplicate theme. Please try again.");
return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302);
}
return c.redirect("/admin/themes", 302);
});
Step 4: Run tests to verify they pass#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "duplicate"
Step 5: Commit#
git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "$(cat <<'EOF'
feat(web): POST /admin/themes/:rkey/duplicate — proxy duplicate to AppView (ATB-58)
EOF
)"
Task 7: Web — POST /admin/themes/:rkey/delete#
Files:
- Modify:
apps/web/src/routes/admin.tsx - Modify:
apps/web/src/routes/__tests__/admin.test.tsx
Step 1: Write the failing tests#
describe("createAdminRoutes — POST /admin/themes/:rkey/delete", () => {
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, json: () => Promise.resolve(body) };
}
function setupAuthenticatedSession(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
async function loadAdminRoutes() {
const { createAdminRoutes } = await import("../admin.js");
return createAdminRoutes("http://localhost:3000");
}
it("deletes theme and redirects to /admin/themes on success", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(mockResponse({ deleted: true }, true, 200));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes/3lbltheme1aa/delete", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/themes");
});
it("redirects with human-friendly error message on 409 conflict (theme is a default)", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(
mockResponse(
{ error: "Cannot delete a theme that is currently set as a default" },
false,
409
)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes/3lbltheme1aa/delete", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
const location = res.headers.get("location") ?? "";
expect(location).toContain("/admin/themes?error=");
expect(decodeURIComponent(location)).toContain("Cannot delete");
});
it("redirects with error on generic AppView failure", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ error: "Internal server error" }, false, 500)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/themes/3lbltheme1aa/delete", {
method: "POST",
headers: { cookie: "atbb_session=token" },
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/admin/themes?error=");
});
});
Step 2: Run tests to verify they fail#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "delete"
Step 3: Implement#
Add after POST /admin/themes/:rkey/duplicate:
app.post("/admin/themes/:rkey/delete", 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 cookie = c.req.header("cookie") ?? "";
const themeRkey = c.req.param("rkey");
let apiRes: Response;
try {
apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, {
method: "DELETE",
headers: { Cookie: cookie },
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error deleting theme", {
operation: "POST /admin/themes/:rkey/delete",
themeRkey,
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
302
);
}
if (!apiRes.ok) {
const msg = await extractAppviewError(apiRes, "Failed to delete theme. Please try again.");
return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302);
}
return c.redirect("/admin/themes", 302);
});
Step 4: Run tests to verify they pass#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "delete"
Step 5: Commit#
git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "$(cat <<'EOF'
feat(web): POST /admin/themes/:rkey/delete — proxy delete to AppView with 409 handling (ATB-58)
EOF
)"
Task 8: Web — POST /admin/theme-policy#
The trickiest route: parses a checkbox (absent = false) and multi-value field (availableThemes).
Files:
- Modify:
apps/web/src/routes/admin.tsx - Modify:
apps/web/src/routes/__tests__/admin.test.tsx
Step 1: Write the failing tests#
describe("createAdminRoutes — POST /admin/theme-policy", () => {
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, json: () => Promise.resolve(body) };
}
function setupAuthenticatedSession(permissions: string[]) {
mockFetch.mockResolvedValueOnce(
mockResponse({ authenticated: true, did: "did:plc:user", handle: "alice.bsky.social" })
);
mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
}
async function loadAdminRoutes() {
const { createAdminRoutes } = await import("../admin.js");
return createAdminRoutes("http://localhost:3000");
}
it("saves policy and redirects to /admin/themes on success", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200));
const routes = await loadAdminRoutes();
const body = new URLSearchParams({
defaultLightThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1",
defaultDarkThemeUri: "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2",
allowUserChoice: "on",
});
body.append("availableThemes", "at://did:plc:forum/space.atbb.forum.theme/3lbltheme1");
body.append("availableThemes", "at://did:plc:forum/space.atbb.forum.theme/3lbltheme2");
const res = await routes.request("/admin/theme-policy", {
method: "POST",
headers: {
cookie: "atbb_session=token",
"content-type": "application/x-www-form-urlencoded",
},
body: body.toString(),
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toBe("/admin/themes");
const apiCall = mockFetch.mock.calls[2];
const sentBody = JSON.parse(apiCall[1].body);
expect(sentBody.allowUserChoice).toBe(true);
expect(sentBody.availableThemes).toHaveLength(2);
});
it("treats absent allowUserChoice checkbox as false", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200));
const routes = await loadAdminRoutes();
// No allowUserChoice field — checkbox was unchecked
const res = await routes.request("/admin/theme-policy", {
method: "POST",
headers: {
cookie: "atbb_session=token",
"content-type": "application/x-www-form-urlencoded",
},
body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test",
});
expect(res.status).toBe(302);
const apiCall = mockFetch.mock.calls[2];
const sentBody = JSON.parse(apiCall[1].body);
expect(sentBody.allowUserChoice).toBe(false);
});
it("sends empty availableThemes when no checkboxes are checked", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(mockResponse({ updated: true }, true, 200));
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/theme-policy", {
method: "POST",
headers: {
cookie: "atbb_session=token",
"content-type": "application/x-www-form-urlencoded",
},
body: "defaultLightThemeUri=at://test&defaultDarkThemeUri=at://test&allowUserChoice=on",
});
expect(res.status).toBe(302);
const apiCall = mockFetch.mock.calls[2];
const sentBody = JSON.parse(apiCall[1].body);
expect(sentBody.availableThemes).toEqual([]);
});
it("redirects with error on AppView failure", async () => {
setupAuthenticatedSession(["space.atbb.permission.manageThemes"]);
mockFetch.mockResolvedValueOnce(
mockResponse({ error: "Invalid theme URIs" }, false, 400)
);
const routes = await loadAdminRoutes();
const res = await routes.request("/admin/theme-policy", {
method: "POST",
headers: {
cookie: "atbb_session=token",
"content-type": "application/x-www-form-urlencoded",
},
body: "defaultLightThemeUri=bad&defaultDarkThemeUri=bad",
});
expect(res.status).toBe(302);
expect(res.headers.get("location")).toContain("/admin/themes?error=");
});
});
Step 2: Run tests to verify they fail#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "theme-policy"
Step 3: Implement#
Add after POST /admin/themes/:rkey/delete:
app.post("/admin/theme-policy", 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 cookie = c.req.header("cookie") ?? "";
let rawBody: Record<string, string | string[] | File | File[]>;
try {
rawBody = await c.req.parseBody({ all: true });
} catch (error) {
if (isProgrammingError(error)) throw error;
return c.redirect(
`/admin/themes?error=${encodeURIComponent("Invalid form submission.")}`,
302
);
}
const defaultLightThemeUri =
typeof rawBody.defaultLightThemeUri === "string" ? rawBody.defaultLightThemeUri : "";
const defaultDarkThemeUri =
typeof rawBody.defaultDarkThemeUri === "string" ? rawBody.defaultDarkThemeUri : "";
// Checkbox: present with value "on" when checked, absent when unchecked
const allowUserChoice = rawBody.allowUserChoice === "on";
// availableThemes may be a single string, an array, or absent
const rawAvailable = rawBody.availableThemes;
const availableThemes =
rawAvailable === undefined
? []
: Array.isArray(rawAvailable)
? rawAvailable.filter((v): v is string => typeof v === "string")
: typeof rawAvailable === "string"
? [rawAvailable]
: [];
let apiRes: Response;
try {
apiRes = await fetch(`${appviewUrl}/api/admin/theme-policy`, {
method: "PUT",
headers: { "Content-Type": "application/json", Cookie: cookie },
body: JSON.stringify({
defaultLightThemeUri,
defaultDarkThemeUri,
allowUserChoice,
availableThemes: availableThemes.map((uri) => ({ uri })),
}),
});
} catch (error) {
if (isProgrammingError(error)) throw error;
logger.error("Network error updating theme policy", {
operation: "POST /admin/theme-policy",
error: error instanceof Error ? error.message : String(error),
});
return c.redirect(
`/admin/themes?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
302
);
}
if (!apiRes.ok) {
const msg = await extractAppviewError(apiRes, "Failed to update theme policy. Please try again.");
return c.redirect(`/admin/themes?error=${encodeURIComponent(msg)}`, 302);
}
return c.redirect("/admin/themes", 302);
});
Note on availableThemes format: The PUT /api/admin/theme-policy AppView endpoint expects availableThemes as an array of objects { uri, cid }. However, the web layer doesn't have the CIDs (they live in the DB). Check the AppView's PUT /api/admin/theme-policy handler to see if cid is required or if it can be omitted/empty. If CID is required, the GET page will need to pass CIDs as hidden inputs alongside the checkboxes.
Step 4: Run tests to verify they pass#
pnpm --filter @atbb/web exec vitest run --reporter verbose 2>&1 | grep -A5 "theme-policy"
Step 5: Commit#
git add apps/web/src/routes/admin.tsx apps/web/src/routes/__tests__/admin.test.tsx
git commit -m "$(cat <<'EOF'
feat(web): POST /admin/theme-policy — update theme policy with availability and defaults (ATB-58)
EOF
)"
Task 9: Final Verification + Linear Update#
Step 1: Run full test suite#
PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm turbo test
Expected: all tests pass.
Step 2: Run lint:fix#
PATH=/path/to/repo/.devenv/profile/bin:/bin:/usr/bin:$PATH pnpm turbo lint:fix
Step 3: Update Linear#
Mark ATB-58 status → "In Review" and add a comment documenting:
- New AppView endpoints:
GET /api/admin/themes,POST /api/admin/themes/:rkey/duplicate - New web routes:
GET /admin/themes+ 4 POST routes - Session:
canManageThemesadded - Known gap: Edit button renders disabled (links to ATB-59 editor not yet built)
- Known gap: CID handling in theme-policy — verify AppView accepts
{ uri }without CID
Step 4: Check availableThemes CID requirement#
Read apps/appview/src/routes/admin.ts around the PUT /theme-policy handler (line ~1257) to check whether CID is required in availableThemes. If it is:
-
In the GET /admin/themes page JSX, add hidden inputs alongside each availability checkbox:
<input type="hidden" form="policy-form" name={`cid_${theme.uri}`} value={/* need CID */} />But the AdminThemeEntry doesn't include CID — it would need to come from the policy's
availableThemesarray for already-available themes. This might require a design adjustment. -
Alternatively: the AppView can look up CIDs itself when writing the policy (it has the DB). In that case, the web layer can send just
{ uri }and the AppView fills in the CID.
Resolve this before declaring done.
Known Limitations (for follow-up issues)#
- Edit button disabled:
/admin/themes/:rkeyeditor page is ATB-59 (not yet built). The Edit button renders as a non-functional span witharia-disabled="true". - Missing presets: Clean Light, Clean Dark, Classic BB presets not yet created. The Create form offers only Neobrutal Light, Neobrutal Dark, Blank.
- Bruno collection: Update
bruno/with the newGET /api/admin/themesandPOST /api/admin/themes/:rkey/duplicateendpoints in the same branch before requesting review.