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

ATB-59 Theme Token Editor — Implementation Plan#

For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

Goal: Build the admin theme token editor at GET /admin/themes/:rkey with HTMX live preview, save to PDS, and preset reset.

Architecture: Extract all theme-admin route handlers from admin.tsx into a new admin-themes.tsx module, then add the editor page, HTMX preview endpoint, save handler, and reset-to-preset handler. The preview endpoint is web-server-only (calls tokensToCss(), never touches AppView), with tokens scoped to .preview-pane-inner so the editor UI doesn't update live.

Tech Stack: Hono, Hono JSX, HTMX, Vitest, TypeScript. No new dependencies.


Before You Start#

Run the test suite first to establish a baseline:

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test 2>&1 | tail -20

Expected: all tests pass. If not, stop and investigate before proceeding.

Key files:

  • apps/web/src/routes/admin.tsx — source of truth for theme handlers to extract (lines 1491–1992)
  • apps/web/src/routes/__tests__/admin.test.tsx — existing tests; do NOT break them
  • apps/web/src/lib/theme.tstokensToCss() utility
  • apps/web/src/styles/presets/neobrutal-light.json — 46-token preset (light)
  • apps/web/src/styles/presets/neobrutal-dark.json — 46-token preset (dark)
  • apps/web/public/static/css/theme.css — CSS class reference for preview HTML

Token names (all 46, from preset JSON):

COLOR:      color-bg, color-surface, color-text, color-text-muted, color-primary,
            color-primary-hover, color-secondary, color-border, color-shadow,
            color-success, color-warning, color-danger, color-code-bg, color-code-text
TYPOGRAPHY: font-body, font-heading, font-mono, font-size-base, font-size-sm,
            font-size-xs, font-size-lg, font-size-xl, font-size-2xl,
            font-weight-normal, font-weight-bold, line-height-body, line-height-heading
SPACING:    space-xs, space-sm, space-md, space-lg, space-xl,
            radius, border-width, shadow-offset, content-width
COMPONENTS: button-radius, button-shadow, card-radius, card-shadow,
            btn-press-hover, btn-press-active, input-radius, input-border, nav-height

Task 1: Create admin-themes.tsx with extracted handlers#

Files:

  • Create: apps/web/src/routes/admin-themes.tsx

This task moves existing code. No new functionality yet. The file will:

  1. Re-define the extractAppviewError helper (it stays in admin.tsx too, for structure routes)
  2. Re-define AdminThemeEntry, ThemePolicy types (only used by theme handlers)
  3. Move THEME_PRESETS constant and the two JSON imports
  4. Export createAdminThemeRoutes(appviewUrl: string) factory containing all 5 existing handlers

Step 1: Create the file

apps/web/src/routes/admin-themes.tsx:

import { Hono } from "hono";
import { BaseLayout } from "../layouts/base.js";
import { PageHeader, EmptyState } from "../components/index.js";
import {
  getSessionWithPermissions,
  canManageThemes,
} from "../lib/session.js";
import { isProgrammingError } from "../lib/errors.js";
import { logger } from "../lib/logger.js";
import { tokensToCss } from "../lib/theme.js";
import neobrutalLight from "../styles/presets/neobrutal-light.json" assert { type: "json" };
import neobrutalDark from "../styles/presets/neobrutal-dark.json" assert { type: "json" };

// ─── Types ─────────────────────────────────────────────────────────────────

interface AdminThemeEntry {
  id: string;
  uri: string;
  name: string;
  colorScheme: string;
  tokens: Record<string, string>;
  cssOverrides: string | null;
  fontUrls: string[] | null;
  createdAt: string;
  indexedAt: string;
}

interface ThemePolicy {
  defaultLightThemeUri: string | null;
  defaultDarkThemeUri: string | null;
  allowUserChoice: boolean;
  availableThemes: Array<{ uri: string; cid: string }>;
}

// ─── Constants ──────────────────────────────────────────────────────────────

const THEME_PRESETS: Record<string, Record<string, string>> = {
  "neobrutal-light": neobrutalLight as Record<string, string>,
  "neobrutal-dark": neobrutalDark as Record<string, string>,
  "blank": {},
};

const COLOR_TOKENS = [
  "color-bg", "color-surface", "color-text", "color-text-muted",
  "color-primary", "color-primary-hover", "color-secondary", "color-border",
  "color-shadow", "color-success", "color-warning", "color-danger",
  "color-code-bg", "color-code-text",
] as const;

const TYPOGRAPHY_TOKENS = [
  "font-body", "font-heading", "font-mono",
  "font-size-base", "font-size-sm", "font-size-xs", "font-size-lg",
  "font-size-xl", "font-size-2xl",
  "font-weight-normal", "font-weight-bold",
  "line-height-body", "line-height-heading",
] as const;

const SPACING_TOKENS = [
  "space-xs", "space-sm", "space-md", "space-lg", "space-xl",
  "radius", "border-width", "shadow-offset", "content-width",
] as const;

const COMPONENT_TOKENS = [
  "button-radius", "button-shadow",
  "card-radius", "card-shadow",
  "btn-press-hover", "btn-press-active",
  "input-radius", "input-border",
  "nav-height",
] as const;

