···11+# ATB-58: Admin Theme List Page — Design
22+33+**Status:** Approved
44+**Linear:** ATB-58
55+**Depends on:** ATB-55 (read endpoints), ATB-57 (write endpoints — complete)
66+77+---
88+99+## Overview
1010+1111+Admin page at `GET /admin/themes` for viewing, creating, duplicating, and deleting themes, and managing the theme policy. Follows established admin panel patterns: permission-gated page, `<details>` for inline create form, `<dialog>` for delete confirmations, POST-redirect-GET flow.
1212+1313+---
1414+1515+## Section 1: New AppView Endpoints
1616+1717+Two new endpoints added to `apps/appview/src/routes/admin.ts`:
1818+1919+### `GET /api/admin/themes`
2020+2121+Returns all themes for the forum DID — **no policy filtering** — with full token data for color swatch rendering. Requires `manageThemes` permission.
2222+2323+```
2424+Response: { themes: [{ id, uri, name, colorScheme, tokens, cssOverrides, fontUrls, createdAt, indexedAt }] }
2525+```
2626+2727+Middleware: `requireAuth(ctx)` → `requirePermission(ctx, "space.atbb.permission.manageThemes")` → handler.
2828+2929+### `POST /api/admin/themes/:rkey/duplicate`
3030+3131+Clones a theme: fetches source from DB by rkey, generates a fresh TID via `TID.nextStr()`, writes a new PDS record with name + `" (Copy)"`. Returns `{ uri, rkey, name }` on 201. Returns 404 if source not found.
3232+3333+Same middleware pattern as above.
3434+3535+---
3636+3737+## Section 2: Session Layer + Admin Landing Page
3838+3939+### `apps/web/src/lib/session.ts`
4040+4141+1. Add `"space.atbb.permission.manageThemes"` to the `ADMIN_PERMISSIONS` array.
4242+2. Add `canManageThemes(auth: WebSessionWithPermissions): boolean` — same pattern as `canManageRoles`.
4343+4444+### `apps/web/src/routes/admin.tsx` — `GET /admin`
4545+4646+Add Themes card to the landing page grid, gated by `canManageThemes(auth)`:
4747+4848+```
4949+🎨 Themes
5050+Customize forum appearance and color schemes
5151+→ /admin/themes
5252+```
5353+5454+---
5555+5656+## Section 3: `GET /admin/themes` Page Layout
5757+5858+Data fetched server-side before render:
5959+- `GET /api/admin/themes` — all themes with full token data
6060+- `GET /api/theme-policy` — current policy (defaults, availableThemes URIs, allowUserChoice)
6161+6262+Page sections top to bottom:
6363+6464+### Error Banner
6565+```tsx
6666+{errorMsg && <div class="structure-error-banner">{errorMsg}</div>}
6767+```
6868+Sourced from `?error=` query param, same as the structure page.
6969+7070+### Theme Cards List
7171+7272+One card per theme. Each card contains:
7373+7474+- Theme name + `colorScheme` badge (`light` / `dark`)
7575+- Color swatch row: 5 small `<span>` squares with inline `style="background: {token}"` for tokens `color-bg`, `color-surface`, `color-primary`, `color-secondary`, `color-border`
7676+- "Edit" link → `/admin/themes/:rkey` (ATB-59, not yet implemented — renders as disabled link)
7777+- "Duplicate" `<form method="post" action="/admin/themes/:rkey/duplicate">` with submit button
7878+- "Delete" button calling `showModal()` on a per-card `<dialog>` containing `<form method="post" action="/admin/themes/:rkey/delete">`
7979+- Availability checkbox: `<input type="checkbox" form="policy-form" name="availableThemes" value="{uri}" checked={isAvailable}>` — associated with the policy form via HTML `form` attribute, not nesting
8080+8181+### Policy Section
8282+8383+```html
8484+<form id="policy-form" method="post" action="/admin/theme-policy">
8585+ <!-- Default Light Theme -->
8686+ <select name="defaultLightThemeUri"> ... light-scheme themes ... </select>
8787+8888+ <!-- Default Dark Theme -->
8989+ <select name="defaultDarkThemeUri"> ... dark-scheme themes ... </select>
9090+9191+ <!-- Allow User Choice -->
9292+ <input type="checkbox" name="allowUserChoice" checked={policy.allowUserChoice}>
9393+9494+ <button type="submit">Save Policy</button>
9595+</form>
9696+```
9797+9898+### Create Theme `<details>`
9999+100100+Collapsible form at the bottom:
101101+- Name (text input, required)
102102+- Color Scheme (select: `light` / `dark`)
103103+- Start from preset (select: `Neobrutal Light`, `Neobrutal Dark`, `Blank`)
104104+- Submit → `POST /admin/themes`
105105+106106+Note: Only 2 presets exist (`neobrutal-light.json`, `neobrutal-dark.json`). Additional presets (Clean Light, Clean Dark, Classic BB) are a follow-up.
107107+108108+---
109109+110110+## Section 4: Web POST Routes
111111+112112+All in `apps/web/src/routes/admin.tsx`. All use parse → validate → proxy AppView API → redirect pattern.
113113+114114+### `POST /admin/themes` (create)
115115+- Parses `name`, `colorScheme`, `preset` from form body
116116+- Loads preset tokens from `apps/web/src/styles/presets/` JSON files, or `{}` for Blank
117117+- POSTs to `POST /api/admin/themes` with `{ name, colorScheme, tokens }`
118118+- Success → redirect `/admin/themes`
119119+120120+### `POST /admin/themes/:rkey/duplicate`
121121+- No body parsing needed
122122+- POSTs to `POST /api/admin/themes/:rkey/duplicate`
123123+- Success → redirect `/admin/themes`
124124+125125+### `POST /admin/themes/:rkey/delete`
126126+- No body parsing needed
127127+- DELETEs to `DELETE /api/admin/themes/:rkey`
128128+- 409 response → redirect `/admin/themes?error=Cannot delete a theme that is currently set as a default`
129129+- Success → redirect `/admin/themes`
130130+131131+### `POST /admin/theme-policy`
132132+- Parses `defaultLightThemeUri`, `defaultDarkThemeUri`, `allowUserChoice` (absent when unchecked → `false`), `availableThemes` (array via `parseBody`)
133133+- PUTs to `PUT /api/admin/theme-policy`
134134+- Success → redirect `/admin/themes`
135135+136136+---
137137+138138+## Section 5: Tests
139139+140140+### AppView (`apps/appview/src/routes/__tests__/themes.test.ts`)
141141+- `GET /api/admin/themes`: returns all themes unfiltered by policy; 401 unauthenticated; 403 insufficient permission
142142+- `POST /api/admin/themes/:rkey/duplicate`: creates copy with "(Copy)" suffix and new rkey; 404 for unknown rkey; 401/403 for auth
143143+144144+### Web (`apps/web/src/routes/__tests__/admin.test.tsx`)
145145+- `GET /admin/themes`: renders theme cards with color swatches; shows error banner from `?error=`; 403 for missing `manageThemes` permission; redirects to login if unauthenticated
146146+- `POST /admin/themes`: creates theme with preset tokens loaded from JSON; redirects on success; redirects with error on AppView failure
147147+- `POST /admin/themes/:rkey/duplicate`: redirects on success; redirects with error on AppView failure
148148+- `POST /admin/themes/:rkey/delete`: redirects on success; redirects with human-friendly error message on 409 conflict
149149+- `POST /admin/theme-policy`: saves policy; correctly handles absent `allowUserChoice` checkbox as `false`; redirects on success
150150+151151+---
152152+153153+## Key Decisions
154154+155155+- **Form attribute pattern:** Availability checkboxes on theme cards use `<input form="policy-form">` to associate with the policy form without HTML nesting. Cards can contain their own `<form>` elements for delete/duplicate.
156156+- **Presets scoped to 2 + Blank:** Only existing preset JSONs used. Additional presets deferred.
157157+- **Edit link disabled:** `/admin/themes/:rkey` editor (ATB-59) not implemented yet — edit button renders as a placeholder.
158158+- **Admin-only theme list:** `GET /api/admin/themes` is separate from the public `GET /api/themes` (which filters by policy). Admins see all themes including drafts not yet in the policy.
159159+- **Duplicate in appview:** Clone logic lives in a dedicated `POST /api/admin/themes/:rkey/duplicate` appview endpoint — keeps all PDS write logic server-side and consistent with existing patterns.