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

Merge pull request #2 from malpercio-dev/claude/plan-theming-system-Oa93y

Add theming system design plan

authored by

Malpercio and committed by
GitHub
c4e82d07 30d024c1

+555
+555
docs/theming-plan.md
···
··· 1 + # Theming System — Design Plan 2 + 3 + **Status:** Future (post-MVP) 4 + **Package:** `@atbb/web` 5 + **Goal:** Admin-customizable forum themes with a neobrutal default, inspired by phpBB's theme flexibility. 6 + 7 + --- 8 + 9 + ## Design Principles 10 + 11 + 1. **Admin control without code.** Forum admins pick a theme or tweak design tokens from an admin panel — no CSS authoring needed. 12 + 2. **Theme-as-data.** Themes are serializable JSON stored as AT Proto records on the Forum DID's PDS, making them portable and versionable. 13 + 3. **Progressive enhancement.** The base HTML is semantic and readable with no styles at all (it already is). Themes layer on top via CSS custom properties. If CSS fails to load, the forum is still usable. 14 + 4. **No build step per theme.** Themes are applied at runtime through CSS custom properties and a server-rendered `<style>` block. No per-theme CSS compilation. 15 + 5. **Neobrutal default.** The built-in theme uses a neobrutal aesthetic: bold borders, solid shadows, high contrast, punchy accent colors, chunky type. 16 + 17 + --- 18 + 19 + ## Architecture 20 + 21 + ### How Themes Work 22 + 23 + ``` 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.themePolicy│ │ 2. Color scheme (cookie/header) │ 30 + │ (singleton) { │──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 + └─────────────────────────────┘ └───────────────────────────────────┘ 37 + ``` 38 + 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** is a separate singleton record (`space.atbb.forum.themePolicy`) that 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. 44 + 45 + ### Theme Layers 46 + 47 + ``` 48 + Layer 0: Reset / Normalize (minimal, ships with @atbb/web) 49 + Layer 1: Base component styles (theme.css — uses only custom properties) 50 + Layer 2: Design tokens (<style>:root { --color-bg: ... }) 51 + Layer 3: Per-theme CSS overrides (optional structural tweaks) 52 + ``` 53 + 54 + --- 55 + 56 + ## Design Token Schema 57 + 58 + Themes are defined as a flat set of design tokens. The token names map 1:1 to CSS custom properties. 59 + 60 + ### Color Tokens 61 + 62 + | Token | Description | Neobrutal Default | 63 + |-------|-------------|-------------------| 64 + | `color-bg` | Page background | `#f5f0e8` (warm off-white) | 65 + | `color-surface` | Card/panel background | `#ffffff` | 66 + | `color-text` | Primary text | `#1a1a1a` | 67 + | `color-text-muted` | Secondary/meta text | `#555555` | 68 + | `color-primary` | Primary accent (links, buttons) | `#ff5c00` (bold orange) | 69 + | `color-primary-hover` | Primary accent hover state | `#e04f00` | 70 + | `color-secondary` | Secondary accent | `#3a86ff` (vivid blue) | 71 + | `color-border` | Border color | `#1a1a1a` (black) | 72 + | `color-shadow` | Box-shadow color | `#1a1a1a` | 73 + | `color-success` | Success/positive | `#2ec44a` | 74 + | `color-warning` | Warning | `#ffbe0b` | 75 + | `color-danger` | Danger/destructive | `#ff006e` | 76 + | `color-code-bg` | Code block background | `#1a1a1a` | 77 + | `color-code-text` | Code block text | `#f5f0e8` | 78 + 79 + ### Typography Tokens 80 + 81 + | Token | Description | Neobrutal Default | 82 + |-------|-------------|-------------------| 83 + | `font-body` | Body font stack | `'Space Grotesk', system-ui, sans-serif` | 84 + | `font-heading` | Heading font stack | `'Space Grotesk', system-ui, sans-serif` | 85 + | `font-mono` | Monospace font stack | `'JetBrains Mono', ui-monospace, monospace` | 86 + | `font-size-base` | Base font size | `16px` | 87 + | `font-size-sm` | Small text | `14px` | 88 + | `font-size-lg` | Large text | `20px` | 89 + | `font-size-xl` | XL text (headings) | `28px` | 90 + | `font-size-2xl` | 2XL text (page titles) | `36px` | 91 + | `font-weight-normal` | Normal weight | `400` | 92 + | `font-weight-bold` | Bold weight | `700` | 93 + | `line-height-body` | Body line height | `1.6` | 94 + | `line-height-heading` | Heading line height | `1.2` | 95 + 96 + ### Spacing & Layout Tokens 97 + 98 + | Token | Description | Neobrutal Default | 99 + |-------|-------------|-------------------| 100 + | `space-xs` | Extra-small spacing | `4px` | 101 + | `space-sm` | Small spacing | `8px` | 102 + | `space-md` | Medium spacing | `16px` | 103 + | `space-lg` | Large spacing | `24px` | 104 + | `space-xl` | Extra-large spacing | `40px` | 105 + | `radius` | Border radius | `0px` (sharp corners — neobrutal) | 106 + | `border-width` | Default border width | `3px` (chunky — neobrutal) | 107 + | `shadow-offset` | Box-shadow offset | `4px` (solid offset shadows) | 108 + | `content-width` | Max content width | `960px` | 109 + 110 + ### Component-Level Tokens 111 + 112 + | Token | Description | Neobrutal Default | 113 + |-------|-------------|-------------------| 114 + | `button-radius` | Button border radius | `0px` | 115 + | `button-shadow` | Button box-shadow | `4px 4px 0 var(--color-shadow)` | 116 + | `card-radius` | Card border radius | `0px` | 117 + | `card-shadow` | Card box-shadow | `6px 6px 0 var(--color-shadow)` | 118 + | `input-radius` | Input border radius | `0px` | 119 + | `input-border` | Input border | `3px solid var(--color-border)` | 120 + | `nav-height` | Navigation bar height | `64px` | 121 + 122 + --- 123 + 124 + ## Neobrutal Default Theme — Design Direction 125 + 126 + The neobrutal aesthetic is characterized by: 127 + 128 + - **Thick black borders** on cards, buttons, and inputs (3px+) 129 + - **Solid offset box-shadows** instead of soft/blurred shadows (e.g., `4px 4px 0 #1a1a1a`) 130 + - **Sharp corners** (border-radius: 0) or very slight rounding 131 + - **High contrast** color palette — dark text on light backgrounds, bold accent colors 132 + - **Punchy accent colors** — saturated oranges, blues, pinks (not pastels) 133 + - **Chunky, confident typography** — geometric sans-serifs, generous sizing 134 + - **Flat color fills** — no gradients, no translucency 135 + - **Deliberate "roughness"** — the UI looks intentionally bold and unpolished, like a zine or poster 136 + 137 + ### Visual Reference Points 138 + 139 + - [Gumroad's redesign](https://gumroad.com) — the canonical neobrutal web product 140 + - [Figma neobrutal UI kits](https://www.figma.com/community/tag/neobrutalism) — community references 141 + - The general energy: "Web Brutalism meets a friendly color palette" 142 + 143 + ### Layout Sketch 144 + 145 + ``` 146 + ┌─────────────────────────────────────────────────────┐ 147 + │ ██ atBB Forum Name [Login] │ <- thick bottom border 148 + ├─────────────────────────────────────────────────────┤ 149 + │ │ 150 + │ ┌─────────────────────────────────────────────┐ │ 151 + │ │ 📁 Category Name 12 topics│ │ <- solid shadow card 152 + │ │ Description of the category... │ │ 153 + │ └──┬──┬───────────────────────────────────────┘ │ 154 + │ └──┘ (offset shadow) │ 155 + │ │ 156 + │ ┌─────────────────────────────────────────────┐ │ 157 + │ │ 📁 Another Category 8 topics│ │ 158 + │ │ Another description here... │ │ 159 + │ └──┬──┬───────────────────────────────────────┘ │ 160 + │ └──┘ │ 161 + │ │ 162 + │ ┌──────────────────┐ │ 163 + │ │ [+ New Topic] │ <- bold button w/ shadow │ 164 + │ └──┬──┬────────────┘ │ 165 + │ └──┘ │ 166 + │ │ 167 + ├─────────────────────────────────────────────────────┤ 168 + │ Powered by atBB on the ATmosphere │ 169 + └─────────────────────────────────────────────────────┘ 170 + ``` 171 + 172 + --- 173 + 174 + ## Lexicon Changes 175 + 176 + ### New: `space.atbb.forum.theme` 177 + 178 + A new record type on the Forum DID for storing theme configuration. 179 + 180 + ```yaml 181 + lexiconId: space.atbb.forum.theme 182 + key: tid # Multiple themes per forum 183 + fields: 184 + name: string (required) # "Neobrutal Default", "Dark Mode", etc. 185 + colorScheme: 186 + type: string (required) 187 + knownValues: ["light", "dark"] # Which mode this theme targets (extensible) 188 + tokens: map<string, string> # Design token key-value pairs 189 + cssOverrides: string (optional)# Raw CSS for structural overrides 190 + fontUrls: array<string> (opt) # HTTPS URLs for Google Fonts or self-hosted fonts 191 + createdAt: datetime 192 + updatedAt: datetime 193 + ``` 194 + 195 + **Record ownership:** Forum DID (same as `forum.forum`, `forum.category`). When implemented, `forum.theme` must be added to the canonical ownership list in CLAUDE.md. 196 + 197 + **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. 198 + 199 + **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 theme policy handles the rest. 200 + 201 + ### New: `space.atbb.forum.themePolicy` 202 + 203 + A new singleton record on the Forum DID for theme configuration, separate from the main forum record to allow independent updates without invalidating `strongRef`s to the forum record. 204 + 205 + ```yaml 206 + lexiconId: space.atbb.forum.themePolicy 207 + key: literal:self # Singleton — one per forum 208 + 209 + # Named def for theme references 210 + defs: 211 + themeRef: 212 + type: object 213 + required: [theme] 214 + properties: 215 + theme: 216 + type: ref 217 + ref: com.atproto.repo.strongRef # CID integrity check for theme records 218 + 219 + fields: 220 + availableThemes: # Themes admins have enabled for users 221 + type: array 222 + items: 223 + type: ref 224 + ref: '#themeRef' 225 + defaultLightTheme: # Default light-mode theme 226 + type: ref 227 + ref: '#themeRef' 228 + defaultDarkTheme: # Default dark-mode theme 229 + type: ref 230 + ref: '#themeRef' 231 + allowUserChoice: # Can users pick their own theme? 232 + type: boolean 233 + default: true 234 + updatedAt: datetime 235 + ``` 236 + 237 + **Record ownership:** Forum DID. 238 + 239 + 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`. 240 + 241 + ### Extended: `space.atbb.membership` 242 + 243 + Add an optional theme preference to the existing user membership record: 244 + 245 + ```yaml 246 + # New optional field on membership 247 + preferredTheme: 248 + type: ref (optional) 249 + ref: com.atproto.repo.strongRef # strongRef to space.atbb.forum.theme record 250 + # Null = follow forum defaults 251 + ``` 252 + 253 + 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. Uses `strongRef` for CID integrity — if the theme record is updated, stale preferences are detected. 254 + 255 + --- 256 + 257 + ## Admin Theme Editor 258 + 259 + The admin panel gets a theme management section: 260 + 261 + ### Theme List View 262 + - Shows all saved themes with preview thumbnails and `colorScheme` badges (light/dark) 263 + - Create / duplicate / delete themes 264 + - **Availability toggles** — check themes on/off to control `themePolicy.availableThemes` 265 + - **Default assignment** — dropdown to pick the default light theme and default dark theme (must be from the available list) 266 + - **User choice kill-switch** — toggle for `themePolicy.allowUserChoice`. When off, all users see the forum defaults regardless of their membership preference. 267 + 268 + ### Theme Editor View 269 + - **Live preview panel** — shows a sample forum page with current token values 270 + - **Token editor** — grouped by category (colors, typography, spacing, components) 271 + - Color tokens: color picker inputs 272 + - Typography tokens: font selector + size sliders 273 + - Spacing tokens: numeric inputs with preview 274 + - Component tokens: composite editors (shadow builder, border builder) 275 + - **Color scheme selector** — pick whether this theme targets `light` or `dark` mode 276 + - **CSS overrides** — code editor (CodeMirror or similar) for advanced users 277 + - **Font management** — add Google Fonts URLs or upload self-hosted fonts 278 + - **Import/Export** — download theme as JSON, upload to share between forums 279 + - **Preset gallery** — start from built-in presets (Neobrutal, Clean, Dark, Classic BB) 280 + 281 + ### Implementation Notes 282 + - 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`. 283 + - No client-side JS framework needed — HTMX + server rendering is sufficient for the live preview workflow. 284 + - Theme JSON import/export is just the `tokens` + `cssOverrides` + `fontUrls` + `colorScheme` fields serialized. 285 + - Theme policy changes (defaults, available list, allowUserChoice) write to the `space.atbb.forum.themePolicy` singleton on the Forum DID's PDS. 286 + 287 + --- 288 + 289 + ## AppView API Endpoints 290 + 291 + Theme data flows through the AppView like all other forum data. New endpoints (AppView REST, not XRPC — consistent with existing `/api/forum`, `/api/categories` patterns): 292 + 293 + ### Read Endpoints 294 + 295 + | Endpoint | Auth | Description | 296 + |----------|------|-------------| 297 + | `GET /api/themes` | Public | List available themes (filtered by `themePolicy.availableThemes`). Returns name, colorScheme, and token summary for each. | 298 + | `GET /api/themes/:rkey` | Public | Get a single theme's full token set, cssOverrides, and fontUrls. | 299 + | `GET /api/theme-policy` | Public | Get the forum's theme policy (available themes, defaults for light/dark, allowUserChoice). | 300 + 301 + ### Write Endpoints 302 + 303 + | Endpoint | Auth | Description | 304 + |----------|------|-------------| 305 + | `POST /api/themes` | Admin | Create a new theme record on Forum DID's PDS. | 306 + | `PUT /api/themes/:rkey` | Admin | Update an existing theme's tokens, name, colorScheme, etc. | 307 + | `DELETE /api/themes/:rkey` | Admin | Delete a theme. Fails if it's currently a default. | 308 + | `PUT /api/theme-policy` | Admin | Update the `themePolicy` singleton (available list, defaults, allowUserChoice). | 309 + | `PATCH /api/membership/theme` | User | Set `preferredTheme` on the caller's membership record (writes to their PDS). Pass `null` to clear. | 310 + 311 + ### Caching 312 + 313 + The web server caches resolved theme data aggressively since themes change rarely: 314 + 315 + - **Theme tokens:** Cached in-memory on the web server, keyed by AT-URI **and resolved color scheme** (light/dark). Cache key must include color scheme to prevent serving a cached light response to dark-mode users. Use `Vary: Cookie` or equivalent for HTTP caching. 316 + - **Theme policy:** Cached alongside forum metadata. Same invalidation path. 317 + - **User preference:** Looked up from the AppView's indexed `membership` records (local DB query, not a PDS fetch per request). 318 + 319 + --- 320 + 321 + ## Built-in Preset Themes 322 + 323 + 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. 324 + 325 + | Preset | Color Scheme | Description | 326 + |--------|-------------|-------------| 327 + | **Neobrutal Light** (default light) | light | Bold borders, solid shadows, warm off-white palette, sharp corners | 328 + | **Neobrutal Dark** (default dark) | dark | Same bold/chunky aesthetic on a dark background, muted shadows | 329 + | **Clean Light** | light | Minimal, airy, subtle shadows, rounded corners, neutral palette | 330 + | **Clean Dark** | dark | Soft dark surfaces, gentle borders, same airy spacing | 331 + | **Classic BB** | light | Nostalgic phpBB/vBulletin feel — blue headers, gray panels, small type | 332 + 333 + 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. 334 + 335 + --- 336 + 337 + ## CSS Architecture 338 + 339 + ### File Structure (future) 340 + 341 + ``` 342 + packages/web/src/ 343 + styles/ 344 + reset.css # Minimal normalize/reset 345 + theme.css # All component styles using var(--token) references 346 + presets/ 347 + neobrutal-light.json # Token values for neobrutal light preset 348 + neobrutal-dark.json # Token values for neobrutal dark preset 349 + clean-light.json # Token values for clean light preset 350 + clean-dark.json # Token values for clean dark preset 351 + classic.json # Token values for classic BB preset 352 + ``` 353 + 354 + ### Base Stylesheet Approach 355 + 356 + `theme.css` is written once and never changes per-theme. It references custom properties exclusively: 357 + 358 + ```css 359 + /* Example — not final */ 360 + body { 361 + font-family: var(--font-body); 362 + font-size: var(--font-size-base); 363 + line-height: var(--line-height-body); 364 + color: var(--color-text); 365 + background: var(--color-bg); 366 + } 367 + 368 + .card { 369 + background: var(--color-surface); 370 + border: var(--border-width) solid var(--color-border); 371 + border-radius: var(--card-radius); 372 + box-shadow: var(--card-shadow); 373 + padding: var(--space-md); 374 + } 375 + 376 + .btn-primary { 377 + background: var(--color-primary); 378 + color: var(--color-surface); 379 + border: var(--border-width) solid var(--color-border); 380 + border-radius: var(--button-radius); 381 + box-shadow: var(--button-shadow); 382 + font-weight: var(--font-weight-bold); 383 + padding: var(--space-sm) var(--space-md); 384 + } 385 + 386 + .btn-primary:hover { 387 + background: var(--color-primary-hover); 388 + transform: translate(2px, 2px); 389 + box-shadow: 2px 2px 0 var(--color-shadow); 390 + } 391 + ``` 392 + 393 + ### Server-Side Token Injection 394 + 395 + `BaseLayout` renders the active theme's tokens as a `<style>` block: 396 + 397 + ```tsx 398 + // Pseudocode — future implementation 399 + const BaseLayout: FC<PropsWithChildren<LayoutProps>> = (props) => { 400 + // Theme resolved server-side before render (see Theme Resolution section) 401 + const theme = props.resolvedTheme; 402 + const policy = props.themePolicy; 403 + 404 + return ( 405 + <html lang="en"> 406 + <head> 407 + <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" /> 408 + <title>{props.title ?? "atBB Forum"}</title> 409 + <style>{`:root { ${tokensToCss(theme.tokens)} }`}</style> 410 + <link rel="stylesheet" href="/static/reset.css" /> 411 + <link rel="stylesheet" href="/static/theme.css" /> 412 + {theme.fontUrls?.map(url => ( 413 + <link rel="stylesheet" href={url} /> 414 + ))} 415 + {theme.cssOverrides && ( 416 + <style>{theme.cssOverrides}</style> 417 + )} 418 + <script src="https://unpkg.com/htmx.org@2.0.4" /> 419 + </head> 420 + <body> 421 + <header> 422 + {/* ... nav ... */} 423 + <button onclick="toggleColorScheme()">Light/Dark</button> 424 + {policy.allowUserChoice && props.user && ( 425 + <ThemePicker 426 + themes={props.availableThemes} 427 + current={props.user.preferredTheme} 428 + /> 429 + )} 430 + </header> 431 + <main>{props.children}</main> 432 + </body> 433 + </html> 434 + ); 435 + }; 436 + ``` 437 + 438 + --- 439 + 440 + ## Theme Resolution 441 + 442 + When the web server handles a request, it resolves which theme to render using a waterfall: 443 + 444 + ``` 445 + 1. User preference 446 + Is the user logged in? 447 + AND has a preferredTheme set on their membership record? 448 + AND does the forum's themePolicy.allowUserChoice == true? 449 + AND is preferredTheme.uri still in themePolicy.availableThemes? 450 + AND does preferredTheme.cid match current theme record (integrity check)? 451 + → Use their preferred theme. 452 + 453 + 2. Color scheme default 454 + Read color scheme preference: 455 + a. Cookie: atbb-color-scheme=light|dark 456 + b. HTTP header: Sec-CH-Prefers-Color-Scheme (client hint) 457 + c. Default: light 458 + 459 + → Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly 460 + (with CID integrity check via strongRef). 461 + 462 + 3. Hardcoded fallback 463 + If no theme policy exists or the resolved theme can't be loaded: 464 + → Use the built-in neobrutal token values (no PDS needed, works offline). 465 + ``` 466 + 467 + 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. 468 + 469 + ### Light/Dark Toggle 470 + 471 + The one piece of client interactivity that doesn't need HTMX — a vanilla JS color scheme toggle: 472 + 473 + ```html 474 + <!-- In the site header/footer --> 475 + <button onclick="toggleColorScheme()">Light/Dark</button> 476 + 477 + <script> 478 + function toggleColorScheme() { 479 + const current = document.cookie.match(/atbb-color-scheme=(light|dark)/)?.[1] ?? 'light'; 480 + const next = current === 'light' ? 'dark' : 'light'; 481 + document.cookie = `atbb-color-scheme=${next};path=/;max-age=31536000`; 482 + location.reload(); 483 + } 484 + </script> 485 + ``` 486 + 487 + 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. 488 + 489 + ### User Theme Picker 490 + 491 + For logged-in users (when `themePolicy.allowUserChoice` is true): 492 + 493 + - A dropdown or modal in the user's settings area (or a compact picker in the site header) 494 + - Shows the admin's curated `availableThemes` list with names and color swatch previews 495 + - Selecting one writes `preferredTheme` to the user's membership record on their PDS 496 + - An **"Auto (follow forum default)"** option clears the preference, falling back to the light/dark defaults 497 + - Implemented as a standard HTMX form — `hx-patch` to the API, swap a success indicator 498 + 499 + --- 500 + 501 + ## Implementation Phases 502 + 503 + This work is post-MVP. Suggested ordering: 504 + 505 + ### Theme Phase 1: Foundation 506 + - Add `reset.css` and `theme.css` with custom property references 507 + - Hardcode neobrutal light tokens in `BaseLayout` as the default 508 + - Style all existing views (homepage, category, topic, compose, admin) 509 + - Add static file serving (`/static/` route via Hono `serveStatic`) 510 + - No admin editor, no dynamic themes — just ship a good-looking default 511 + 512 + ### Theme Phase 2: Light/Dark + Token System 513 + - Define `space.atbb.forum.theme` lexicon (with `colorScheme` field using `knownValues`) 514 + - Define `space.atbb.forum.themePolicy` lexicon as separate singleton (with `themeRef` strongRef wrapper) 515 + - Build `tokensToCss()` utility 516 + - Ship built-in preset JSON files (neobrutal light + dark, clean light + dark, classic) 517 + - Load theme policy + resolved theme from Forum DID's PDS (with caching) 518 + - Inject tokens dynamically in `BaseLayout` based on theme resolution waterfall 519 + - Add light/dark toggle (cookie-based, vanilla JS, ~6 lines) 520 + - Add `Sec-CH-Prefers-Color-Scheme` client hint support as fallback 521 + - AppView endpoints: `GET /api/themes`, `GET /api/themes/:rkey`, `GET /api/theme-policy` (REST, not XRPC) 522 + 523 + ### Theme Phase 3: Admin Theme Management 524 + - **CSS sanitization (mandatory gate):** Server-side sanitization for `cssOverrides` and freeform `tokens` map before rendering. Raw CSS in `<style>` tags is a real exfiltration vector via `url()`, `@import`, `@font-face`. Must sanitize before this phase ships. 525 + - Admin endpoints: `POST/PUT/DELETE /api/themes`, `PUT /api/forum/theme-policy` (AppView REST, not XRPC) 526 + - Theme list management UI (create, duplicate, delete, availability toggles) 527 + - Default light/dark assignment dropdowns 528 + - `allowUserChoice` kill-switch toggle 529 + - Token editor with grouped controls + live preview via HTMX 530 + - Import/export (JSON download/upload) 531 + - Database additions: `themes` table following `(did, rkey, cid, indexed_at)` pattern 532 + 533 + ### Theme Phase 4: User Choice 534 + - Add `preferredTheme` field to `space.atbb.membership` lexicon 535 + - User endpoint: `PATCH /api/membership/theme` (AppView REST, not XRPC) 536 + - Theme picker UI for logged-in users (dropdown in settings or site header) 537 + - "Auto (follow forum default)" option to clear preference 538 + - Database additions: `preferred_theme_uri` column on `memberships` table (nullable) 539 + - AppView indexes `preferredTheme` from membership records for fast lookup 540 + 541 + ### Theme Phase 5: Polish 542 + - CSS override editor for advanced admins (with sanitization) 543 + - Preset gallery expansion 544 + - Theme sharing between forums (export includes metadata for discovery) 545 + - HTMX-based theme swap without full page reload (stretch) 546 + 547 + --- 548 + 549 + ## Open Questions 550 + 551 + 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? **Security note:** `fontUrls` must be constrained to HTTPS and consider an allowlist (Google Fonts, self-hosted paths) — arbitrary font URLs leak user IPs to third parties. 552 + 2. **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. 553 + 3. **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? 554 + 4. **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. 555 + 5. **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?