const ALL_KNOWN_TOKENS: readonly string[] = [
  ...COLOR_TOKENS, ...TYPOGRAPHY_TOKENS, ...SPACING_TOKENS, ...COMPONENT_TOKENS,
];

// ─── Helpers ────────────────────────────────────────────────────────────────

async function extractAppviewError(res: Response, fallback: string): Promise<string> {
  try {
    const data = (await res.json()) as { error?: string };
    return data.error ?? fallback;
  } catch {
    return fallback;
  }
}

/** Drop token values that could break the CSS style block. */
function sanitizeTokenValue(value: unknown): string | null {
  if (typeof value !== "string") return null;
  if (value.includes("<") || value.includes(";") || value.includes("</")) return null;
  return value;
}

// ─── Components ─────────────────────────────────────────────────────────────

function ColorTokenInput({ name, value }: { name: string; value: string }) {
  const safeValue =
    !value.startsWith("var(") && !value.includes(";") && !value.includes("<")
      ? value
      : "#cccccc";
  return (
    <div class="token-input token-input--color">
      <label for={`token-${name}`}>{name}</label>
      <div class="token-input__controls">
        <input
          type="color"
          value={safeValue}
          aria-label={`${name} color picker`}
          oninput="this.nextElementSibling.value=this.value;this.nextElementSibling.dispatchEvent(new Event('change',{bubbles:true}))"
        />
        <input
          type="text"
          id={`token-${name}`}
          name={name}
          value={safeValue}
          oninput="if(/^#[0-9a-fA-F]{6}$/.test(this.value))this.previousElementSibling.value=this.value"
        />
      </div>
    </div>
  );
}

function TextTokenInput({ name, value }: { name: string; value: string }) {
  return (
    <div class="token-input">
      <label for={`token-${name}`}>{name}</label>
      <input type="text" id={`token-${name}`} name={name} value={value} />
    </div>
  );
}

function TokenFieldset({
  legend,
  tokens,
  effectiveTokens,
  isColor,
}: {
  legend: string;
  tokens: readonly string[];
  effectiveTokens: Record<string, string>;
  isColor: boolean;
}) {
  return (
    <fieldset class="token-group">
      <legend>{legend}</legend>
      {tokens.map((name) =>
        isColor ? (
          <ColorTokenInput name={name} value={effectiveTokens[name] ?? ""} />
        ) : (
          <TextTokenInput name={name} value={effectiveTokens[name] ?? ""} />
        )
      )}
    </fieldset>
  );
}

function ThemePreviewContent({ tokens }: { tokens: Record<string, string> }) {
  const css = tokensToCss(tokens);
  return (
    <>
      <style>{`.preview-pane-inner{${css}}`}</style>
      <div class="preview-pane-inner">
        <div
          style="background:var(--color-surface);border-bottom:var(--border-width) solid var(--color-border);padding:var(--space-sm) var(--space-md);display:flex;align-items:center;font-family:var(--font-heading);font-weight:var(--font-weight-bold);font-size:var(--font-size-lg);color:var(--color-text);"
          role="navigation"
          aria-label="Preview navigation"
        >
          atBB Forum Preview
        </div>
        <div style="padding:var(--space-md);">
          <div
            style="background:var(--color-surface);border:var(--border-width) solid var(--color-border);border-radius:var(--card-radius);box-shadow:var(--card-shadow);padding:var(--space-md);margin-bottom:var(--space-md);"
          >
            <h2
              style="font-family:var(--font-heading);font-size:var(--font-size-xl);font-weight:var(--font-weight-bold);line-height:var(--line-height-heading);color:var(--color-text);margin:0 0 var(--space-sm) 0;"
            >
              Sample Thread Title
            </h2>
            <p style="font-family:var(--font-body);font-size:var(--font-size-base);line-height:var(--line-height-body);color:var(--color-text);margin:0 0 var(--space-md) 0;">
              Body text showing font, color, and spacing at work.{" "}
              <a href="#" style="color:var(--color-primary);">A sample link</a>
            </p>
            <pre
              style="font-family:var(--font-mono);font-size:var(--font-size-sm);background:var(--color-code-bg);color:var(--color-code-text);padding:var(--space-sm) var(--space-md);border-radius:var(--radius);margin:0 0 var(--space-md) 0;overflow-x:auto;"
            >
              {`const greeting = "hello forum";`}
            </pre>
            <input
              type="text"
              placeholder="Reply…"
              style="font-family:var(--font-body);font-size:var(--font-size-base);border:var(--input-border);border-radius:var(--input-radius);padding:var(--space-sm) var(--space-md);width:100%;box-sizing:border-box;background:var(--color-bg);color:var(--color-text);margin-bottom:var(--space-sm);"
            />
            <div style="display:flex;gap:var(--space-sm);flex-wrap:wrap;">
              <button
                type="button"
                style="background:var(--color-primary);color:var(--color-surface);border:var(--border-width) solid var(--color-border);border-radius:var(--button-radius);box-shadow:var(--button-shadow);font-family:var(--font-body);font-weight:var(--font-weight-bold);padding:var(--space-sm) var(--space-md);cursor:pointer;"
              >
                Post Reply
              </button>
              <button
                type="button"
                style="background:var(--color-surface);color:var(--color-text);border:var(--border-width) solid var(--color-border);border-radius:var(--button-radius);box-shadow:var(--button-shadow);font-family:var(--font-body);font-weight:var(--font-weight-bold);padding:var(--space-sm) var(--space-md);cursor:pointer;"
              >
                Cancel
              </button>
              <span
                style="display:inline-block;background:var(--color-success);color:var(--color-surface);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);"
              >
                success
              </span>
              <span
                style="display:inline-block;background:var(--color-warning);color:var(--color-text);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);"
              >
                warning
              </span>
              <span
                style="display:inline-block;background:var(--color-danger);color:var(--color-surface);border:var(--border-width) solid var(--color-border);padding:0 var(--space-sm);font-size:var(--font-size-sm);"
              >
                danger
              </span>
            </div>
          </div>
        </div>
      </div>
    </>
  );
}

