···11+# Theming System — Design Plan
22+33+**Status:** Future (post-MVP)
44+**Package:** `@atbb/web`
55+**Goal:** Admin-customizable forum themes with a neobrutal default, inspired by phpBB's theme flexibility.
66+77+---
88+99+## Design Principles
1010+1111+1. **Admin control without code.** Forum admins pick a theme or tweak design tokens from an admin panel — no CSS authoring needed.
1212+2. **Theme-as-data.** Themes are serializable JSON stored as AT Proto records on the Forum DID's PDS, making them portable and versionable.
1313+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.
1414+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.
1515+5. **Neobrutal default.** The built-in theme uses a neobrutal aesthetic: bold borders, solid shadows, high contrast, punchy accent colors, chunky type.
1616+1717+---
1818+1919+## Architecture
2020+2121+### How Themes Work
2222+2323+```
2424+Forum DID PDS Web Server (Hono)
2525+┌─────────────────────────────┐ ┌───────────────────────────────────┐
2626+│ space.atbb.forum.theme │ │ │
2727+│ (multiple records) │──cache─▶│ Theme resolution per request: │
2828+│ │ │ 1. User pref (membership record) │
2929+│ space.atbb.forum.themePolicy│ │ 2. Color scheme (cookie/header) │
3030+│ (singleton) { │──cache─▶│ 3. Forum default (themePolicy) │
3131+│ availableThemes │ │ 4. Hardcoded fallback │
3232+│ defaultLightTheme │ │ │ │
3333+│ defaultDarkTheme │ │ ▼ │
3434+│ allowUserChoice │ │ <style>:root { --tokens }</style>│
3535+│ } │ │ + /static/theme.css │
3636+└─────────────────────────────┘ └───────────────────────────────────┘
3737+```
3838+3939+1. **Theme records** live on the Forum DID's PDS as `space.atbb.forum.theme` records. A forum can have many saved themes.
4040+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.
4141+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>`.
4242+4. **A single base stylesheet** (`theme.css`) references only custom properties — never hardcoded colors or sizes. Swapping property values completely changes the look.
4343+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.
4444+4545+### Theme Layers
4646+4747+```
4848+Layer 0: Reset / Normalize (minimal, ships with @atbb/web)
4949+Layer 1: Base component styles (theme.css — uses only custom properties)
5050+Layer 2: Design tokens (<style>:root { --color-bg: ... })
5151+Layer 3: Per-theme CSS overrides (optional structural tweaks)
5252+```
5353+5454+---
5555+5656+## Design Token Schema
5757+5858+Themes are defined as a flat set of design tokens. The token names map 1:1 to CSS custom properties.
5959+6060+### Color Tokens
6161+6262+| Token | Description | Neobrutal Default |
6363+|-------|-------------|-------------------|
6464+| `color-bg` | Page background | `#f5f0e8` (warm off-white) |
6565+| `color-surface` | Card/panel background | `#ffffff` |
6666+| `color-text` | Primary text | `#1a1a1a` |
6767+| `color-text-muted` | Secondary/meta text | `#555555` |
6868+| `color-primary` | Primary accent (links, buttons) | `#ff5c00` (bold orange) |
6969+| `color-primary-hover` | Primary accent hover state | `#e04f00` |
7070+| `color-secondary` | Secondary accent | `#3a86ff` (vivid blue) |
7171+| `color-border` | Border color | `#1a1a1a` (black) |
7272+| `color-shadow` | Box-shadow color | `#1a1a1a` |
7373+| `color-success` | Success/positive | `#2ec44a` |
7474+| `color-warning` | Warning | `#ffbe0b` |
7575+| `color-danger` | Danger/destructive | `#ff006e` |
7676+| `color-code-bg` | Code block background | `#1a1a1a` |
7777+| `color-code-text` | Code block text | `#f5f0e8` |
7878+7979+### Typography Tokens
8080+8181+| Token | Description | Neobrutal Default |
8282+|-------|-------------|-------------------|
8383+| `font-body` | Body font stack | `'Space Grotesk', system-ui, sans-serif` |
8484+| `font-heading` | Heading font stack | `'Space Grotesk', system-ui, sans-serif` |
8585+| `font-mono` | Monospace font stack | `'JetBrains Mono', ui-monospace, monospace` |
8686+| `font-size-base` | Base font size | `16px` |
8787+| `font-size-sm` | Small text | `14px` |
8888+| `font-size-lg` | Large text | `20px` |
8989+| `font-size-xl` | XL text (headings) | `28px` |
9090+| `font-size-2xl` | 2XL text (page titles) | `36px` |
9191+| `font-weight-normal` | Normal weight | `400` |
9292+| `font-weight-bold` | Bold weight | `700` |
9393+| `line-height-body` | Body line height | `1.6` |
9494+| `line-height-heading` | Heading line height | `1.2` |
9595+9696+### Spacing & Layout Tokens
9797+9898+| Token | Description | Neobrutal Default |
9999+|-------|-------------|-------------------|
100100+| `space-xs` | Extra-small spacing | `4px` |
101101+| `space-sm` | Small spacing | `8px` |
102102+| `space-md` | Medium spacing | `16px` |
103103+| `space-lg` | Large spacing | `24px` |
104104+| `space-xl` | Extra-large spacing | `40px` |
105105+| `radius` | Border radius | `0px` (sharp corners — neobrutal) |
106106+| `border-width` | Default border width | `3px` (chunky — neobrutal) |
107107+| `shadow-offset` | Box-shadow offset | `4px` (solid offset shadows) |
108108+| `content-width` | Max content width | `960px` |
109109+110110+### Component-Level Tokens
111111+112112+| Token | Description | Neobrutal Default |
113113+|-------|-------------|-------------------|
114114+| `button-radius` | Button border radius | `0px` |
115115+| `button-shadow` | Button box-shadow | `4px 4px 0 var(--color-shadow)` |
116116+| `card-radius` | Card border radius | `0px` |
117117+| `card-shadow` | Card box-shadow | `6px 6px 0 var(--color-shadow)` |
118118+| `input-radius` | Input border radius | `0px` |
119119+| `input-border` | Input border | `3px solid var(--color-border)` |
120120+| `nav-height` | Navigation bar height | `64px` |
121121+122122+---
123123+124124+## Neobrutal Default Theme — Design Direction
125125+126126+The neobrutal aesthetic is characterized by:
127127+128128+- **Thick black borders** on cards, buttons, and inputs (3px+)
129129+- **Solid offset box-shadows** instead of soft/blurred shadows (e.g., `4px 4px 0 #1a1a1a`)
130130+- **Sharp corners** (border-radius: 0) or very slight rounding
131131+- **High contrast** color palette — dark text on light backgrounds, bold accent colors
132132+- **Punchy accent colors** — saturated oranges, blues, pinks (not pastels)
133133+- **Chunky, confident typography** — geometric sans-serifs, generous sizing
134134+- **Flat color fills** — no gradients, no translucency
135135+- **Deliberate "roughness"** — the UI looks intentionally bold and unpolished, like a zine or poster
136136+137137+### Visual Reference Points
138138+139139+- [Gumroad's redesign](https://gumroad.com) — the canonical neobrutal web product
140140+- [Figma neobrutal UI kits](https://www.figma.com/community/tag/neobrutalism) — community references
141141+- The general energy: "Web Brutalism meets a friendly color palette"
142142+143143+### Layout Sketch
144144+145145+```
146146+┌─────────────────────────────────────────────────────┐
147147+│ ██ atBB Forum Name [Login] │ <- thick bottom border
148148+├─────────────────────────────────────────────────────┤
149149+│ │
150150+│ ┌─────────────────────────────────────────────┐ │
151151+│ │ 📁 Category Name 12 topics│ │ <- solid shadow card
152152+│ │ Description of the category... │ │
153153+│ └──┬──┬───────────────────────────────────────┘ │
154154+│ └──┘ (offset shadow) │
155155+│ │
156156+│ ┌─────────────────────────────────────────────┐ │
157157+│ │ 📁 Another Category 8 topics│ │
158158+│ │ Another description here... │ │
159159+│ └──┬──┬───────────────────────────────────────┘ │
160160+│ └──┘ │
161161+│ │
162162+│ ┌──────────────────┐ │
163163+│ │ [+ New Topic] │ <- bold button w/ shadow │
164164+│ └──┬──┬────────────┘ │
165165+│ └──┘ │
166166+│ │
167167+├─────────────────────────────────────────────────────┤
168168+│ Powered by atBB on the ATmosphere │
169169+└─────────────────────────────────────────────────────┘
170170+```
171171+172172+---
173173+174174+## Lexicon Changes
175175+176176+### New: `space.atbb.forum.theme`
177177+178178+A new record type on the Forum DID for storing theme configuration.
179179+180180+```yaml
181181+lexiconId: space.atbb.forum.theme
182182+key: tid # Multiple themes per forum
183183+fields:
184184+ name: string (required) # "Neobrutal Default", "Dark Mode", etc.
185185+ colorScheme:
186186+ type: string (required)
187187+ knownValues: ["light", "dark"] # Which mode this theme targets (extensible)
188188+ tokens: map<string, string> # Design token key-value pairs
189189+ cssOverrides: string (optional)# Raw CSS for structural overrides
190190+ fontUrls: array<string> (opt) # HTTPS URLs for Google Fonts or self-hosted fonts
191191+ createdAt: datetime
192192+ updatedAt: datetime
193193+```
194194+195195+**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.
196196+197197+**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.
198198+199199+**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.
200200+201201+### New: `space.atbb.forum.themePolicy`
202202+203203+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.
204204+205205+```yaml
206206+lexiconId: space.atbb.forum.themePolicy
207207+key: literal:self # Singleton — one per forum
208208+209209+# Named def for theme references
210210+defs:
211211+ themeRef:
212212+ type: object
213213+ required: [theme]
214214+ properties:
215215+ theme:
216216+ type: ref
217217+ ref: com.atproto.repo.strongRef # CID integrity check for theme records
218218+219219+fields:
220220+ availableThemes: # Themes admins have enabled for users
221221+ type: array
222222+ items:
223223+ type: ref
224224+ ref: '#themeRef'
225225+ defaultLightTheme: # Default light-mode theme
226226+ type: ref
227227+ ref: '#themeRef'
228228+ defaultDarkTheme: # Default dark-mode theme
229229+ type: ref
230230+ ref: '#themeRef'
231231+ allowUserChoice: # Can users pick their own theme?
232232+ type: boolean
233233+ default: true
234234+ updatedAt: datetime
235235+```
236236+237237+**Record ownership:** Forum DID.
238238+239239+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`.
240240+241241+### Extended: `space.atbb.membership`
242242+243243+Add an optional theme preference to the existing user membership record:
244244+245245+```yaml
246246+# New optional field on membership
247247+preferredTheme:
248248+ type: ref (optional)
249249+ ref: com.atproto.repo.strongRef # strongRef to space.atbb.forum.theme record
250250+ # Null = follow forum defaults
251251+```
252252+253253+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.
254254+255255+---
256256+257257+## Admin Theme Editor
258258+259259+The admin panel gets a theme management section:
260260+261261+### Theme List View
262262+- Shows all saved themes with preview thumbnails and `colorScheme` badges (light/dark)
263263+- Create / duplicate / delete themes
264264+- **Availability toggles** — check themes on/off to control `themePolicy.availableThemes`
265265+- **Default assignment** — dropdown to pick the default light theme and default dark theme (must be from the available list)
266266+- **User choice kill-switch** — toggle for `themePolicy.allowUserChoice`. When off, all users see the forum defaults regardless of their membership preference.
267267+268268+### Theme Editor View
269269+- **Live preview panel** — shows a sample forum page with current token values
270270+- **Token editor** — grouped by category (colors, typography, spacing, components)
271271+ - Color tokens: color picker inputs
272272+ - Typography tokens: font selector + size sliders
273273+ - Spacing tokens: numeric inputs with preview
274274+ - Component tokens: composite editors (shadow builder, border builder)
275275+- **Color scheme selector** — pick whether this theme targets `light` or `dark` mode
276276+- **CSS overrides** — code editor (CodeMirror or similar) for advanced users
277277+- **Font management** — add Google Fonts URLs or upload self-hosted fonts
278278+- **Import/Export** — download theme as JSON, upload to share between forums
279279+- **Preset gallery** — start from built-in presets (Neobrutal, Clean, Dark, Classic BB)
280280+281281+### Implementation Notes
282282+- 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`.
283283+- No client-side JS framework needed — HTMX + server rendering is sufficient for the live preview workflow.
284284+- Theme JSON import/export is just the `tokens` + `cssOverrides` + `fontUrls` + `colorScheme` fields serialized.
285285+- Theme policy changes (defaults, available list, allowUserChoice) write to the `space.atbb.forum.themePolicy` singleton on the Forum DID's PDS.
286286+287287+---
288288+289289+## AppView API Endpoints
290290+291291+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):
292292+293293+### Read Endpoints
294294+295295+| Endpoint | Auth | Description |
296296+|----------|------|-------------|
297297+| `GET /api/themes` | Public | List available themes (filtered by `themePolicy.availableThemes`). Returns name, colorScheme, and token summary for each. |
298298+| `GET /api/themes/:rkey` | Public | Get a single theme's full token set, cssOverrides, and fontUrls. |
299299+| `GET /api/theme-policy` | Public | Get the forum's theme policy (available themes, defaults for light/dark, allowUserChoice). |
300300+301301+### Write Endpoints
302302+303303+| Endpoint | Auth | Description |
304304+|----------|------|-------------|
305305+| `POST /api/themes` | Admin | Create a new theme record on Forum DID's PDS. |
306306+| `PUT /api/themes/:rkey` | Admin | Update an existing theme's tokens, name, colorScheme, etc. |
307307+| `DELETE /api/themes/:rkey` | Admin | Delete a theme. Fails if it's currently a default. |
308308+| `PUT /api/theme-policy` | Admin | Update the `themePolicy` singleton (available list, defaults, allowUserChoice). |
309309+| `PATCH /api/membership/theme` | User | Set `preferredTheme` on the caller's membership record (writes to their PDS). Pass `null` to clear. |
310310+311311+### Caching
312312+313313+The web server caches resolved theme data aggressively since themes change rarely:
314314+315315+- **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.
316316+- **Theme policy:** Cached alongside forum metadata. Same invalidation path.
317317+- **User preference:** Looked up from the AppView's indexed `membership` records (local DB query, not a PDS fetch per request).
318318+319319+---
320320+321321+## Built-in Preset Themes
322322+323323+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.
324324+325325+| Preset | Color Scheme | Description |
326326+|--------|-------------|-------------|
327327+| **Neobrutal Light** (default light) | light | Bold borders, solid shadows, warm off-white palette, sharp corners |
328328+| **Neobrutal Dark** (default dark) | dark | Same bold/chunky aesthetic on a dark background, muted shadows |
329329+| **Clean Light** | light | Minimal, airy, subtle shadows, rounded corners, neutral palette |
330330+| **Clean Dark** | dark | Soft dark surfaces, gentle borders, same airy spacing |
331331+| **Classic BB** | light | Nostalgic phpBB/vBulletin feel — blue headers, gray panels, small type |
332332+333333+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.
334334+335335+---
336336+337337+## CSS Architecture
338338+339339+### File Structure (future)
340340+341341+```
342342+packages/web/src/
343343+ styles/
344344+ reset.css # Minimal normalize/reset
345345+ theme.css # All component styles using var(--token) references
346346+ presets/
347347+ neobrutal-light.json # Token values for neobrutal light preset
348348+ neobrutal-dark.json # Token values for neobrutal dark preset
349349+ clean-light.json # Token values for clean light preset
350350+ clean-dark.json # Token values for clean dark preset
351351+ classic.json # Token values for classic BB preset
352352+```
353353+354354+### Base Stylesheet Approach
355355+356356+`theme.css` is written once and never changes per-theme. It references custom properties exclusively:
357357+358358+```css
359359+/* Example — not final */
360360+body {
361361+ font-family: var(--font-body);
362362+ font-size: var(--font-size-base);
363363+ line-height: var(--line-height-body);
364364+ color: var(--color-text);
365365+ background: var(--color-bg);
366366+}
367367+368368+.card {
369369+ background: var(--color-surface);
370370+ border: var(--border-width) solid var(--color-border);
371371+ border-radius: var(--card-radius);
372372+ box-shadow: var(--card-shadow);
373373+ padding: var(--space-md);
374374+}
375375+376376+.btn-primary {
377377+ background: var(--color-primary);
378378+ color: var(--color-surface);
379379+ border: var(--border-width) solid var(--color-border);
380380+ border-radius: var(--button-radius);
381381+ box-shadow: var(--button-shadow);
382382+ font-weight: var(--font-weight-bold);
383383+ padding: var(--space-sm) var(--space-md);
384384+}
385385+386386+.btn-primary:hover {
387387+ background: var(--color-primary-hover);
388388+ transform: translate(2px, 2px);
389389+ box-shadow: 2px 2px 0 var(--color-shadow);
390390+}
391391+```
392392+393393+### Server-Side Token Injection
394394+395395+`BaseLayout` renders the active theme's tokens as a `<style>` block:
396396+397397+```tsx
398398+// Pseudocode — future implementation
399399+const BaseLayout: FC<PropsWithChildren<LayoutProps>> = (props) => {
400400+ // Theme resolved server-side before render (see Theme Resolution section)
401401+ const theme = props.resolvedTheme;
402402+ const policy = props.themePolicy;
403403+404404+ return (
405405+ <html lang="en">
406406+ <head>
407407+ <meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" />
408408+ <title>{props.title ?? "atBB Forum"}</title>
409409+ <style>{`:root { ${tokensToCss(theme.tokens)} }`}</style>
410410+ <link rel="stylesheet" href="/static/reset.css" />
411411+ <link rel="stylesheet" href="/static/theme.css" />
412412+ {theme.fontUrls?.map(url => (
413413+ <link rel="stylesheet" href={url} />
414414+ ))}
415415+ {theme.cssOverrides && (
416416+ <style>{theme.cssOverrides}</style>
417417+ )}
418418+ <script src="https://unpkg.com/htmx.org@2.0.4" />
419419+ </head>
420420+ <body>
421421+ <header>
422422+ {/* ... nav ... */}
423423+ <button onclick="toggleColorScheme()">Light/Dark</button>
424424+ {policy.allowUserChoice && props.user && (
425425+ <ThemePicker
426426+ themes={props.availableThemes}
427427+ current={props.user.preferredTheme}
428428+ />
429429+ )}
430430+ </header>
431431+ <main>{props.children}</main>
432432+ </body>
433433+ </html>
434434+ );
435435+};
436436+```
437437+438438+---
439439+440440+## Theme Resolution
441441+442442+When the web server handles a request, it resolves which theme to render using a waterfall:
443443+444444+```
445445+1. User preference
446446+ Is the user logged in?
447447+ AND has a preferredTheme set on their membership record?
448448+ AND does the forum's themePolicy.allowUserChoice == true?
449449+ AND is preferredTheme.uri still in themePolicy.availableThemes?
450450+ AND does preferredTheme.cid match current theme record (integrity check)?
451451+ → Use their preferred theme.
452452+453453+2. Color scheme default
454454+ Read color scheme preference:
455455+ a. Cookie: atbb-color-scheme=light|dark
456456+ b. HTTP header: Sec-CH-Prefers-Color-Scheme (client hint)
457457+ c. Default: light
458458+459459+ → Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly
460460+ (with CID integrity check via strongRef).
461461+462462+3. Hardcoded fallback
463463+ If no theme policy exists or the resolved theme can't be loaded:
464464+ → Use the built-in neobrutal token values (no PDS needed, works offline).
465465+```
466466+467467+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.
468468+469469+### Light/Dark Toggle
470470+471471+The one piece of client interactivity that doesn't need HTMX — a vanilla JS color scheme toggle:
472472+473473+```html
474474+<!-- In the site header/footer -->
475475+<button onclick="toggleColorScheme()">Light/Dark</button>
476476+477477+<script>
478478+function toggleColorScheme() {
479479+ const current = document.cookie.match(/atbb-color-scheme=(light|dark)/)?.[1] ?? 'light';
480480+ const next = current === 'light' ? 'dark' : 'light';
481481+ document.cookie = `atbb-color-scheme=${next};path=/;max-age=31536000`;
482482+ location.reload();
483483+}
484484+</script>
485485+```
486486+487487+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.
488488+489489+### User Theme Picker
490490+491491+For logged-in users (when `themePolicy.allowUserChoice` is true):
492492+493493+- A dropdown or modal in the user's settings area (or a compact picker in the site header)
494494+- Shows the admin's curated `availableThemes` list with names and color swatch previews
495495+- Selecting one writes `preferredTheme` to the user's membership record on their PDS
496496+- An **"Auto (follow forum default)"** option clears the preference, falling back to the light/dark defaults
497497+- Implemented as a standard HTMX form — `hx-patch` to the API, swap a success indicator
498498+499499+---
500500+501501+## Implementation Phases
502502+503503+This work is post-MVP. Suggested ordering:
504504+505505+### Theme Phase 1: Foundation
506506+- Add `reset.css` and `theme.css` with custom property references
507507+- Hardcode neobrutal light tokens in `BaseLayout` as the default
508508+- Style all existing views (homepage, category, topic, compose, admin)
509509+- Add static file serving (`/static/` route via Hono `serveStatic`)
510510+- No admin editor, no dynamic themes — just ship a good-looking default
511511+512512+### Theme Phase 2: Light/Dark + Token System
513513+- Define `space.atbb.forum.theme` lexicon (with `colorScheme` field using `knownValues`)
514514+- Define `space.atbb.forum.themePolicy` lexicon as separate singleton (with `themeRef` strongRef wrapper)
515515+- Build `tokensToCss()` utility
516516+- Ship built-in preset JSON files (neobrutal light + dark, clean light + dark, classic)
517517+- Load theme policy + resolved theme from Forum DID's PDS (with caching)
518518+- Inject tokens dynamically in `BaseLayout` based on theme resolution waterfall
519519+- Add light/dark toggle (cookie-based, vanilla JS, ~6 lines)
520520+- Add `Sec-CH-Prefers-Color-Scheme` client hint support as fallback
521521+- AppView endpoints: `GET /api/themes`, `GET /api/themes/:rkey`, `GET /api/theme-policy` (REST, not XRPC)
522522+523523+### Theme Phase 3: Admin Theme Management
524524+- **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.
525525+- Admin endpoints: `POST/PUT/DELETE /api/themes`, `PUT /api/forum/theme-policy` (AppView REST, not XRPC)
526526+- Theme list management UI (create, duplicate, delete, availability toggles)
527527+- Default light/dark assignment dropdowns
528528+- `allowUserChoice` kill-switch toggle
529529+- Token editor with grouped controls + live preview via HTMX
530530+- Import/export (JSON download/upload)
531531+- Database additions: `themes` table following `(did, rkey, cid, indexed_at)` pattern
532532+533533+### Theme Phase 4: User Choice
534534+- Add `preferredTheme` field to `space.atbb.membership` lexicon
535535+- User endpoint: `PATCH /api/membership/theme` (AppView REST, not XRPC)
536536+- Theme picker UI for logged-in users (dropdown in settings or site header)
537537+- "Auto (follow forum default)" option to clear preference
538538+- Database additions: `preferred_theme_uri` column on `memberships` table (nullable)
539539+- AppView indexes `preferredTheme` from membership records for fast lookup
540540+541541+### Theme Phase 5: Polish
542542+- CSS override editor for advanced admins (with sanitization)
543543+- Preset gallery expansion
544544+- Theme sharing between forums (export includes metadata for discovery)
545545+- HTMX-based theme swap without full page reload (stretch)
546546+547547+---
548548+549549+## Open Questions
550550+551551+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.
552552+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.
553553+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?
554554+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.
555555+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?