···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?