// ─── Route factory ──────────────────────────────────────────────────────────

export function createAdminThemeRoutes(appviewUrl: string) {
  const app = new Hono();

  // ── GET /admin/themes ──────────────────────────────────────────────────────
  // PASTE the GET /admin/themes handler from admin.tsx here (lines 1493–1771)
  // Change app.get to app.get (it's already on app in admin.tsx)
  // Update all fetch URLs to use appviewUrl parameter instead of the module-level variable

  // ── POST /admin/themes ─────────────────────────────────────────────────────
  // PASTE the POST /admin/themes handler from admin.tsx here (lines 1773–1836)

  // ── POST /admin/themes/:rkey/duplicate ─────────────────────────────────────
  // PASTE the handler from admin.tsx here (lines 1838–1875)

  // ── POST /admin/themes/:rkey/delete ────────────────────────────────────────
  // PASTE the handler from admin.tsx here (lines 1877–1920)

  // ── POST /admin/theme-policy ───────────────────────────────────────────────
  // PASTE the handler from admin.tsx here (lines 1922–1992)

  return app;
}

Step 2: Fill in the extracted handlers

Open apps/web/src/routes/admin.tsx. Copy the body of each theme handler (lines 1493–1992) into the corresponding slot in admin-themes.tsx. Important differences:

  • In admin.tsx, the handlers reference the module-level appviewUrl variable. In the new factory, use the appviewUrl parameter instead.
  • The imports already exist in the new file (logger, isProgrammingError, etc.).
  • Remove the // PASTE... placeholder comments as you fill each one in.

Step 3: Run tests to verify no regressions

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test 2>&1 | tail -20

Expected: same pass count as baseline. (The admin-themes tests don't exist yet — that's OK.)

Step 4: Commit

git add apps/web/src/routes/admin-themes.tsx
git commit -m "refactor(web): extract theme admin handlers into admin-themes.tsx (ATB-59)"

Task 2: Update admin.tsx to delegate to admin-themes.tsx#

Files:

  • Modify: apps/web/src/routes/admin.tsx

Step 1: Add import at the top of admin.tsx

After the existing imports, add:

import { createAdminThemeRoutes } from "./admin-themes.js";

Also remove (now unused) imports from admin.tsx:

  • neobrutalLight and neobrutalDark JSON imports (lines 15–16 — only used by THEME_PRESETS)

Step 2: Mount the theme routes

Just before return app; at the end of createAdminRoutes (around line 1993), add:

  app.route("/", createAdminThemeRoutes(appviewUrl));

Step 3: Remove the extracted code from admin.tsx

