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
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 111. **Admin control without code.** Forum admins pick a theme or tweak design tokens from an admin panel — no CSS authoring needed. 122. **Theme-as-data.** Themes are serializable JSON stored as AT Proto records on the Forum DID's PDS, making them portable and versionable. 133. **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. 144. **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. 155. **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``` 24Forum 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 391. **Theme records** live on the Forum DID's PDS as `space.atbb.forum.theme` records. A forum can have many saved themes. 402. **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. 413. **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>`. 424. **A single base stylesheet** (`theme.css`) references only custom properties — never hardcoded colors or sizes. Swapping property values completely changes the look. 435. **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``` 48Layer 0: Reset / Normalize (minimal, ships with @atbb/web) 49Layer 1: Base component styles (theme.css — uses only custom properties) 50Layer 2: Design tokens (<style>:root { --color-bg: ... }) 51Layer 3: Per-theme CSS overrides (optional structural tweaks) 52``` 53 54--- 55 56## Design Token Schema 57 58Themes 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 126The 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 178A new record type on the Forum DID for storing theme configuration. 179 180```yaml 181lexiconId: space.atbb.forum.theme 182key: tid # Multiple themes per forum 183fields: 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 203A new singleton record on the Forum DID for theme configuration, separate from the main forum record to allow independent updates without invalidating references to the forum record. 204 205```yaml 206lexiconId: space.atbb.forum.themePolicy 207key: literal:self # Singleton — one per forum 208 209# Named def for theme references 210defs: 211 themeRef: 212 type: object 213 required: [uri] 214 properties: 215 uri: 216 type: string 217 format: at-uri # AT-URI of the space.atbb.forum.theme record 218 cid: 219 type: string 220 format: cid # Optional. When present: pins to exact version (strong). 221 # When absent: resolves live current record (weak). 222 223fields: 224 availableThemes: # Themes admins have enabled for users 225 type: array 226 items: 227 type: ref 228 ref: '#themeRef' 229 defaultLightTheme: # Default light-mode theme 230 type: ref 231 ref: '#themeRef' 232 defaultDarkTheme: # Default dark-mode theme 233 type: ref 234 ref: '#themeRef' 235 allowUserChoice: # Can users pick their own theme? 236 type: boolean 237 default: true 238 updatedAt: datetime 239``` 240 241**Record ownership:** Forum DID. 242 243The `cid` field is intentionally optional. The default `themePolicy` shipped by atBB uses URI-only references to canonical preset records on `atbb.space` — forums pick up preset updates automatically after cache expiry. Operators who want version stability can pin a theme to its current CID via the admin UI ("Pin to version"). The appview validates CID on fetch when present; a mismatch falls through to the next step in the resolution waterfall. 244 245| Reference type | `themeRef` shape | Behavior | 246|---|---|---| 247| Live | `{ uri: "at://atbb.space/..." }` | Auto-updates when atbb.space publishes new preset versions | 248| Pinned | `{ uri: "at://atbb.space/...", cid: "bafyrei..." }` | Locked to exact version; admin sees warning when a newer version exists | 249| Local copy | `{ uri: "at://forum-did/..." }` | Full autonomy, no external dependency | 250 251The 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`. 252 253### Extended: `space.atbb.membership` 254 255Add an optional theme preference to the existing user membership record: 256 257```yaml 258# New optional field on membership 259preferredTheme: 260 type: ref (optional) 261 ref: com.atproto.repo.strongRef # strongRef to space.atbb.forum.theme record 262 # Null = follow forum defaults 263``` 264 265This 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. 266 267--- 268 269## Admin Theme Editor 270 271The admin panel gets a theme management section: 272 273### Theme List View 274- Shows all saved themes with preview thumbnails and `colorScheme` badges (light/dark) 275- Create / duplicate / delete themes 276- **Availability toggles** — check themes on/off to control `themePolicy.availableThemes` 277- **Default assignment** — dropdown to pick the default light theme and default dark theme (must be from the available list) 278- **User choice kill-switch** — toggle for `themePolicy.allowUserChoice`. When off, all users see the forum defaults regardless of their membership preference. 279 280### Theme Editor View 281- **Live preview panel** — shows a sample forum page with current token values 282- **Token editor** — grouped by category (colors, typography, spacing, components) 283 - Color tokens: color picker inputs 284 - Typography tokens: font selector + size sliders 285 - Spacing tokens: numeric inputs with preview 286 - Component tokens: composite editors (shadow builder, border builder) 287- **Color scheme selector** — pick whether this theme targets `light` or `dark` mode 288- **CSS overrides** — code editor (CodeMirror or similar) for advanced users 289- **Font management** — add Google Fonts URLs or upload self-hosted fonts 290- **Import/Export** — download theme as JSON, upload to share between forums 291- **Preset gallery** — start from built-in presets (Neobrutal, Clean, Dark, Classic BB) 292 293### Implementation Notes 294- 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`. 295- No client-side JS framework needed — HTMX + server rendering is sufficient for the live preview workflow. 296- Theme JSON import/export is just the `tokens` + `cssOverrides` + `fontUrls` + `colorScheme` fields serialized. 297- Theme policy changes (defaults, available list, allowUserChoice) write to the `space.atbb.forum.themePolicy` singleton on the Forum DID's PDS. 298 299--- 300 301## AppView API Endpoints 302 303Theme data flows through the AppView like all other forum data. New endpoints (AppView REST, not XRPC — consistent with existing `/api/forum`, `/api/categories` patterns): 304 305### Read Endpoints 306 307| Endpoint | Auth | Description | 308|----------|------|-------------| 309| `GET /api/themes` | Public | List available themes (filtered by `themePolicy.availableThemes`). Returns name, colorScheme, and token summary for each. | 310| `GET /api/themes/:rkey` | Public | Get a single theme's full token set, cssOverrides, and fontUrls. | 311| `GET /api/theme-policy` | Public | Get the forum's theme policy (available themes, defaults for light/dark, allowUserChoice). | 312 313### Write Endpoints 314 315| Endpoint | Auth | Description | 316|----------|------|-------------| 317| `POST /api/themes` | Admin | Create a new theme record on Forum DID's PDS. | 318| `PUT /api/themes/:rkey` | Admin | Update an existing theme's tokens, name, colorScheme, etc. | 319| `DELETE /api/themes/:rkey` | Admin | Delete a theme. Fails if it's currently a default. | 320| `PUT /api/theme-policy` | Admin | Update the `themePolicy` singleton (available list, defaults, allowUserChoice). | 321| `PATCH /api/membership/theme` | User | Set `preferredTheme` on the caller's membership record (writes to their PDS). Pass `null` to clear. | 322 323### Caching 324 325The web server caches resolved theme data aggressively since themes change rarely: 326 327- **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. 328- **Theme policy:** Cached alongside forum metadata. Same invalidation path. 329- **User preference:** Looked up from the AppView's indexed `membership` records (local DB query, not a PDS fetch per request). 330 331--- 332 333## Built-in Preset Themes 334 335Built-in presets ship as **canonical records on the `atbb.space` PDS**, published and maintained by the atBB project. Fresh installations reference these canonical records in their default `themePolicy` using live (URI-only) `themeRef`s — no local seeding step required. 336 337| Preset | Color Scheme | Canonical rkey | 338|--------|-------------|----------------| 339| **Neobrutal Light** (default light) | light | `neobrutal-light` | 340| **Neobrutal Dark** (default dark) | dark | `neobrutal-dark` | 341| **Clean Light** | light | `clean-light` | 342| **Clean Dark** | dark | `clean-dark` | 343| **Classic BB** | light | `classic-bb` | 344 345Canonical URIs follow the pattern: `at://did:web:atbb.space/space.atbb.forum.theme/<rkey>` 346 347Each 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. 348 349The bundled JSON files in `apps/web/src/styles/presets/` serve two purposes: 3501. **Hardcoded fallback** — used directly if no theme policy can be resolved (no network, no PDS write yet). 3512. **Deployment pipeline source** — the release script reads these files and writes them to `atbb.space`'s PDS. 352 353### Deployment Pipeline 354 355When atBB releases a new version with updated preset tokens, a release script: 356 3571. Reads the bundled preset JSON files (canonical source of truth in the app repo) 3582. Writes them to `atbb.space`'s PDS under stable rkeys using the Forum DID's signing keys (same URI every release; new CID only when content changes) 3593. Skips records where token values are unchanged (idempotent) 360 361Forums using live (URI-only) references pick up changes after cache expiry. Forums using pinned (URI+CID) references remain on the locked version until the operator updates. 362 363### Local Escape Hatch 364 365Operators who want zero external dependencies run: 366 367```sh 368atbb bootstrap --local-presets 369``` 370 371This command: 372 3731. Reads the bundled preset JSON files 3742. Writes them to the forum's own PDS under the same stable rkeys 3753. Updates `themePolicy` to point at the local URIs instead of `atbb.space` 376 377After this, the forum is fully self-contained. The admin can still customize individual presets via the theme editor. 378 379--- 380 381## CSS Architecture 382 383### File Structure (future) 384 385``` 386packages/web/src/ 387 styles/ 388 reset.css # Minimal normalize/reset 389 theme.css # All component styles using var(--token) references 390 presets/ 391 neobrutal-light.json # Hardcoded fallback + deployment pipeline source 392 neobrutal-dark.json # Hardcoded fallback + deployment pipeline source 393 clean-light.json # Hardcoded fallback + deployment pipeline source 394 clean-dark.json # Hardcoded fallback + deployment pipeline source 395 classic-bb.json # Hardcoded fallback + deployment pipeline source 396``` 397 398The JSON files are **not** seeded into each forum's PDS on bootstrap. They serve as the hardcoded fallback (step 4 of the resolution waterfall) and as the source of truth for the release pipeline that publishes canonical records to `atbb.space`. See [Built-in Preset Themes](#built-in-preset-themes) for the full model. 399 400### Base Stylesheet Approach 401 402`theme.css` is written once and never changes per-theme. It references custom properties exclusively: 403 404```css 405/* Example — not final */ 406body { 407 font-family: var(--font-body); 408 font-size: var(--font-size-base); 409 line-height: var(--line-height-body); 410 color: var(--color-text); 411 background: var(--color-bg); 412} 413 414.card { 415 background: var(--color-surface); 416 border: var(--border-width) solid var(--color-border); 417 border-radius: var(--card-radius); 418 box-shadow: var(--card-shadow); 419 padding: var(--space-md); 420} 421 422.btn-primary { 423 background: var(--color-primary); 424 color: var(--color-surface); 425 border: var(--border-width) solid var(--color-border); 426 border-radius: var(--button-radius); 427 box-shadow: var(--button-shadow); 428 font-weight: var(--font-weight-bold); 429 padding: var(--space-sm) var(--space-md); 430} 431 432.btn-primary:hover { 433 background: var(--color-primary-hover); 434 transform: translate(2px, 2px); 435 box-shadow: 2px 2px 0 var(--color-shadow); 436} 437``` 438 439### Server-Side Token Injection 440 441`BaseLayout` renders the active theme's tokens as a `<style>` block: 442 443```tsx 444// Pseudocode — future implementation 445const BaseLayout: FC<PropsWithChildren<LayoutProps>> = (props) => { 446 // Theme resolved server-side before render (see Theme Resolution section) 447 const theme = props.resolvedTheme; 448 const policy = props.themePolicy; 449 450 return ( 451 <html lang="en"> 452 <head> 453 <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" /> 454 <title>{props.title ?? "atBB Forum"}</title> 455 <style>{`:root { ${tokensToCss(theme.tokens)} }`}</style> 456 <link rel="stylesheet" href="/static/reset.css" /> 457 <link rel="stylesheet" href="/static/theme.css" /> 458 {theme.fontUrls?.map(url => ( 459 <link rel="stylesheet" href={url} /> 460 ))} 461 {theme.cssOverrides && ( 462 <style>{theme.cssOverrides}</style> 463 )} 464 <script src="https://unpkg.com/htmx.org@2.0.4" /> 465 </head> 466 <body> 467 <header> 468 {/* ... nav ... */} 469 <button onclick="toggleColorScheme()">Light/Dark</button> 470 {policy.allowUserChoice && props.user && ( 471 <ThemePicker 472 themes={props.availableThemes} 473 current={props.user.preferredTheme} 474 /> 475 )} 476 </header> 477 <main>{props.children}</main> 478 </body> 479 </html> 480 ); 481}; 482``` 483 484--- 485 486## Theme Resolution 487 488When the web server handles a request, it resolves which theme to render using a waterfall: 489 490``` 4911. User preference 492 Is the user logged in? 493 AND has a preferredTheme set on their membership record? 494 AND does the forum's themePolicy.allowUserChoice == true? 495 AND is preferredTheme.uri still in themePolicy.availableThemes? 496 AND (if preferredTheme.cid is set) does cid match current theme record? 497 → Use their preferred theme. 498 4992. Color scheme default 500 Read color scheme preference: 501 a. Cookie: atbb-color-scheme=light|dark 502 b. HTTP header: Sec-CH-Prefers-Color-Scheme (client hint) 503 c. Default: light 504 505 → Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly. 506 If themeRef.cid is set, verify CID on fetch; mismatch → fall through. 507 If themeRef.cid is absent, fetch live current record at the URI. 508 5093. Hardcoded fallback 510 If no theme policy exists, the resolved theme can't be loaded, 511 or the atbb.space PDS is unreachable: 512 → Use the built-in neobrutal token values from the bundled JSON files 513 (no PDS or network needed, always works offline). 514``` 515 516This 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. 517 518### Light/Dark Toggle 519 520The one piece of client interactivity that doesn't need HTMX — a vanilla JS color scheme toggle: 521 522```html 523<!-- In the site header/footer --> 524<button onclick="toggleColorScheme()">Light/Dark</button> 525 526<script> 527function toggleColorScheme() { 528 const current = document.cookie.match(/atbb-color-scheme=(light|dark)/)?.[1] ?? 'light'; 529 const next = current === 'light' ? 'dark' : 'light'; 530 document.cookie = `atbb-color-scheme=${next};path=/;max-age=31536000`; 531 location.reload(); 532} 533</script> 534``` 535 536The `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. 537 538### User Theme Picker 539 540For logged-in users (when `themePolicy.allowUserChoice` is true): 541 542- A dropdown or modal in the user's settings area (or a compact picker in the site header) 543- Shows the admin's curated `availableThemes` list with names and color swatch previews 544- Selecting one writes `preferredTheme` to the user's membership record on their PDS 545- An **"Auto (follow forum default)"** option clears the preference, falling back to the light/dark defaults 546- Implemented as a standard HTMX form — `hx-patch` to the API, swap a success indicator 547 548--- 549 550## Implementation Phases 551 552This work is post-MVP. Suggested ordering: 553 554### Theme Phase 1: Foundation 555- Add `reset.css` and `theme.css` with custom property references 556- Hardcode neobrutal light tokens in `BaseLayout` as the default 557- Style all existing views (homepage, category, topic, compose, admin) 558- Add static file serving (`/static/` route via Hono `serveStatic`) 559- No admin editor, no dynamic themes — just ship a good-looking default 560 561### Theme Phase 2: Light/Dark + Token System 562- Define `space.atbb.forum.theme` lexicon (with `colorScheme` field using `knownValues`) 563- Define `space.atbb.forum.themePolicy` lexicon as separate singleton (with `themeRef` strongRef wrapper) 564- Build `tokensToCss()` utility 565- Ship built-in preset JSON files (neobrutal light + dark, clean light + dark, classic) 566- Load theme policy + resolved theme from Forum DID's PDS (with caching) 567- Inject tokens dynamically in `BaseLayout` based on theme resolution waterfall 568- Add light/dark toggle (cookie-based, vanilla JS, ~6 lines) 569- Add `Sec-CH-Prefers-Color-Scheme` client hint support as fallback 570- AppView endpoints: `GET /api/themes`, `GET /api/themes/:rkey`, `GET /api/theme-policy` (REST, not XRPC) 571 572### Theme Phase 3: Admin Theme Management 573- **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. 574- Admin endpoints: `POST/PUT/DELETE /api/themes`, `PUT /api/forum/theme-policy` (AppView REST, not XRPC) 575- Theme list management UI (create, duplicate, delete, availability toggles) 576- Default light/dark assignment dropdowns 577- `allowUserChoice` kill-switch toggle 578- Token editor with grouped controls + live preview via HTMX 579- Import/export (JSON download/upload) 580- Database additions: `themes` table following `(did, rkey, cid, indexed_at)` pattern 581 582### Theme Phase 4: User Choice 583- Add `preferredTheme` field to `space.atbb.membership` lexicon 584- User endpoint: `PATCH /api/membership/theme` (AppView REST, not XRPC) 585- Theme picker UI for logged-in users (dropdown in settings or site header) 586- "Auto (follow forum default)" option to clear preference 587- Database additions: `preferred_theme_uri` column on `memberships` table (nullable) 588- AppView indexes `preferredTheme` from membership records for fast lookup 589 590### Theme Phase 5: Polish 591- CSS override editor for advanced admins (with sanitization) 592- Preset gallery expansion 593- Theme sharing between forums (export includes metadata for discovery) 594- HTMX-based theme swap without full page reload (stretch) 595 596--- 597 598## Open Questions 599 6001. **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. 6012. **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. 6023. **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? 6034. **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. 6045. **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?