WIP! A BB-style forum, on the ATmosphere! We're still working... we'll be back soon when we have something to show off!
node typescript hono htmx atproto
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

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.ts lines 983–1059 (POST /api/admin/themes — exact pattern to follow)
  • apps/appview/src/routes/__tests__/admin.test.ts lines 1–80 (test setup: mock structure, createTestContext, describe.sequential)
  • apps/web/src/routes/admin.tsx lines 1–210 (imports, types, helper functions, createAdminRoutes signature)
  • apps/web/src/routes/__tests__/admin.test.tsx lines 1–120 (setupAuthenticatedSession, mockFetch pattern, loadAdminRoutes)
  • apps/web/src/lib/session.ts lines 155–220 (ADMIN_PERMISSIONS array, canManageRoles — the pattern to clone for canManageThemes)

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&apos;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 &quot;{theme.name}&quot;? 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: canManageThemes added
  • 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:

  1. 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 availableThemes array for already-available themes. This might require a design adjustment.

  2. 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/:rkey editor page is ATB-59 (not yet built). The Edit button renders as a non-functional span with aria-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 new GET /api/admin/themes and POST /api/admin/themes/:rkey/duplicate endpoints in the same branch before requesting review.