Delete lines 1491–1992 (the // ─── Themes ─── section and all theme handler blocks). Leave return app; in place.

Also delete from admin.tsx:

  • AdminThemeEntry interface (lines 65–75)
  • ThemePolicy interface (lines 77–82)
  • THEME_PRESETS constant and JSON imports (lines 15–16, 84–89)

Step 4: Run tests — must still pass

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test 2>&1 | tail -20

All existing tests must pass. The theme list routes are now handled by admin-themes.tsx but mounted at the same paths.

Step 5: Commit

git add apps/web/src/routes/admin.tsx
git commit -m "refactor(web): mount admin-themes routes, remove extracted code from admin.tsx (ATB-59)"

Task 3: Write tests for GET /admin/themes/:rkey (TDD)#

Files:

  • Create: apps/web/src/routes/__tests__/admin-themes.test.tsx

Step 1: Create the test file with scaffolding

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const mockFetch = vi.fn();

describe("createAdminThemeRoutes — GET /admin/themes/:rkey", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok,
      status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  /** Session check: 2 fetches — /api/auth/session, then /api/admin/members/me */
  function setupAuth(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  const MANAGE_THEMES = "space.atbb.permission.manageThemes";

  const sampleTheme = {
    id: "1",
    uri: "at://did:plc:forum/space.atbb.forum.theme/abc123",
    name: "My Theme",
    colorScheme: "light",
    tokens: { "color-bg": "#f5f0e8", "color-text": "#1a1a1a" },
    cssOverrides: null,
    fontUrls: null,
    createdAt: "2026-01-01T00:00:00.000Z",
    indexedAt: "2026-01-01T00:00:00.000Z",
  };

  async function loadThemeRoutes() {
    const { createAdminThemeRoutes } = await import("../admin-themes.js");
    return createAdminThemeRoutes("http://localhost:3000");
  }

  it("redirects unauthenticated users to /login", async () => {
    // No session cookie — no fetch calls made
    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123");
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns 403 for users without manageThemes permission", async () => {
    setupAuth([]);
    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(403);
    const html = await res.text();
    expect(html).toContain("Access Denied");
  });

  it("returns 404 when theme not found (AppView returns 404)", async () => {
    setupAuth([MANAGE_THEMES]);
    mockFetch.mockResolvedValueOnce(mockResponse({ error: "Theme not found" }, false, 404));

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/notexist", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(404);
  });

  it("renders editor with theme name, colorScheme, and token inputs", async () => {
    setupAuth([MANAGE_THEMES]);
    mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme));

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123", {
      headers: { cookie: "atbb_session=token" },
    });
    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("My Theme");
    expect(html).toContain('value="light"');
    expect(html).toContain("#f5f0e8");   // color-bg token
    expect(html).toContain("#1a1a1a");   // color-text token
    expect(html).toContain('name="color-bg"');
  });

  it("shows success banner when ?success=1 is present", async () => {
    setupAuth([MANAGE_THEMES]);
    mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme));

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123?success=1", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("saved");   // some form of success text
  });

  it("shows error banner when ?error=<msg> is present", async () => {
    setupAuth([MANAGE_THEMES]);
    mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme));

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123?error=Something+went+wrong", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("Something went wrong");
  });

  it("uses preset tokens when ?preset=neobrutal-light is present", async () => {
    setupAuth([MANAGE_THEMES]);
    // Theme has no tokens; preset should override
    const emptyTheme = { ...sampleTheme, tokens: {} };
    mockFetch.mockResolvedValueOnce(mockResponse(emptyTheme));

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123?preset=neobrutal-light", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    // neobrutal-light has color-bg: #f5f0e8
    expect(html).toContain("#f5f0e8");
  });

  it("renders CSS overrides field as disabled (awaiting ATB-62)", async () => {
    setupAuth([MANAGE_THEMES]);
    mockFetch.mockResolvedValueOnce(mockResponse(sampleTheme));

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123", {
      headers: { cookie: "atbb_session=token" },
    });
    const html = await res.text();
    expect(html).toContain("css-overrides");
    expect(html).toContain("disabled");
  });
});

Step 2: Run the tests to verify they fail

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "admin-themes"

Expected: All new tests fail with "createAdminThemeRoutes has no route matching GET /admin/themes/:rkey".


Task 4: Implement GET /admin/themes/:rkey#

Files:

  • Modify: apps/web/src/routes/admin-themes.tsx

Step 1: Add the route handler inside createAdminThemeRoutes

