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-59 admin theme token editor

Covers file structure (extract to admin-themes.tsx), editor page layout,
HTMX preview endpoint, save/reset flows, error handling, and test plan.

+273
+273
docs/plans/2026-03-03-atb59-theme-token-editor-design.md
··· 1 + # ATB-59 Design: Admin Theme Token Editor with Live Preview 2 + 3 + **Linear:** ATB-59 4 + **Date:** 2026-03-03 5 + **Status:** Approved — ready for implementation 6 + **Depends on:** ATB-57 (Done), ATB-58 (Done) 7 + 8 + --- 9 + 10 + ## Overview 11 + 12 + Build the theme token editor page at `GET /admin/themes/:rkey` and a HTMX-driven preview endpoint that shows a live sample forum page as token values change. The editor lets admins customize all 46 design tokens through grouped form controls, save to the PDS via the AppView, and reset to any built-in preset. 13 + 14 + --- 15 + 16 + ## Architecture 17 + 18 + ### File Structure Changes 19 + 20 + **New file:** `apps/web/src/routes/admin-themes.tsx` 21 + 22 + Extract all existing theme-admin handlers from `admin.tsx` into a dedicated module and add the new editor and preview handlers there. 23 + 24 + | Route | Handler Location | Description | 25 + |-------|-----------------|-------------| 26 + | `GET /admin/themes` | moved from admin.tsx | Theme list page | 27 + | `POST /admin/themes` | moved | Create theme | 28 + | `POST /admin/themes/:rkey/duplicate` | moved | Duplicate theme | 29 + | `POST /admin/themes/:rkey/delete` | moved | Delete theme | 30 + | `POST /admin/theme-policy` | moved | Update policy | 31 + | `GET /admin/themes/:rkey` | **new** | Token editor page | 32 + | `POST /admin/themes/:rkey/preview` | **new** | HTMX preview fragment | 33 + | `POST /admin/themes/:rkey/save` | **new** | Save tokens to AppView | 34 + | `POST /admin/themes/:rkey/reset-to-preset` | **new** | Redirect with preset query param | 35 + 36 + In `admin.tsx`, replace all theme handler blocks with: 37 + 38 + ```typescript 39 + import { createAdminThemeRoutes } from "./admin-themes.js"; 40 + const themeRoutes = createAdminThemeRoutes(appviewUrl); 41 + app.route("/", themeRoutes); 42 + ``` 43 + 44 + The `createAdminThemeRoutes(appviewUrl: string)` factory matches the existing pattern for route modules in this project. 45 + 46 + --- 47 + 48 + ## Editor Page — `GET /admin/themes/:rkey` 49 + 50 + ### Data Loading 51 + 52 + 1. Fetch `GET /api/admin/themes/:rkey` from the AppView (requires `manageThemes` session cookie) 53 + 2. If `?preset=<name>` is present in the query string, merge preset tokens over the DB tokens for initial input values (enables the reset-to-preset flow) 54 + 3. If `?success=1`, show a success banner 55 + 4. If `?error=<msg>`, show an error banner 56 + 57 + ### Layout 58 + 59 + Two-column layout on desktop, stacked on mobile: 60 + 61 + ``` 62 + ┌─ Metadata ─────────────────────────────────────────────────┐ 63 + │ Name: [_________________] Color Scheme: [light ▾] │ 64 + │ Font URLs: [textarea — one per line] │ 65 + └──────────────────────────────────────────────────────────────┘ 66 + 67 + ┌─ Token Editor (~60%) ──────┐ ┌─ Live Preview (~40%) ───────┐ 68 + │ <fieldset> Colors │ │ <div id="preview-pane" │ 69 + │ color-bg [■] [#f5f0e8] │ │ hx-swap-oob="true"> │ 70 + │ color-text [■] [#1a1a1a]│ │ │ 71 + │ ... (14 color tokens) │ │ <style>:root{--tokens}</style> 72 + │ </fieldset> │ │ [Sample: card, button, │ 73 + │ │ │ heading, text, code, │ 74 + │ <fieldset> Typography │ │ input, nav strip] │ 75 + │ font-body [___________] │ │ │ 76 + │ font-size-base [16] px │ │ Updates on every change │ 77 + │ ... (13 tokens) │ │ via hx-trigger="change │ 78 + │ </fieldset> │ │ delay:300ms" │ 79 + │ │ └──────────────────────────────┘ 80 + │ <fieldset> Spacing & Layout│ 81 + │ space-xs [____] │ 82 + │ radius [____] │ 83 + │ ... (9 tokens) │ 84 + │ </fieldset> │ 85 + │ │ 86 + │ <fieldset> Components │ 87 + │ button-shadow [________] │ (plain text — CSS shorthand) 88 + │ card-shadow [________] │ 89 + │ ... (10 tokens) │ 90 + │ </fieldset> │ 91 + │ │ 92 + │ [Save] [Reset to Preset] │ 93 + │ [← Back to themes] │ 94 + └─────────────────────────────┘ 95 + ``` 96 + 97 + ### Token Groups (all 46 tokens from preset JSON) 98 + 99 + **Colors (14):** `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` 100 + - Input: `<input type="color">` + adjacent text input for hex value 101 + 102 + **Typography (13):** `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` 103 + - Font families: text inputs; sizes: text inputs with `px` unit hint 104 + 105 + **Spacing & Layout (9):** `space-xs`, `space-sm`, `space-md`, `space-lg`, `space-xl`, `radius`, `border-width`, `shadow-offset`, `content-width` 106 + - Input: text inputs 107 + 108 + **Components (10):** `button-radius`, `button-shadow`, `card-radius`, `card-shadow`, `btn-press-hover`, `btn-press-active`, `input-radius`, `input-border`, `nav-height` 109 + - Input: plain text inputs (CSS shorthand values) 110 + 111 + ### HTMX Wiring 112 + 113 + The token fieldsets (not the metadata inputs) carry: 114 + 115 + ```html 116 + <form id="token-form" 117 + hx-post="/admin/themes/:rkey/preview" 118 + hx-trigger="change delay:300ms from:find input, change delay:300ms from:find select" 119 + hx-target="#preview-pane" 120 + hx-swap="innerHTML"> 121 + ``` 122 + 123 + This debounces preview updates 300ms after any input change. 124 + 125 + ### Reset to Preset Dialog 126 + 127 + ```html 128 + <dialog id="reset-dialog"> 129 + <form method="post" action="/admin/themes/:rkey/reset-to-preset"> 130 + <label for="reset-preset">Reset tokens to:</label> 131 + <select name="preset" id="reset-preset"> 132 + <option value="neobrutal-light">Neobrutal Light</option> 133 + <option value="neobrutal-dark">Neobrutal Dark</option> 134 + <option value="blank">Blank (empty tokens)</option> 135 + </select> 136 + <p>This will replace all token values. Your current changes will be lost.</p> 137 + <button type="submit">Reset</button> 138 + <button type="button" onclick="document.getElementById('reset-dialog').close()">Cancel</button> 139 + </form> 140 + </dialog> 141 + <button type="button" onclick="document.getElementById('reset-dialog').showModal()"> 142 + Reset to Preset 143 + </button> 144 + ``` 145 + 146 + ### CSS Override Field 147 + 148 + Hidden/disabled pending ATB-62 (CSS sanitization). Render a disabled textarea with a note explaining it will be available after sanitization is implemented. 149 + 150 + --- 151 + 152 + ## Preview Endpoint — `POST /admin/themes/:rkey/preview` 153 + 154 + **This is a pure web-server computation — no AppView call, no DB, no PDS write.** 155 + 156 + ### Request 157 + 158 + Form data containing all token name/value pairs submitted by the HTMX form. 159 + 160 + ### Processing 161 + 162 + 1. Parse form fields into `tokens: Record<string, string>` 163 + 2. Sanitize each value: reject values containing `<`, `;` outside strings, or that look like injected markup. For color tokens, validate hex format. 164 + 3. Call `tokensToCss(tokens)` from `apps/web/src/lib/theme.ts` 165 + 4. Build and return an HTML fragment 166 + 167 + ### Response 168 + 169 + ```html 170 + <style> 171 + :root { 172 + --color-bg: #f5f0e8; 173 + --color-text: #1a1a1a; 174 + /* ... all 46 tokens */ 175 + } 176 + </style> 177 + 178 + <div class="preview-sample"> 179 + <!-- Representative forum elements using the theme classes --> 180 + <nav class="preview-nav">atBB Forum Preview</nav> 181 + <div class="card"> 182 + <h2>Sample Thread Title</h2> 183 + <p>Body text showing font, color, and spacing tokens at work.</p> 184 + <code>const example = "code block";</code> 185 + <input type="text" placeholder="Reply..." /> 186 + <button class="btn-primary">Post Reply</button> 187 + </div> 188 + </div> 189 + ``` 190 + 191 + The sample HTML uses existing `.card`, `.btn-primary`, and other CSS classes from `theme.css` so the preview reflects the real design system. 192 + 193 + --- 194 + 195 + ## Save Flow — `POST /admin/themes/:rkey/save` 196 + 197 + 1. Parse form: `name` (string), `colorScheme` (light|dark), `fontUrls` (string → split by newline), plus all 46 token key/value pairs 198 + 2. Build `tokens: Record<string, string>` from the token fields 199 + 3. `fetch(PUT /api/admin/themes/:rkey, { body: JSON.stringify({ name, colorScheme, tokens, fontUrls }) })` 200 + 4. On success (2xx) → `redirect /admin/themes/:rkey?success=1` 201 + 5. On AppView 4xx → extract error message → `redirect /admin/themes/:rkey?error=<msg>` 202 + 6. On network error → `redirect /admin/themes/:rkey?error=Forum+temporarily+unavailable` 203 + 204 + --- 205 + 206 + ## Reset Flow — `POST /admin/themes/:rkey/reset-to-preset` 207 + 208 + 1. Parse `preset` from body 209 + 2. Validate: must be one of `neobrutal-light`, `neobrutal-dark`, `blank`; otherwise return 400 210 + 3. On valid preset → `redirect /admin/themes/:rkey?preset=<name>` 211 + 212 + The `GET /admin/themes/:rkey` handler already handles `?preset=<name>` by loading preset tokens from the imported JSON files and using them as the initial input values instead of the DB values. 213 + 214 + --- 215 + 216 + ## Error Handling 217 + 218 + | Scenario | Behavior | 219 + |----------|----------| 220 + | Theme not found (AppView 404) | 404 page | 221 + | Network error loading theme | Error banner, no crash | 222 + | Unauthenticated | Redirect to /login | 223 + | No manageThemes permission | 403 page | 224 + | Preview parse error | Return preview pane with fallback style | 225 + | Save AppView failure | Redirect with `?error=<message>` | 226 + | Save network error | Redirect with generic error message | 227 + | Invalid preset name in reset | 400 response | 228 + 229 + --- 230 + 231 + ## Testing Plan 232 + 233 + All tests added to `apps/web/src/routes/__tests__/admin.test.tsx` (or a new `admin-themes.test.tsx` if extracted). 234 + 235 + **`GET /admin/themes/:rkey`** 236 + - Redirects unauthenticated users to /login 237 + - Returns 403 for users without manageThemes permission 238 + - Returns 404 for unknown rkey 239 + - Renders editor with correct initial token values from theme data 240 + - `?preset=neobrutal-light` populates inputs from preset instead of DB values 241 + - `?success=1` shows success banner 242 + - `?error=<msg>` shows error banner 243 + - CSS overrides field is rendered disabled 244 + 245 + **`POST /admin/themes/:rkey/preview`** 246 + - Returns HTML fragment containing a `<style>` block with submitted token values 247 + - Sanitizes malicious input (drops values containing `<` or `;`) 248 + - Returns a fallback preview on parse error (doesn't crash) 249 + 250 + **`POST /admin/themes/:rkey/save`** 251 + - Redirects to `?success=1` on AppView 2xx 252 + - Redirects with `?error=<msg>` on AppView 4xx 253 + - Redirects with generic error on network failure 254 + 255 + **`POST /admin/themes/:rkey/reset-to-preset`** 256 + - Redirects to `?preset=neobrutal-light` for valid preset name 257 + - Returns 400 for unknown preset name 258 + 259 + --- 260 + 261 + ## Bruno Collection Update 262 + 263 + Add to `bruno/AppView API/Admin Themes/`: 264 + - No new AppView endpoints are introduced in ATB-59 (preview is web-only; save reuses the existing `PUT /api/admin/themes/:rkey`). The existing `Update Theme.bru` file covers the save path. 265 + 266 + --- 267 + 268 + ## Implementation Notes 269 + 270 + - The `THEME_PRESETS` constant (already in `admin.tsx`) moves to `admin-themes.tsx` 271 + - `tokensToCss()` is imported from `apps/web/src/lib/theme.ts` — already exists, no changes needed 272 + - The Edit button in the theme list (`aria-disabled="true"`) becomes a real `<a href="/admin/themes/:rkey">` link 273 + - `admin.tsx` imports `createAdminThemeRoutes` and mounts it — all other theme-handler blocks are deleted