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

Address PR review comments for theming plan

Fix lexicon convention issues identified in code review:
- Use knownValues (not implicit enum) for colorScheme field
- Wrap theme references with strongRef for CID integrity checks
- Separate themePolicy into its own singleton to prevent forum record bloat
- Note that forum.theme must be added to CLAUDE.md ownership list

Add missing implementation details:
- Elevate CSS sanitization from open question to mandatory Phase 3 gate
- Document cache key must include resolved color scheme
- Constrain fontUrls to HTTPS with allowlist consideration
- Add database schema notes for themes and memberships tables
- Clarify AppView endpoints are REST, not XRPC

Updates theme resolution waterfall to show CID integrity checks via strongRef.

+79 -54
+79 -54
docs/theming-plan.md
··· 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.forum │ │ 2. Color scheme (cookie/header) │ 30 - │ .themePolicy { │──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** on the `forum.forum` record 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. ··· 182 key: tid # Multiple themes per forum 183 fields: 184 name: string (required) # "Neobrutal Default", "Dark Mode", etc. 185 - colorScheme: string (required) # "light" | "dark" — which mode this theme targets 186 tokens: map<string, string> # Design token key-value pairs 187 cssOverrides: string (optional)# Raw CSS for structural overrides 188 - fontUrls: array<string> (opt) # Google Fonts or self-hosted font URLs 189 createdAt: datetime 190 updatedAt: datetime 191 ``` 192 193 - **Record ownership:** Forum DID (same as `forum.forum`, `forum.category`). 194 195 **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. 196 197 - **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 `themePolicy` on the forum record handles the rest. 198 199 - ### Extended: `space.atbb.forum.forum` 200 201 - Add a `themePolicy` object to the existing forum singleton record: 202 203 ```yaml 204 - # New fields on forum.forum 205 - themePolicy: 206 - type: object 207 - properties: 208 - availableThemes: # AT-URIs of themes admins have enabled for users 209 - type: array 210 - items: string (at-uri) 211 - defaultLightTheme: # AT-URI of the default light-mode theme 212 - type: string (at-uri) 213 - defaultDarkTheme: # AT-URI of the default dark-mode theme 214 - type: string (at-uri) 215 - allowUserChoice: # Can users pick their own theme? 216 - type: boolean 217 - default: true 218 ``` 219 220 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`. 221 ··· 226 ```yaml 227 # New optional field on membership 228 preferredTheme: 229 - type: string (at-uri, optional) # AT-URI of user's chosen theme, null = follow forum defaults 230 ``` 231 232 - 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. 233 234 --- 235 ··· 261 - 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`. 262 - No client-side JS framework needed — HTMX + server rendering is sufficient for the live preview workflow. 263 - Theme JSON import/export is just the `tokens` + `cssOverrides` + `fontUrls` + `colorScheme` fields serialized. 264 - - Theme policy changes (defaults, available list, allowUserChoice) write back to the `forum.forum` record on the Forum DID's PDS. 265 266 --- 267 268 ## AppView API Endpoints 269 270 - Theme data flows through the AppView like all other forum data. New endpoints: 271 272 ### Read Endpoints 273 ··· 275 |----------|------|-------------| 276 | `GET /api/themes` | Public | List available themes (filtered by `themePolicy.availableThemes`). Returns name, colorScheme, and token summary for each. | 277 | `GET /api/themes/:rkey` | Public | Get a single theme's full token set, cssOverrides, and fontUrls. | 278 - | `GET /api/forum` (existing) | Public | Already returns forum metadata — now also includes `themePolicy` (defaults, available list, allowUserChoice). | 279 280 ### Write Endpoints 281 ··· 284 | `POST /api/themes` | Admin | Create a new theme record on Forum DID's PDS. | 285 | `PUT /api/themes/:rkey` | Admin | Update an existing theme's tokens, name, colorScheme, etc. | 286 | `DELETE /api/themes/:rkey` | Admin | Delete a theme. Fails if it's currently a default. | 287 - | `PUT /api/forum/theme-policy` | Admin | Update `themePolicy` on the forum record (available list, defaults, allowUserChoice). | 288 | `PATCH /api/membership/theme` | User | Set `preferredTheme` on the caller's membership record (writes to their PDS). Pass `null` to clear. | 289 290 ### Caching 291 292 The web server caches resolved theme data aggressively since themes change rarely: 293 294 - - **Theme tokens:** Cached in-memory on the web server, keyed by AT-URI. Invalidated when the AppView receives a firehose event for `space.atbb.forum.theme` records. 295 - **Theme policy:** Cached alongside forum metadata. Same invalidation path. 296 - **User preference:** Looked up from the AppView's indexed `membership` records (local DB query, not a PDS fetch per request). 297 ··· 425 Is the user logged in? 426 AND has a preferredTheme set on their membership record? 427 AND does the forum's themePolicy.allowUserChoice == true? 428 - AND is preferredTheme still in themePolicy.availableThemes? 429 → Use their preferred theme. 430 431 2. Color scheme default ··· 434 b. HTTP header: Sec-CH-Prefers-Color-Scheme (client hint) 435 c. Default: light 436 437 - → Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly. 438 439 3. Hardcoded fallback 440 If no theme policy exists or the resolved theme can't be loaded: ··· 487 - No admin editor, no dynamic themes — just ship a good-looking default 488 489 ### Theme Phase 2: Light/Dark + Token System 490 - - Define `space.atbb.forum.theme` lexicon (with `colorScheme` field) 491 - - Add `themePolicy` to `space.atbb.forum.forum` lexicon 492 - Build `tokensToCss()` utility 493 - Ship built-in preset JSON files (neobrutal light + dark, clean light + dark, classic) 494 - Load theme policy + resolved theme from Forum DID's PDS (with caching) 495 - Inject tokens dynamically in `BaseLayout` based on theme resolution waterfall 496 - Add light/dark toggle (cookie-based, vanilla JS, ~6 lines) 497 - Add `Sec-CH-Prefers-Color-Scheme` client hint support as fallback 498 - - AppView endpoints: `GET /api/themes`, `GET /api/themes/:rkey` 499 500 ### Theme Phase 3: Admin Theme Management 501 - - Admin endpoints: `POST/PUT/DELETE /api/themes`, `PUT /api/forum/theme-policy` 502 - Theme list management UI (create, duplicate, delete, availability toggles) 503 - Default light/dark assignment dropdowns 504 - `allowUserChoice` kill-switch toggle 505 - Token editor with grouped controls + live preview via HTMX 506 - Import/export (JSON download/upload) 507 508 ### Theme Phase 4: User Choice 509 - Add `preferredTheme` field to `space.atbb.membership` lexicon 510 - - User endpoint: `PATCH /api/membership/theme` 511 - Theme picker UI for logged-in users (dropdown in settings or site header) 512 - "Auto (follow forum default)" option to clear preference 513 - AppView indexes `preferredTheme` from membership records for fast lookup 514 515 ### Theme Phase 5: Polish ··· 522 523 ## Open Questions 524 525 - 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? 526 - 2. **CSS override sandboxing.** Raw CSS in `cssOverrides` could break layouts or introduce XSS (via `url()`, `expression()`, etc.). Need a sanitization strategy — maybe a CSS parser that strips dangerous properties. 527 - 3. **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. 528 - 4. **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? 529 - 5. **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. 530 - 6. **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?
··· 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. ··· 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 ··· 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 ··· 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 ··· 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 ··· 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 ··· 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 ··· 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: ··· 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 ··· 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?