Add this after the existing POST /admin/theme-policy handler, before return app;:

  // ── GET /admin/themes/:rkey ────────────────────────────────────────────────

  app.get("/admin/themes/:rkey", async (c) => {
    const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
    if (!auth.authenticated) return c.redirect("/login");
    if (!canManageThemes(auth)) {
      return c.html(
        <BaseLayout title="Access Denied — atBB Forum" auth={auth}>
          <PageHeader title="Access Denied" />
          <p>You don&apos;t have permission to manage themes.</p>
        </BaseLayout>,
        403
      );
    }

    const themeRkey = c.req.param("rkey");
    const cookie = c.req.header("cookie") ?? "";
    const presetParam = c.req.query("preset") ?? null;
    const successMsg = c.req.query("success") === "1" ? "Theme saved successfully." : null;
    const errorMsg = c.req.query("error") ?? null;

    // Fetch theme from AppView
    let theme: AdminThemeEntry | null = null;
    try {
      const res = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, {
        headers: { Cookie: cookie },
      });
      if (res.status === 404) {
        return c.html(
          <BaseLayout title="Theme Not Found — atBB Admin" auth={auth}>
            <PageHeader title="Theme Not Found" />
            <p>This theme does not exist.</p>
            <a href="/admin/themes" class="btn btn-secondary"> Back to themes</a>
          </BaseLayout>,
          404
        );
      }
      if (res.ok) {
        try {
          theme = (await res.json()) as AdminThemeEntry;
        } catch {
          logger.error("Failed to parse theme response", {
            operation: "GET /admin/themes/:rkey",
            themeRkey,
          });
        }
      } else {
        logger.error("AppView returned error loading theme", {
          operation: "GET /admin/themes/:rkey",
          themeRkey,
          status: res.status,
        });
      }
    } catch (error) {
      if (isProgrammingError(error)) throw error;
      logger.error("Network error loading theme", {
        operation: "GET /admin/themes/:rkey",
        themeRkey,
        error: error instanceof Error ? error.message : String(error),
      });
    }

    if (!theme) {
      return c.html(
        <BaseLayout title="Theme Unavailable — atBB Admin" auth={auth}>
          <PageHeader title="Theme Unavailable" />
          <p>Unable to load theme data. Please try again.</p>
          <a href="/admin/themes" class="btn btn-secondary"> Back to themes</a>
        </BaseLayout>,
        500
      );
    }

    // If ?preset is set, override DB tokens with preset tokens
    const presetTokens = presetParam ? (THEME_PRESETS[presetParam] ?? null) : null;
    const effectiveTokens: Record<string, string> = presetTokens
      ? { ...theme.tokens, ...presetTokens }
      : { ...theme.tokens };

    const fontUrlsText = (theme.fontUrls ?? []).join("\n");

    return c.html(
      <BaseLayout title={`Edit Theme: ${theme.name} — atBB Admin`} auth={auth}>
        <PageHeader title={`Edit Theme: ${theme.name}`} />

        {successMsg && <div class="structure-success-banner">{successMsg}</div>}
        {errorMsg && <div class="structure-error-banner">{errorMsg}</div>}

        <a href="/admin/themes" class="btn btn-secondary btn-sm" style="margin-bottom: var(--space-md); display: inline-block;">
           Back to themes
        </a>

        {/* Metadata + tokens form */}
        <form
          id="editor-form"
          method="post"
          action={`/admin/themes/${themeRkey}/save`}
          class="theme-editor"
        >
          {/* Metadata */}
          <fieldset class="token-group">
            <legend>Theme Metadata</legend>
            <div class="token-input">
              <label for="theme-name">Name</label>
              <input type="text" id="theme-name" name="name" value={theme.name} required />
            </div>
            <div class="token-input">
              <label for="theme-scheme">Color Scheme</label>
              <select id="theme-scheme" name="colorScheme">
                <option value="light" selected={theme.colorScheme === "light"}>Light</option>
                <option value="dark" selected={theme.colorScheme === "dark"}>Dark</option>
              </select>
            </div>
            <div class="token-input">
              <label for="theme-font-urls">Font URLs (one per line)</label>
              <textarea id="theme-font-urls" name="fontUrls" rows={3} placeholder="https://fonts.googleapis.com/css2?family=...">
                {fontUrlsText}
              </textarea>
            </div>
          </fieldset>

          {/* Token editor + live preview layout */}
          <div class="theme-editor__layout">
            {/* Left: token controls */}
            <div
              class="theme-editor__controls"
              hx-post={`/admin/themes/${themeRkey}/preview`}
              hx-trigger="input delay:500ms"
              hx-target="#preview-pane"
              hx-include="#editor-form"
            >
              <TokenFieldset
                legend="Colors"
                tokens={COLOR_TOKENS}
                effectiveTokens={effectiveTokens}
                isColor={true}
              />
              <TokenFieldset
                legend="Typography"
                tokens={TYPOGRAPHY_TOKENS}
                effectiveTokens={effectiveTokens}
                isColor={false}
              />
              <TokenFieldset
                legend="Spacing & Layout"
                tokens={SPACING_TOKENS}
                effectiveTokens={effectiveTokens}
                isColor={false}
              />
              <TokenFieldset
                legend="Components"
                tokens={COMPONENT_TOKENS}
                effectiveTokens={effectiveTokens}
                isColor={false}
              />

              {/* CSS overrides — disabled until ATB-62 */}
              <fieldset class="token-group">
                <legend>CSS Overrides</legend>
                <div class="token-input">
                  <label for="css-overrides">
                    Custom CSS{" "}
                    <span class="form-hint">(disabled  CSS sanitization not yet implemented)</span>
                  </label>
                  <textarea
                    id="css-overrides"
                    name="cssOverrides"
                    rows={6}
                    disabled
                    aria-describedby="css-overrides-hint"
                    placeholder="/* Will be enabled in ATB-62 */"
                  >
                    {theme.cssOverrides ?? ""}
                  </textarea>
                  <p id="css-overrides-hint" class="form-hint">
                    Raw CSS overrides will be available after CSS sanitization is implemented (ATB-62).
                  </p>
                </div>
              </fieldset>
            </div>

            {/* Right: live preview */}
            <div class="theme-editor__preview">
              <h3>Live Preview</h3>
              <div id="preview-pane" class="preview-pane">
                <ThemePreviewContent tokens={effectiveTokens} />
              </div>
            </div>
          </div>

          {/* Actions */}
          <div class="theme-editor__actions">
            <button type="submit" class="btn btn-primary">Save Theme</button>

            <button
              type="button"
              class="btn btn-secondary"
              onclick="document.getElementById('reset-dialog').showModal()"
            >
              Reset to Preset
            </button>
          </div>
        </form>

        {/* Reset to preset dialog */}
        <dialog id="reset-dialog" class="structure-confirm-dialog">
          <form method="post" action={`/admin/themes/${themeRkey}/reset-to-preset`}>
            <p>Reset all token values to a built-in preset? Your unsaved changes will be lost.</p>
            <div class="form-group">
              <label for="reset-preset-select">Reset to preset:</label>
              <select id="reset-preset-select" name="preset">
                <option value="neobrutal-light">Neobrutal Light</option>
                <option value="neobrutal-dark">Neobrutal Dark</option>
                <option value="blank">Blank (empty tokens)</option>
              </select>
            </div>
            <div class="dialog-actions">
              <button type="submit" class="btn btn-danger">Reset</button>
              <button
                type="button"
                class="btn btn-secondary"
                onclick="document.getElementById('reset-dialog').close()"
              >
                Cancel
              </button>
            </div>
          </form>
        </dialog>
      </BaseLayout>
    );
  });

Step 2: Run the tests

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "admin-themes"

Expected: All 8 GET /admin/themes/:rkey tests pass.

Step 3: Also fix the Edit button in the theme list

In admin-themes.tsx, find the GET /admin/themes list handler. Find this line:

<span class="btn btn-secondary btn-sm" aria-disabled="true">
  Edit
</span>

Replace with:

<a href={`/admin/themes/${themeRkey}`} class="btn btn-secondary btn-sm">
  Edit
