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 `strongRef`s 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: [theme] 214 properties: 215 theme: 216 type: ref 217 ref: com.atproto.repo.strongRef # CID integrity check for theme records 218 219fields: 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 239The 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 243Add an optional theme preference to the existing user membership record: 244 245```yaml 246# New optional field on membership 247preferredTheme: 248 type: ref (optional) 249 ref: com.atproto.repo.strongRef # strongRef to space.atbb.forum.theme record 250 # Null = follow forum defaults 251``` 252 253This 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 259The 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 291Theme 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 313The 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 323Ship 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 333Each 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``` 342packages/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 */ 360body { 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 399const 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 442When the web server handles a request, it resolves which theme to render using a waterfall: 443 444``` 4451. 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 4532. 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 4623. 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 467This 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 471The 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> 478function 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 487The `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 491For 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 503This 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 5511. **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. 5522. **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. 5533. **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? 5544. **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. 5555. **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?