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

Expand theming plan with admin-curated themes and user choice

Replace the single active-theme model with a full theme policy system:
- Admin curates available themes with separate light/dark defaults
- Users can pick their own theme from the available list
- Theme resolution waterfall: user pref → color scheme → forum default → fallback
- New themePolicy on forum.forum, preferredTheme on membership
- AppView API endpoints for theme CRUD and user preference
- Updated presets to ship light+dark variants
- 5-phase implementation plan reflecting new scope

https://claude.ai/code/session_01Y3xoFe9ty2gduA4KHVKeYx

Claude f7a38315 ec5c9012

+236 -74
+236 -74
docs/theming-plan.md
··· 21 21 ### How Themes Work 22 22 23 23 ``` 24 - Forum DID PDS Web Server (Hono) 25 - ┌──────────────────┐ ┌─────────────────────────┐ 26 - │ space.atbb.forum │ │ │ 27 - │ .theme record │───fetch───▶│ BaseLayout renders: │ 28 - │ │ │ <style>:root { ... }</style>│ 29 - │ { tokens, ... } │ │ + theme class on <body>│ 30 - └──────────────────┘ │ + /static/theme.css │ 31 - └─────────────────────────┘ 24 + Forum DID PDS Web Server (Hono) 25 + ┌──────────────────────────┐ ┌───────────────────────────────────┐ 26 + │ space.atbb.forum.theme │ │ │ 27 + │ (multiple records) │──cache─▶│ Theme resolution per request: │ 28 + │ │ │ 1. User pref (membership record) │ 29 + │ space.atbb.forum.forum │ │ 2. Color scheme (cookie/header) │ 30 + │ .themePolicy { │──cache─▶│ 3. Forum default (themePolicy) │ 31 + │ availableThemes │ │ 4. Hardcoded fallback │ 32 + │ defaultLightTheme │ │ │ │ 33 + │ defaultDarkTheme │ │ ▼ │ 34 + │ allowUserChoice │ │ <style>:root { --tokens }</style>│ 35 + │ } │ │ + /static/theme.css │ 36 + └──────────────────────────┘ └───────────────────────────────────┘ 32 37 ``` 33 38 34 - 1. **Theme record** lives on the Forum DID's PDS as a `space.atbb.forum.theme` record. 35 - 2. **On request**, the web server reads the active theme config (cached) and injects CSS custom properties into a `<style>` block in `<head>`. 36 - 3. **A single base stylesheet** (`theme.css`) references only custom properties — never hardcoded colors or sizes. Swapping property values completely changes the look. 37 - 4. **Optional per-theme CSS overrides** can extend the base for structural changes (e.g., sidebar layout vs. top-nav), stored as a `cssOverrides` string in the theme record. 39 + 1. **Theme records** live on the Forum DID's PDS as `space.atbb.forum.theme` records. A forum can have many saved themes. 40 + 2. **Theme policy** on the `forum.forum` record controls which themes are available to users, which are the defaults for light/dark mode, and whether users can choose their own. 41 + 3. **On each request**, the web server resolves which theme to render (see [Theme Resolution](#theme-resolution) below) and injects the winning theme's CSS custom properties into a `<style>` block in `<head>`. 42 + 4. **A single base stylesheet** (`theme.css`) references only custom properties — never hardcoded colors or sizes. Swapping property values completely changes the look. 43 + 5. **Optional per-theme CSS overrides** can extend the base for structural changes (e.g., sidebar layout vs. top-nav), stored as a `cssOverrides` string in the theme record. 38 44 39 45 ### Theme Layers 40 46 ··· 165 171 166 172 --- 167 173 168 - ## Lexicon: `space.atbb.forum.theme` 174 + ## Lexicon Changes 175 + 176 + ### New: `space.atbb.forum.theme` 169 177 170 178 A new record type on the Forum DID for storing theme configuration. 171 179 172 180 ```yaml 173 181 lexiconId: space.atbb.forum.theme 174 - key: tid # Multiple themes per forum, one marked active 182 + key: tid # Multiple themes per forum 175 183 fields: 176 184 name: string (required) # "Neobrutal Default", "Dark Mode", etc. 177 - active: boolean # Is this the currently applied theme? 185 + colorScheme: string (required) # "light" | "dark" — which mode this theme targets 178 186 tokens: map<string, string> # Design token key-value pairs 179 187 cssOverrides: string (optional)# Raw CSS for structural overrides 180 188 fontUrls: array<string> (opt) # Google Fonts or self-hosted font URLs ··· 184 192 185 193 **Record ownership:** Forum DID (same as `forum.forum`, `forum.category`). 186 194 187 - **Why `tid` key?** Forums can have multiple saved themes (like phpBB's theme gallery). Only one has `active: true` at a time. The AppView enforces the single-active constraint. 195 + **Why `tid` key?** Forums can have many saved themes (like phpBB's theme gallery). The admin curates which ones are available via the theme policy below. 196 + 197 + **Why `colorScheme` instead of `active`?** A single `active` boolean is too limiting. Forums need separate defaults for light and dark mode, plus a curated list of user-selectable themes. The `colorScheme` field tags each theme so the resolution logic knows which mode it serves. The `themePolicy` on the forum record handles the rest. 198 + 199 + ### Extended: `space.atbb.forum.forum` 200 + 201 + Add a `themePolicy` object to the existing forum singleton record: 202 + 203 + ```yaml 204 + # New fields on forum.forum 205 + themePolicy: 206 + type: object 207 + properties: 208 + availableThemes: # AT-URIs of themes admins have enabled for users 209 + type: array 210 + items: string (at-uri) 211 + defaultLightTheme: # AT-URI of the default light-mode theme 212 + type: string (at-uri) 213 + defaultDarkTheme: # AT-URI of the default dark-mode theme 214 + type: string (at-uri) 215 + allowUserChoice: # Can users pick their own theme? 216 + type: boolean 217 + default: true 218 + ``` 219 + 220 + The admin's saved themes may outnumber the available list — `availableThemes` is the curated subset exposed to users. Both `defaultLightTheme` and `defaultDarkTheme` must be members of `availableThemes`. 221 + 222 + ### Extended: `space.atbb.membership` 223 + 224 + Add an optional theme preference to the existing user membership record: 225 + 226 + ```yaml 227 + # New optional field on membership 228 + preferredTheme: 229 + type: string (at-uri, optional) # AT-URI of user's chosen theme, null = follow forum defaults 230 + ``` 231 + 232 + This lives on the **user's PDS** (they own their membership record), so theme preference is portable — leave a forum and rejoin, your preference is still there. 188 233 189 234 --- 190 235 ··· 193 238 The admin panel gets a theme management section: 194 239 195 240 ### Theme List View 196 - - Shows all saved themes with preview thumbnails 197 - - Toggle active theme 241 + - Shows all saved themes with preview thumbnails and `colorScheme` badges (light/dark) 198 242 - Create / duplicate / delete themes 243 + - **Availability toggles** — check themes on/off to control `themePolicy.availableThemes` 244 + - **Default assignment** — dropdown to pick the default light theme and default dark theme (must be from the available list) 245 + - **User choice kill-switch** — toggle for `themePolicy.allowUserChoice`. When off, all users see the forum defaults regardless of their membership preference. 199 246 200 247 ### Theme Editor View 201 248 - **Live preview panel** — shows a sample forum page with current token values ··· 204 251 - Typography tokens: font selector + size sliders 205 252 - Spacing tokens: numeric inputs with preview 206 253 - Component tokens: composite editors (shadow builder, border builder) 254 + - **Color scheme selector** — pick whether this theme targets `light` or `dark` mode 207 255 - **CSS overrides** — code editor (CodeMirror or similar) for advanced users 208 256 - **Font management** — add Google Fonts URLs or upload self-hosted fonts 209 257 - **Import/Export** — download theme as JSON, upload to share between forums ··· 212 260 ### Implementation Notes 213 261 - The editor itself is an HTMX-driven form. Token changes POST to the server, which returns an updated `<style>` block for the preview panel via an `hx-swap`. 214 262 - No client-side JS framework needed — HTMX + server rendering is sufficient for the live preview workflow. 215 - - Theme JSON import/export is just the `tokens` + `cssOverrides` + `fontUrls` fields serialized. 263 + - Theme JSON import/export is just the `tokens` + `cssOverrides` + `fontUrls` + `colorScheme` fields serialized. 264 + - Theme policy changes (defaults, available list, allowUserChoice) write back to the `forum.forum` record on the Forum DID's PDS. 265 + 266 + --- 267 + 268 + ## AppView API Endpoints 269 + 270 + Theme data flows through the AppView like all other forum data. New endpoints: 271 + 272 + ### Read Endpoints 273 + 274 + | Endpoint | Auth | Description | 275 + |----------|------|-------------| 276 + | `GET /api/themes` | Public | List available themes (filtered by `themePolicy.availableThemes`). Returns name, colorScheme, and token summary for each. | 277 + | `GET /api/themes/:rkey` | Public | Get a single theme's full token set, cssOverrides, and fontUrls. | 278 + | `GET /api/forum` (existing) | Public | Already returns forum metadata — now also includes `themePolicy` (defaults, available list, allowUserChoice). | 279 + 280 + ### Write Endpoints 281 + 282 + | Endpoint | Auth | Description | 283 + |----------|------|-------------| 284 + | `POST /api/themes` | Admin | Create a new theme record on Forum DID's PDS. | 285 + | `PUT /api/themes/:rkey` | Admin | Update an existing theme's tokens, name, colorScheme, etc. | 286 + | `DELETE /api/themes/:rkey` | Admin | Delete a theme. Fails if it's currently a default. | 287 + | `PUT /api/forum/theme-policy` | Admin | Update `themePolicy` on the forum record (available list, defaults, allowUserChoice). | 288 + | `PATCH /api/membership/theme` | User | Set `preferredTheme` on the caller's membership record (writes to their PDS). Pass `null` to clear. | 289 + 290 + ### Caching 291 + 292 + The web server caches resolved theme data aggressively since themes change rarely: 293 + 294 + - **Theme tokens:** Cached in-memory on the web server, keyed by AT-URI. Invalidated when the AppView receives a firehose event for `space.atbb.forum.theme` records. 295 + - **Theme policy:** Cached alongside forum metadata. Same invalidation path. 296 + - **User preference:** Looked up from the AppView's indexed `membership` records (local DB query, not a PDS fetch per request). 216 297 217 298 --- 218 299 219 300 ## Built-in Preset Themes 220 301 221 - Ship with a small set of presets that admins can use as starting points: 302 + Ship with a small set of presets that admins can use as starting points. Each preset ships with both a light and dark variant so forums have sensible defaults for both modes out of the box. 222 303 223 - | Preset | Description | 224 - |--------|-------------| 225 - | **Neobrutal** (default) | Bold borders, solid shadows, warm palette, sharp corners | 226 - | **Clean** | Minimal, airy, subtle shadows, rounded corners, neutral palette | 227 - | **Dark** | Dark backgrounds, muted borders, cool accent colors | 228 - | **Classic BB** | Nostalgic phpBB/vBulletin feel — blue headers, gray panels, small type | 304 + | Preset | Color Scheme | Description | 305 + |--------|-------------|-------------| 306 + | **Neobrutal Light** (default light) | light | Bold borders, solid shadows, warm off-white palette, sharp corners | 307 + | **Neobrutal Dark** (default dark) | dark | Same bold/chunky aesthetic on a dark background, muted shadows | 308 + | **Clean Light** | light | Minimal, airy, subtle shadows, rounded corners, neutral palette | 309 + | **Clean Dark** | dark | Soft dark surfaces, gentle borders, same airy spacing | 310 + | **Classic BB** | light | Nostalgic phpBB/vBulletin feel — blue headers, gray panels, small type | 229 311 230 - Each preset is a complete set of token values. Admins pick one, then customize from there. 312 + Each preset is a complete set of token values. Admins pick one as a starting point, then customize from there. A fresh forum defaults to Neobrutal Light + Neobrutal Dark with user choice enabled. 231 313 232 314 --- 233 315 ··· 241 323 reset.css # Minimal normalize/reset 242 324 theme.css # All component styles using var(--token) references 243 325 presets/ 244 - neobrutal.json # Token values for neobrutal preset 245 - clean.json # Token values for clean preset 246 - dark.json # Token values for dark preset 326 + neobrutal-light.json # Token values for neobrutal light preset 327 + neobrutal-dark.json # Token values for neobrutal dark preset 328 + clean-light.json # Token values for clean light preset 329 + clean-dark.json # Token values for clean dark preset 247 330 classic.json # Token values for classic BB preset 248 331 ``` 249 332 ··· 292 375 293 376 ```tsx 294 377 // Pseudocode — future implementation 295 - const BaseLayout: FC<PropsWithChildren<LayoutProps>> = (props) => ( 296 - <html lang="en"> 297 - <head> 298 - <title>{props.title ?? "atBB Forum"}</title> 299 - <style>{`:root { ${tokensToCss(props.theme.tokens)} }`}</style> 300 - <link rel="stylesheet" href="/static/reset.css" /> 301 - <link rel="stylesheet" href="/static/theme.css" /> 302 - {props.theme.fontUrls?.map(url => ( 303 - <link rel="stylesheet" href={url} /> 304 - ))} 305 - {props.theme.cssOverrides && ( 306 - <style>{props.theme.cssOverrides}</style> 307 - )} 308 - <script src="https://unpkg.com/htmx.org@2.0.4" /> 309 - </head> 310 - <body> 311 - {props.children} 312 - </body> 313 - </html> 314 - ); 378 + const BaseLayout: FC<PropsWithChildren<LayoutProps>> = (props) => { 379 + // Theme resolved server-side before render (see Theme Resolution section) 380 + const theme = props.resolvedTheme; 381 + const policy = props.themePolicy; 382 + 383 + return ( 384 + <html lang="en"> 385 + <head> 386 + <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" /> 387 + <title>{props.title ?? "atBB Forum"}</title> 388 + <style>{`:root { ${tokensToCss(theme.tokens)} }`}</style> 389 + <link rel="stylesheet" href="/static/reset.css" /> 390 + <link rel="stylesheet" href="/static/theme.css" /> 391 + {theme.fontUrls?.map(url => ( 392 + <link rel="stylesheet" href={url} /> 393 + ))} 394 + {theme.cssOverrides && ( 395 + <style>{theme.cssOverrides}</style> 396 + )} 397 + <script src="https://unpkg.com/htmx.org@2.0.4" /> 398 + </head> 399 + <body> 400 + <header> 401 + {/* ... nav ... */} 402 + <button onclick="toggleColorScheme()">Light/Dark</button> 403 + {policy.allowUserChoice && props.user && ( 404 + <ThemePicker 405 + themes={props.availableThemes} 406 + current={props.user.preferredTheme} 407 + /> 408 + )} 409 + </header> 410 + <main>{props.children}</main> 411 + </body> 412 + </html> 413 + ); 414 + }; 315 415 ``` 316 416 317 417 --- 318 418 319 - ## Multi-Theme / User Preference (Stretch) 419 + ## Theme Resolution 420 + 421 + When the web server handles a request, it resolves which theme to render using a waterfall: 422 + 423 + ``` 424 + 1. User preference 425 + Is the user logged in? 426 + AND has a preferredTheme set on their membership record? 427 + AND does the forum's themePolicy.allowUserChoice == true? 428 + AND is preferredTheme still in themePolicy.availableThemes? 429 + → Use their preferred theme. 430 + 431 + 2. Color scheme default 432 + Read color scheme preference: 433 + a. Cookie: atbb-color-scheme=light|dark 434 + b. HTTP header: Sec-CH-Prefers-Color-Scheme (client hint) 435 + c. Default: light 436 + 437 + → Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly. 438 + 439 + 3. Hardcoded fallback 440 + If no theme policy exists or the resolved theme can't be loaded: 441 + → Use the built-in neobrutal token values (no PDS needed, works offline). 442 + ``` 443 + 444 + This is entirely **server-side** — no client JS framework needed. The web server resolves the theme before rendering and bakes the correct tokens into the HTML response. 445 + 446 + ### Light/Dark Toggle 447 + 448 + The one piece of client interactivity that doesn't need HTMX — a vanilla JS color scheme toggle: 449 + 450 + ```html 451 + <!-- In the site header/footer --> 452 + <button onclick="toggleColorScheme()">Light/Dark</button> 320 453 321 - Beyond admin-set themes, a stretch goal is letting users pick from the forum's saved themes: 454 + <script> 455 + function toggleColorScheme() { 456 + const current = document.cookie.match(/atbb-color-scheme=(light|dark)/)?.[1] ?? 'light'; 457 + const next = current === 'light' ? 'dark' : 'light'; 458 + document.cookie = `atbb-color-scheme=${next};path=/;max-age=31536000`; 459 + location.reload(); 460 + } 461 + </script> 462 + ``` 322 463 323 - - Store preference in a cookie or as part of the user's `membership` record 324 - - Server reads preference and injects that theme's tokens instead of the forum default 325 - - Useful for light/dark mode toggle at minimum 464 + The `location.reload()` is intentional — it keeps the "server renders everything" model clean. The server re-renders the page with the other default theme's tokens. Could be enhanced later with HTMX to swap just the `<style>` block without a full reload. 465 + 466 + ### User Theme Picker 467 + 468 + For logged-in users (when `themePolicy.allowUserChoice` is true): 326 469 327 - This is secondary to the admin theme system and can come later. 470 + - A dropdown or modal in the user's settings area (or a compact picker in the site header) 471 + - Shows the admin's curated `availableThemes` list with names and color swatch previews 472 + - Selecting one writes `preferredTheme` to the user's membership record on their PDS 473 + - An **"Auto (follow forum default)"** option clears the preference, falling back to the light/dark defaults 474 + - Implemented as a standard HTMX form — `hx-patch` to the API, swap a success indicator 328 475 329 476 --- 330 477 ··· 334 481 335 482 ### Theme Phase 1: Foundation 336 483 - Add `reset.css` and `theme.css` with custom property references 337 - - Hardcode neobrutal tokens in `BaseLayout` as the default 484 + - Hardcode neobrutal light tokens in `BaseLayout` as the default 338 485 - Style all existing views (homepage, category, topic, compose, admin) 339 - - No admin editor yet — just ship a good-looking default 486 + - Add static file serving (`/static/` route via Hono `serveStatic`) 487 + - No admin editor, no dynamic themes — just ship a good-looking default 340 488 341 - ### Theme Phase 2: Token System 342 - - Define `space.atbb.forum.theme` lexicon 489 + ### Theme Phase 2: Light/Dark + Token System 490 + - Define `space.atbb.forum.theme` lexicon (with `colorScheme` field) 491 + - Add `themePolicy` to `space.atbb.forum.forum` lexicon 343 492 - Build `tokensToCss()` utility 344 - - Load active theme from Forum DID's PDS (with caching) 345 - - Ship built-in preset JSON files 346 - - Inject tokens dynamically in `BaseLayout` 493 + - Ship built-in preset JSON files (neobrutal light + dark, clean light + dark, classic) 494 + - Load theme policy + resolved theme from Forum DID's PDS (with caching) 495 + - Inject tokens dynamically in `BaseLayout` based on theme resolution waterfall 496 + - Add light/dark toggle (cookie-based, vanilla JS, ~6 lines) 497 + - Add `Sec-CH-Prefers-Color-Scheme` client hint support as fallback 498 + - AppView endpoints: `GET /api/themes`, `GET /api/themes/:rkey` 347 499 348 - ### Theme Phase 3: Admin Editor 349 - - Theme list management UI 350 - - Token editor with grouped controls 351 - - Live preview via HTMX 352 - - Import/export functionality 500 + ### Theme Phase 3: Admin Theme Management 501 + - Admin endpoints: `POST/PUT/DELETE /api/themes`, `PUT /api/forum/theme-policy` 502 + - Theme list management UI (create, duplicate, delete, availability toggles) 503 + - Default light/dark assignment dropdowns 504 + - `allowUserChoice` kill-switch toggle 505 + - Token editor with grouped controls + live preview via HTMX 506 + - Import/export (JSON download/upload) 353 507 354 - ### Theme Phase 4: Polish 355 - - User theme preference (light/dark toggle) 356 - - CSS override editor for advanced admins 357 - - Theme sharing between forums 508 + ### Theme Phase 4: User Choice 509 + - Add `preferredTheme` field to `space.atbb.membership` lexicon 510 + - User endpoint: `PATCH /api/membership/theme` 511 + - Theme picker UI for logged-in users (dropdown in settings or site header) 512 + - "Auto (follow forum default)" option to clear preference 513 + - AppView indexes `preferredTheme` from membership records for fast lookup 514 + 515 + ### Theme Phase 5: Polish 516 + - CSS override editor for advanced admins (with sanitization) 358 517 - Preset gallery expansion 518 + - Theme sharing between forums (export includes metadata for discovery) 519 + - HTMX-based theme swap without full page reload (stretch) 359 520 360 521 --- 361 522 ··· 364 525 1. **Font loading strategy.** Google Fonts is easy but has privacy implications for self-hosters. Should we bundle a few default fonts, allow self-hosted uploads, or both? 365 526 2. **CSS override sandboxing.** Raw CSS in `cssOverrides` could break layouts or introduce XSS (via `url()`, `expression()`, etc.). Need a sanitization strategy — maybe a CSS parser that strips dangerous properties. 366 527 3. **Theme record size limits.** AT Proto records have size limits. If `cssOverrides` gets large, might need to store it as a blob reference instead of inline. 367 - 4. **Static asset serving.** Currently no static file serving in the web package. Need to add a `/static/` route (Hono has `serveStatic` middleware) before any CSS files can be loaded. 368 - 5. **Build-time vs. runtime tokens.** The plan above is fully runtime (no CSS build per theme). This is simpler but means we can't use tools like Tailwind for the base styles. Is that acceptable? 528 + 4. **Build-time vs. runtime tokens.** The plan above is fully runtime (no CSS build per theme). This is simpler but means we can't use tools like Tailwind for the base styles. Is that acceptable? 529 + 5. **Theme migration on updates.** When atBB ships new tokens in a release (e.g., a new component token), existing saved themes won't have values for them. Need a merge strategy — probably fall back to the preset's value for any missing token. 530 + 6. **Stale user preferences.** If an admin removes a theme from `availableThemes` while users have it as their `preferredTheme`, the resolution waterfall handles this gracefully (falls through to the color scheme default). But should we also notify affected users or silently degrade?