</a>

Step 4: Run tests again

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test 2>&1 | tail -20

All tests must pass.

Step 5: Commit

git add apps/web/src/routes/admin-themes.tsx
git commit -m "feat(web): GET /admin/themes/:rkey token editor page + fix Edit button (ATB-59)"

Task 5: Write tests for POST /admin/themes/:rkey/preview#

Files:

  • Modify: apps/web/src/routes/__tests__/admin-themes.test.tsx

Step 1: Add a new describe block

describe("createAdminThemeRoutes — POST /admin/themes/:rkey/preview", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok, status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  function setupAuth(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  const MANAGE_THEMES = "space.atbb.permission.manageThemes";

  async function loadThemeRoutes() {
    const { createAdminThemeRoutes } = await import("../admin-themes.js");
    return createAdminThemeRoutes("http://localhost:3000");
  }

  it("redirects unauthenticated users to /login", async () => {
    const routes = await loadThemeRoutes();
    const body = new URLSearchParams({ "color-bg": "#ff0000" });
    const res = await routes.request("/admin/themes/abc123/preview", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: body.toString(),
    });
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });

  it("returns an HTML fragment with a scoped <style> block containing submitted token values", async () => {
    setupAuth([MANAGE_THEMES]);

    const routes = await loadThemeRoutes();
    const body = new URLSearchParams({
      "color-bg": "#ff0000",
      "color-text": "#0000ff",
    });
    const res = await routes.request("/admin/themes/abc123/preview", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: body.toString(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    expect(html).toContain("--color-bg");
    expect(html).toContain("#ff0000");
    expect(html).toContain("--color-text");
    expect(html).toContain("#0000ff");
    expect(html).toContain(".preview-pane-inner");
    // Should NOT have full page HTML — this is a fragment
    expect(html).not.toContain("<html");
    expect(html).not.toContain("<BaseLayout");
  });

  it("drops token values containing '<' (sanitization)", async () => {
    setupAuth([MANAGE_THEMES]);

    const routes = await loadThemeRoutes();
    const body = new URLSearchParams({
      "color-bg": "<script>alert(1)</script>",
      "color-text": "#1a1a1a",
    });
    const res = await routes.request("/admin/themes/abc123/preview", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: body.toString(),
    });

    expect(res.status).toBe(200);
    const html = await res.text();
    // The malicious value must not appear
    expect(html).not.toContain("<script>");
    expect(html).not.toContain("alert(1)");
  });

  it("drops token values containing ';' (sanitization)", async () => {
    setupAuth([MANAGE_THEMES]);

    const routes = await loadThemeRoutes();
    const body = new URLSearchParams({
      "color-bg": "red; --injected: 1",
    });
    const res = await routes.request("/admin/themes/abc123/preview", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: body.toString(),
    });

    const html = await res.text();
    expect(html).not.toContain("--injected");
  });
});

Step 2: Run tests to verify they fail

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -E "preview|FAIL|PASS" | head -20

Expected: New preview tests fail.


Task 6: Implement POST /admin/themes/:rkey/preview#

Files:

  • Modify: apps/web/src/routes/admin-themes.tsx

Step 1: Add the route handler inside createAdminThemeRoutes, after the GET handler

  // ── POST /admin/themes/:rkey/preview ─────────────────────────────────────

  app.post("/admin/themes/:rkey/preview", async (c) => {
    const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
    if (!auth.authenticated) return c.redirect("/login");

    let rawBody: Record<string, string | File>;
    try {
      rawBody = await c.req.parseBody();
    } catch (error) {
      if (isProgrammingError(error)) throw error;
      // Return empty preview on parse error — don't break the HTMX swap
      return c.html(<ThemePreviewContent tokens={{}} />);
    }

    // Build token map from only known token names (ignore unknown fields like name/colorScheme)
    const tokens: Record<string, string> = {};
    for (const tokenName of ALL_KNOWN_TOKENS) {
      const raw = rawBody[tokenName];
      if (typeof raw !== "string") continue;
      const safe = sanitizeTokenValue(raw);
      if (safe !== null) {
        tokens[tokenName] = safe;
      }
    }

    return c.html(<ThemePreviewContent tokens={tokens} />);
  });

Step 2: Run the tests

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 3 "preview"

Expected: All preview tests pass.

Step 3: Commit

git add apps/web/src/routes/admin-themes.tsx
git commit -m "feat(web): POST /admin/themes/:rkey/preview — HTMX live preview endpoint (ATB-59)"

Task 7: Write tests for POST /admin/themes/:rkey/save#

Files:

  • Modify: apps/web/src/routes/__tests__/admin-themes.test.tsx

Step 1: Add describe block

