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

docs: add design doc for ATB-58 admin theme list page

+159
+159
docs/plans/2026-03-02-atb-58-design.md
··· 1 + # ATB-58: Admin Theme List Page — Design 2 + 3 + **Status:** Approved 4 + **Linear:** ATB-58 5 + **Depends on:** ATB-55 (read endpoints), ATB-57 (write endpoints — complete) 6 + 7 + --- 8 + 9 + ## Overview 10 + 11 + 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. 12 + 13 + --- 14 + 15 + ## Section 1: New AppView Endpoints 16 + 17 + Two new endpoints added to `apps/appview/src/routes/admin.ts`: 18 + 19 + ### `GET /api/admin/themes` 20 + 21 + Returns all themes for the forum DID — **no policy filtering** — with full token data for color swatch rendering. Requires `manageThemes` permission. 22 + 23 + ``` 24 + Response: { themes: [{ id, uri, name, colorScheme, tokens, cssOverrides, fontUrls, createdAt, indexedAt }] } 25 + ``` 26 + 27 + Middleware: `requireAuth(ctx)` → `requirePermission(ctx, "space.atbb.permission.manageThemes")` → handler. 28 + 29 + ### `POST /api/admin/themes/:rkey/duplicate` 30 + 31 + 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. 32 + 33 + Same middleware pattern as above. 34 + 35 + --- 36 + 37 + ## Section 2: Session Layer + Admin Landing Page 38 + 39 + ### `apps/web/src/lib/session.ts` 40 + 41 + 1. Add `"space.atbb.permission.manageThemes"` to the `ADMIN_PERMISSIONS` array. 42 + 2. Add `canManageThemes(auth: WebSessionWithPermissions): boolean` — same pattern as `canManageRoles`. 43 + 44 + ### `apps/web/src/routes/admin.tsx` — `GET /admin` 45 + 46 + Add Themes card to the landing page grid, gated by `canManageThemes(auth)`: 47 + 48 + ``` 49 + 🎨 Themes 50 + Customize forum appearance and color schemes 51 + → /admin/themes 52 + ``` 53 + 54 + --- 55 + 56 + ## Section 3: `GET /admin/themes` Page Layout 57 + 58 + Data fetched server-side before render: 59 + - `GET /api/admin/themes` — all themes with full token data 60 + - `GET /api/theme-policy` — current policy (defaults, availableThemes URIs, allowUserChoice) 61 + 62 + Page sections top to bottom: 63 + 64 + ### Error Banner 65 + ```tsx 66 + {errorMsg && <div class="structure-error-banner">{errorMsg}</div>} 67 + ``` 68 + Sourced from `?error=` query param, same as the structure page. 69 + 70 + ### Theme Cards List 71 + 72 + One card per theme. Each card contains: 73 + 74 + - Theme name + `colorScheme` badge (`light` / `dark`) 75 + - Color swatch row: 5 small `<span>` squares with inline `style="background: {token}"` for tokens `color-bg`, `color-surface`, `color-primary`, `color-secondary`, `color-border` 76 + - "Edit" link → `/admin/themes/:rkey` (ATB-59, not yet implemented — renders as disabled link) 77 + - "Duplicate" `<form method="post" action="/admin/themes/:rkey/duplicate">` with submit button 78 + - "Delete" button calling `showModal()` on a per-card `<dialog>` containing `<form method="post" action="/admin/themes/:rkey/delete">` 79 + - 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 80 + 81 + ### Policy Section 82 + 83 + ```html 84 + <form id="policy-form" method="post" action="/admin/theme-policy"> 85 + <!-- Default Light Theme --> 86 + <select name="defaultLightThemeUri"> ... light-scheme themes ... </select> 87 + 88 + <!-- Default Dark Theme --> 89 + <select name="defaultDarkThemeUri"> ... dark-scheme themes ... </select> 90 + 91 + <!-- Allow User Choice --> 92 + <input type="checkbox" name="allowUserChoice" checked={policy.allowUserChoice}> 93 + 94 + <button type="submit">Save Policy</button> 95 + </form> 96 + ``` 97 + 98 + ### Create Theme `<details>` 99 + 100 + Collapsible form at the bottom: 101 + - Name (text input, required) 102 + - Color Scheme (select: `light` / `dark`) 103 + - Start from preset (select: `Neobrutal Light`, `Neobrutal Dark`, `Blank`) 104 + - Submit → `POST /admin/themes` 105 + 106 + Note: Only 2 presets exist (`neobrutal-light.json`, `neobrutal-dark.json`). Additional presets (Clean Light, Clean Dark, Classic BB) are a follow-up. 107 + 108 + --- 109 + 110 + ## Section 4: Web POST Routes 111 + 112 + All in `apps/web/src/routes/admin.tsx`. All use parse → validate → proxy AppView API → redirect pattern. 113 + 114 + ### `POST /admin/themes` (create) 115 + - Parses `name`, `colorScheme`, `preset` from form body 116 + - Loads preset tokens from `apps/web/src/styles/presets/` JSON files, or `{}` for Blank 117 + - POSTs to `POST /api/admin/themes` with `{ name, colorScheme, tokens }` 118 + - Success → redirect `/admin/themes` 119 + 120 + ### `POST /admin/themes/:rkey/duplicate` 121 + - No body parsing needed 122 + - POSTs to `POST /api/admin/themes/:rkey/duplicate` 123 + - Success → redirect `/admin/themes` 124 + 125 + ### `POST /admin/themes/:rkey/delete` 126 + - No body parsing needed 127 + - DELETEs to `DELETE /api/admin/themes/:rkey` 128 + - 409 response → redirect `/admin/themes?error=Cannot delete a theme that is currently set as a default` 129 + - Success → redirect `/admin/themes` 130 + 131 + ### `POST /admin/theme-policy` 132 + - Parses `defaultLightThemeUri`, `defaultDarkThemeUri`, `allowUserChoice` (absent when unchecked → `false`), `availableThemes` (array via `parseBody`) 133 + - PUTs to `PUT /api/admin/theme-policy` 134 + - Success → redirect `/admin/themes` 135 + 136 + --- 137 + 138 + ## Section 5: Tests 139 + 140 + ### AppView (`apps/appview/src/routes/__tests__/themes.test.ts`) 141 + - `GET /api/admin/themes`: returns all themes unfiltered by policy; 401 unauthenticated; 403 insufficient permission 142 + - `POST /api/admin/themes/:rkey/duplicate`: creates copy with "(Copy)" suffix and new rkey; 404 for unknown rkey; 401/403 for auth 143 + 144 + ### Web (`apps/web/src/routes/__tests__/admin.test.tsx`) 145 + - `GET /admin/themes`: renders theme cards with color swatches; shows error banner from `?error=`; 403 for missing `manageThemes` permission; redirects to login if unauthenticated 146 + - `POST /admin/themes`: creates theme with preset tokens loaded from JSON; redirects on success; redirects with error on AppView failure 147 + - `POST /admin/themes/:rkey/duplicate`: redirects on success; redirects with error on AppView failure 148 + - `POST /admin/themes/:rkey/delete`: redirects on success; redirects with human-friendly error message on 409 conflict 149 + - `POST /admin/theme-policy`: saves policy; correctly handles absent `allowUserChoice` checkbox as `false`; redirects on success 150 + 151 + --- 152 + 153 + ## Key Decisions 154 + 155 + - **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. 156 + - **Presets scoped to 2 + Blank:** Only existing preset JSONs used. Additional presets deferred. 157 + - **Edit link disabled:** `/admin/themes/:rkey` editor (ATB-59) not implemented yet — edit button renders as a placeholder. 158 + - **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. 159 + - **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.