describe("createAdminThemeRoutes — POST /admin/themes/:rkey/save", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok, status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  function setupAuth(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  const MANAGE_THEMES = "space.atbb.permission.manageThemes";

  async function loadThemeRoutes() {
    const { createAdminThemeRoutes } = await import("../admin-themes.js");
    return createAdminThemeRoutes("http://localhost:3000");
  }

  function makeFormBody(overrides: Record<string, string> = {}): string {
    return new URLSearchParams({
      name: "My Theme",
      colorScheme: "light",
      fontUrls: "",
      "color-bg": "#f5f0e8",
      ...overrides,
    }).toString();
  }

  it("redirects to ?success=1 on AppView 200", async () => {
    setupAuth([MANAGE_THEMES]);
    mockFetch.mockResolvedValueOnce(mockResponse({ id: "1", name: "My Theme" }));

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123/save", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toContain("/admin/themes/abc123");
    expect(res.headers.get("location")).toContain("success=1");
  });

  it("redirects with ?error=<msg> when AppView returns 400", async () => {
    setupAuth([MANAGE_THEMES]);
    mockFetch.mockResolvedValueOnce(
      mockResponse({ error: "Name is required" }, false, 400)
    );

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123/save", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody({ name: "" }),
    });

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
    expect(decodeURIComponent(location)).toContain("Name is required");
  });

  it("redirects with generic error on network failure", async () => {
    setupAuth([MANAGE_THEMES]);
    mockFetch.mockRejectedValueOnce(new Error("fetch failed"));

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123/save", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: makeFormBody(),
    });

    expect(res.status).toBe(302);
    const location = res.headers.get("location") ?? "";
    expect(location).toContain("error=");
    expect(decodeURIComponent(location).toLowerCase()).toContain("unavailable");
  });

  it("redirects unauthenticated users to /login", async () => {
    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123/save", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: makeFormBody(),
    });
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });
});

Step 2: Verify tests fail

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "save"

Expected: New save tests fail.


Task 8: Implement POST /admin/themes/:rkey/save#

Files:

  • Modify: apps/web/src/routes/admin-themes.tsx

Step 1: Add the route handler after the preview handler

  // ── POST /admin/themes/:rkey/save ─────────────────────────────────────────

  app.post("/admin/themes/:rkey/save", async (c) => {
    const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
    if (!auth.authenticated) return c.redirect("/login");
    if (!canManageThemes(auth)) {
      return c.html(
        <BaseLayout title="Access Denied" auth={auth}>
          <p>Access denied.</p>
        </BaseLayout>,
        403
      );
    }

    const themeRkey = c.req.param("rkey");
    const cookie = c.req.header("cookie") ?? "";

    let rawBody: Record<string, string | File>;
    try {
      rawBody = await c.req.parseBody();
    } catch (error) {
      if (isProgrammingError(error)) throw error;
      return c.redirect(
        `/admin/themes/${themeRkey}?error=${encodeURIComponent("Invalid form submission.")}`,
        302
      );
    }

    const name = typeof rawBody.name === "string" ? rawBody.name.trim() : "";
    const colorScheme = typeof rawBody.colorScheme === "string" ? rawBody.colorScheme : "light";
    const fontUrlsRaw = typeof rawBody.fontUrls === "string" ? rawBody.fontUrls : "";
    const fontUrls = fontUrlsRaw
      .split("\n")
      .map((u) => u.trim())
      .filter(Boolean);

    // Extract token values from form fields
    const tokens: Record<string, string> = {};
    for (const tokenName of ALL_KNOWN_TOKENS) {
      const raw = rawBody[tokenName];
      if (typeof raw === "string" && raw.trim()) {
        tokens[tokenName] = raw.trim();
      }
    }

    let apiRes: Response;
    try {
      apiRes = await fetch(`${appviewUrl}/api/admin/themes/${themeRkey}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json", Cookie: cookie },
        body: JSON.stringify({ name, colorScheme, tokens, fontUrls }),
      });
    } catch (error) {
      if (isProgrammingError(error)) throw error;
      logger.error("Network error saving theme", {
        operation: "POST /admin/themes/:rkey/save",
        themeRkey,
        error: error instanceof Error ? error.message : String(error),
      });
      return c.redirect(
        `/admin/themes/${themeRkey}?error=${encodeURIComponent("Forum temporarily unavailable. Please try again.")}`,
        302
      );
    }

    if (!apiRes.ok) {
      const msg = await extractAppviewError(apiRes, "Failed to save theme. Please try again.");
      return c.redirect(
        `/admin/themes/${themeRkey}?error=${encodeURIComponent(msg)}`,
        302
      );
    }

    return c.redirect(`/admin/themes/${themeRkey}?success=1`, 302);
  });

Step 2: Run the tests

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "save"

Expected: All save tests pass.

Step 3: Commit

git add apps/web/src/routes/admin-themes.tsx
git commit -m "feat(web): POST /admin/themes/:rkey/save — persist token edits to AppView (ATB-59)"

Task 9: Write tests for POST /admin/themes/:rkey/reset-to-preset#

Files:

  • Modify: apps/web/src/routes/__tests__/admin-themes.test.tsx

Step 1: Add describe block

describe("createAdminThemeRoutes — POST /admin/themes/:rkey/reset-to-preset", () => {
  beforeEach(() => {
    vi.stubGlobal("fetch", mockFetch);
    vi.stubEnv("APPVIEW_URL", "http://localhost:3000");
    vi.resetModules();
  });

  afterEach(() => {
    vi.unstubAllGlobals();
    vi.unstubAllEnvs();
    mockFetch.mockReset();
  });

  function mockResponse(body: unknown, ok = true, status = 200) {
    return {
      ok, status,
      statusText: ok ? "OK" : "Error",
      json: () => Promise.resolve(body),
    };
  }

  function setupAuth(permissions: string[]) {
    mockFetch.mockResolvedValueOnce(
      mockResponse({ authenticated: true, did: "did:plc:admin", handle: "admin.test" })
    );
    mockFetch.mockResolvedValueOnce(mockResponse({ permissions }));
  }

  const MANAGE_THEMES = "space.atbb.permission.manageThemes";

  async function loadThemeRoutes() {
    const { createAdminThemeRoutes } = await import("../admin-themes.js");
    return createAdminThemeRoutes("http://localhost:3000");
  }

  it("redirects to ?preset=neobrutal-light for valid preset", async () => {
    setupAuth([MANAGE_THEMES]);

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123/reset-to-preset", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: new URLSearchParams({ preset: "neobrutal-light" }).toString(),
    });

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=neobrutal-light");
  });

  it("redirects to ?preset=neobrutal-dark for dark preset", async () => {
    setupAuth([MANAGE_THEMES]);

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123/reset-to-preset", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: new URLSearchParams({ preset: "neobrutal-dark" }).toString(),
    });

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=neobrutal-dark");
  });

  it("redirects to ?preset=blank for blank preset", async () => {
    setupAuth([MANAGE_THEMES]);

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123/reset-to-preset", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: new URLSearchParams({ preset: "blank" }).toString(),
    });

    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/admin/themes/abc123?preset=blank");
  });

  it("returns 400 for unknown preset name", async () => {
    setupAuth([MANAGE_THEMES]);

    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123/reset-to-preset", {
      method: "POST",
      headers: {
        "content-type": "application/x-www-form-urlencoded",
        cookie: "atbb_session=token",
      },
      body: new URLSearchParams({ preset: "hacked" }).toString(),
    });

    expect(res.status).toBe(400);
  });

  it("redirects unauthenticated users to /login", async () => {
    const routes = await loadThemeRoutes();
    const res = await routes.request("/admin/themes/abc123/reset-to-preset", {
      method: "POST",
      headers: { "content-type": "application/x-www-form-urlencoded" },
      body: new URLSearchParams({ preset: "neobrutal-light" }).toString(),
    });
    expect(res.status).toBe(302);
    expect(res.headers.get("location")).toBe("/login");
  });
});

Step 2: Verify tests fail

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test -- --reporter=verbose 2>&1 | grep -A 2 "reset"

Task 10: Implement POST /admin/themes/:rkey/reset-to-preset#

Files:

  • Modify: apps/web/src/routes/admin-themes.tsx

Step 1: Add the route handler

  // ── POST /admin/themes/:rkey/reset-to-preset ──────────────────────────────

  app.post("/admin/themes/:rkey/reset-to-preset", async (c) => {
    const auth = await getSessionWithPermissions(appviewUrl, c.req.header("cookie"));
    if (!auth.authenticated) return c.redirect("/login");
    if (!canManageThemes(auth)) {
      return c.html(
        <BaseLayout title="Access Denied" auth={auth}>
          <p>Access denied.</p>
        </BaseLayout>,
        403
      );
    }

    const themeRkey = c.req.param("rkey");

    let body: Record<string, string | File>;
    try {
      body = await c.req.parseBody();
    } catch (error) {
      if (isProgrammingError(error)) throw error;
      return c.json({ error: "Invalid form submission." }, 400);
    }

    const preset = typeof body.preset === "string" ? body.preset : "";
    if (!(preset in THEME_PRESETS)) {
      return c.json({ error: `Unknown preset: ${preset}` }, 400);
    }

    return c.redirect(`/admin/themes/${themeRkey}?preset=${encodeURIComponent(preset)}`, 302);
  });

Step 2: Run all tests

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web test 2>&1 | tail -20

Expected: All tests pass.

Step 3: Commit

git add apps/web/src/routes/admin-themes.tsx apps/web/src/routes/__tests__/admin-themes.test.tsx
git commit -m "feat(web): POST /admin/themes/:rkey/reset-to-preset + all tests (ATB-59)"

Task 11: Full test suite + lint + Linear update#

Step 1: Run the full test suite

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm test 2>&1 | tail -30

Expected: All packages pass.

Step 2: Run lint fix

export PATH="$(pwd)/.devenv/profile/bin:/bin:/usr/bin:$PATH"
pnpm --filter @atbb/web lint:fix

Fix any lint errors that appear.

Step 3: If any lint fixes were needed, commit them

git add -A
git commit -m "style(web): lint fixes for ATB-59 theme editor"

Step 4: Update Linear issue ATB-59

  • Change status to In ProgressIn Review (or Done after review)
  • Add a comment listing what was implemented

What We Did NOT Implement (per spec)#

  • cssOverrides editor — disabled, awaiting ATB-62 (CSS sanitization)
  • Font URL validation — currently stored as-is; proper HTTPS URL validation can be added in ATB-62
  • Import/export JSON — listed in theming-plan.md Phase 3/5 but not in ATB-59 scope
  • User theme picker — ATB-60 scope

Known Token JSON Import Note#

If TypeScript complains about import ... assert { type: "json" }, check how it was done in the existing admin.tsx:

// admin.tsx already uses:
import neobrutalLight from "../styles/presets/neobrutal-light.json";
import neobrutalDark from "../styles/presets/neobrutal-dark.json";

Use the same syntax (without assert { type: "json" }) if that's what the project's tsconfig supports.