Theming System — Design Plan#
Status: Future (post-MVP)
Package: @atbb/web
Goal: Admin-customizable forum themes with a neobrutal default, inspired by phpBB's theme flexibility.
Design Principles#
- Admin control without code. Forum admins pick a theme or tweak design tokens from an admin panel — no CSS authoring needed.
- Theme-as-data. Themes are serializable JSON stored as AT Proto records on the Forum DID's PDS, making them portable and versionable.
- 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.
- 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. - Neobrutal default. The built-in theme uses a neobrutal aesthetic: bold borders, solid shadows, high contrast, punchy accent colors, chunky type.
Architecture#
How Themes Work#
Forum DID PDS Web Server (Hono)
┌─────────────────────────────┐ ┌───────────────────────────────────┐
│ space.atbb.forum.theme │ │ │
│ (multiple records) │──cache─▶│ Theme resolution per request: │
│ │ │ 1. User pref (membership record) │
│ space.atbb.forum.themePolicy│ │ 2. Color scheme (cookie/header) │
│ (singleton) { │──cache─▶│ 3. Forum default (themePolicy) │
│ availableThemes │ │ 4. Hardcoded fallback │
│ defaultLightTheme │ │ │ │
│ defaultDarkTheme │ │ ▼ │
│ allowUserChoice │ │ <style>:root { --tokens }</style>│
│ } │ │ + /static/theme.css │
└─────────────────────────────┘ └───────────────────────────────────┘
- Theme records live on the Forum DID's PDS as
space.atbb.forum.themerecords. A forum can have many saved themes. - 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. - On each request, the web server resolves which theme to render (see Theme Resolution below) and injects the winning theme's CSS custom properties into a
<style>block in<head>. - A single base stylesheet (
theme.css) references only custom properties — never hardcoded colors or sizes. Swapping property values completely changes the look. - Optional per-theme CSS overrides can extend the base for structural changes (e.g., sidebar layout vs. top-nav), stored as a
cssOverridesstring in the theme record.
Theme Layers#
Layer 0: Reset / Normalize (minimal, ships with @atbb/web)
Layer 1: Base component styles (theme.css — uses only custom properties)
Layer 2: Design tokens (<style>:root { --color-bg: ... })
Layer 3: Per-theme CSS overrides (optional structural tweaks)
Design Token Schema#
Themes are defined as a flat set of design tokens. The token names map 1:1 to CSS custom properties.
Color Tokens#
| Token | Description | Neobrutal Default |
|---|---|---|
color-bg |
Page background | #f5f0e8 (warm off-white) |
color-surface |
Card/panel background | #ffffff |
color-text |
Primary text | #1a1a1a |
color-text-muted |
Secondary/meta text | #555555 |
color-primary |
Primary accent (links, buttons) | #ff5c00 (bold orange) |
color-primary-hover |
Primary accent hover state | #e04f00 |
color-secondary |
Secondary accent | #3a86ff (vivid blue) |
color-border |
Border color | #1a1a1a (black) |
color-shadow |
Box-shadow color | #1a1a1a |
color-success |
Success/positive | #2ec44a |
color-warning |
Warning | #ffbe0b |
color-danger |
Danger/destructive | #ff006e |
color-code-bg |
Code block background | #1a1a1a |
color-code-text |
Code block text | #f5f0e8 |
Typography Tokens#
| Token | Description | Neobrutal Default |
|---|---|---|
font-body |
Body font stack | 'Space Grotesk', system-ui, sans-serif |
font-heading |
Heading font stack | 'Space Grotesk', system-ui, sans-serif |
font-mono |
Monospace font stack | 'JetBrains Mono', ui-monospace, monospace |
font-size-base |
Base font size | 16px |
font-size-sm |
Small text | 14px |
font-size-lg |
Large text | 20px |
font-size-xl |
XL text (headings) | 28px |
font-size-2xl |
2XL text (page titles) | 36px |
font-weight-normal |
Normal weight | 400 |
font-weight-bold |
Bold weight | 700 |
line-height-body |
Body line height | 1.6 |
line-height-heading |
Heading line height | 1.2 |
Spacing & Layout Tokens#
| Token | Description | Neobrutal Default |
|---|---|---|
space-xs |
Extra-small spacing | 4px |
space-sm |
Small spacing | 8px |
space-md |
Medium spacing | 16px |
space-lg |
Large spacing | 24px |
space-xl |
Extra-large spacing | 40px |
radius |
Border radius | 0px (sharp corners — neobrutal) |
border-width |
Default border width | 3px (chunky — neobrutal) |
shadow-offset |
Box-shadow offset | 4px (solid offset shadows) |
content-width |
Max content width | 960px |
Component-Level Tokens#
| Token | Description | Neobrutal Default |
|---|---|---|
button-radius |
Button border radius | 0px |
button-shadow |
Button box-shadow | 4px 4px 0 var(--color-shadow) |
card-radius |
Card border radius | 0px |
card-shadow |
Card box-shadow | 6px 6px 0 var(--color-shadow) |
input-radius |
Input border radius | 0px |
input-border |
Input border | 3px solid var(--color-border) |
nav-height |
Navigation bar height | 64px |
Neobrutal Default Theme — Design Direction#
The neobrutal aesthetic is characterized by:
- Thick black borders on cards, buttons, and inputs (3px+)
- Solid offset box-shadows instead of soft/blurred shadows (e.g.,
4px 4px 0 #1a1a1a) - Sharp corners (border-radius: 0) or very slight rounding
- High contrast color palette — dark text on light backgrounds, bold accent colors
- Punchy accent colors — saturated oranges, blues, pinks (not pastels)
- Chunky, confident typography — geometric sans-serifs, generous sizing
- Flat color fills — no gradients, no translucency
- Deliberate "roughness" — the UI looks intentionally bold and unpolished, like a zine or poster
Visual Reference Points#
- Gumroad's redesign — the canonical neobrutal web product
- Figma neobrutal UI kits — community references
- The general energy: "Web Brutalism meets a friendly color palette"
Layout Sketch#
┌─────────────────────────────────────────────────────┐
│ ██ atBB Forum Name [Login] │ <- thick bottom border
├─────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 📁 Category Name 12 topics│ │ <- solid shadow card
│ │ Description of the category... │ │
│ └──┬──┬───────────────────────────────────────┘ │
│ └──┘ (offset shadow) │
│ │
│ ┌─────────────────────────────────────────────┐ │
│ │ 📁 Another Category 8 topics│ │
│ │ Another description here... │ │
│ └──┬──┬───────────────────────────────────────┘ │
│ └──┘ │
│ │
│ ┌──────────────────┐ │
│ │ [+ New Topic] │ <- bold button w/ shadow │
│ └──┬──┬────────────┘ │
│ └──┘ │
│ │
├─────────────────────────────────────────────────────┤
│ Powered by atBB on the ATmosphere │
└─────────────────────────────────────────────────────┘
Lexicon Changes#
New: space.atbb.forum.theme#
A new record type on the Forum DID for storing theme configuration.
lexiconId: space.atbb.forum.theme
key: tid # Multiple themes per forum
fields:
name: string (required) # "Neobrutal Default", "Dark Mode", etc.
colorScheme:
type: string (required)
knownValues: ["light", "dark"] # Which mode this theme targets (extensible)
tokens: map<string, string> # Design token key-value pairs
cssOverrides: string (optional)# Raw CSS for structural overrides
fontUrls: array<string> (opt) # HTTPS URLs for Google Fonts or self-hosted fonts
createdAt: datetime
updatedAt: datetime
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.
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.
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.
New: space.atbb.forum.themePolicy#
A new singleton record on the Forum DID for theme configuration, separate from the main forum record to allow independent updates without invalidating references to the forum record.
lexiconId: space.atbb.forum.themePolicy
key: literal:self # Singleton — one per forum
# Named def for theme references
defs:
themeRef:
type: object
required: [uri]
properties:
uri:
type: string
format: at-uri # AT-URI of the space.atbb.forum.theme record
cid:
type: string
format: cid # Optional. When present: pins to exact version (strong).
# When absent: resolves live current record (weak).
fields:
availableThemes: # Themes admins have enabled for users
type: array
items:
type: ref
ref: '#themeRef'
defaultLightTheme: # Default light-mode theme
type: ref
ref: '#themeRef'
defaultDarkTheme: # Default dark-mode theme
type: ref
ref: '#themeRef'
allowUserChoice: # Can users pick their own theme?
type: boolean
default: true
updatedAt: datetime
Record ownership: Forum DID.
The cid field is intentionally optional. The default themePolicy shipped by atBB uses URI-only references to canonical preset records on atbb.space — forums pick up preset updates automatically after cache expiry. Operators who want version stability can pin a theme to its current CID via the admin UI ("Pin to version"). The appview validates CID on fetch when present; a mismatch falls through to the next step in the resolution waterfall.
| Reference type | themeRef shape |
Behavior |
|---|---|---|
| Live | { uri: "at://atbb.space/..." } |
Auto-updates when atbb.space publishes new preset versions |
| Pinned | { uri: "at://atbb.space/...", cid: "bafyrei..." } |
Locked to exact version; admin sees warning when a newer version exists |
| Local copy | { uri: "at://forum-did/..." } |
Full autonomy, no external dependency |
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.
Extended: space.atbb.membership#
Add an optional theme preference to the existing user membership record:
# New optional field on membership
preferredTheme:
type: ref (optional)
ref: com.atproto.repo.strongRef # strongRef to space.atbb.forum.theme record
# Null = follow forum defaults
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.
Admin Theme Editor#
The admin panel gets a theme management section:
Theme List View#
- Shows all saved themes with preview thumbnails and
colorSchemebadges (light/dark) - Create / duplicate / delete themes
- Availability toggles — check themes on/off to control
themePolicy.availableThemes - Default assignment — dropdown to pick the default light theme and default dark theme (must be from the available list)
- User choice kill-switch — toggle for
themePolicy.allowUserChoice. When off, all users see the forum defaults regardless of their membership preference.
Theme Editor View#
- Live preview panel — shows a sample forum page with current token values
- Token editor — grouped by category (colors, typography, spacing, components)
- Color tokens: color picker inputs
- Typography tokens: font selector + size sliders
- Spacing tokens: numeric inputs with preview
- Component tokens: composite editors (shadow builder, border builder)
- Color scheme selector — pick whether this theme targets
lightordarkmode - CSS overrides — code editor (CodeMirror or similar) for advanced users
- Font management — add Google Fonts URLs or upload self-hosted fonts
- Import/Export — download theme as JSON, upload to share between forums
- Preset gallery — start from built-in presets (Neobrutal, Clean, Dark, Classic BB)
Implementation Notes#
- 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 anhx-swap. - No client-side JS framework needed — HTMX + server rendering is sufficient for the live preview workflow.
- Theme JSON import/export is just the
tokens+cssOverrides+fontUrls+colorSchemefields serialized. - Theme policy changes (defaults, available list, allowUserChoice) write to the
space.atbb.forum.themePolicysingleton on the Forum DID's PDS.
AppView API Endpoints#
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):
Read Endpoints#
| Endpoint | Auth | Description |
|---|---|---|
GET /api/themes |
Public | List available themes (filtered by themePolicy.availableThemes). Returns name, colorScheme, and token summary for each. |
GET /api/themes/:rkey |
Public | Get a single theme's full token set, cssOverrides, and fontUrls. |
GET /api/theme-policy |
Public | Get the forum's theme policy (available themes, defaults for light/dark, allowUserChoice). |
Write Endpoints#
| Endpoint | Auth | Description |
|---|---|---|
POST /api/themes |
Admin | Create a new theme record on Forum DID's PDS. |
PUT /api/themes/:rkey |
Admin | Update an existing theme's tokens, name, colorScheme, etc. |
DELETE /api/themes/:rkey |
Admin | Delete a theme. Fails if it's currently a default. |
PUT /api/theme-policy |
Admin | Update the themePolicy singleton (available list, defaults, allowUserChoice). |
PATCH /api/membership/theme |
User | Set preferredTheme on the caller's membership record (writes to their PDS). Pass null to clear. |
Caching#
The web server caches resolved theme data aggressively since themes change rarely:
- 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: Cookieor equivalent for HTTP caching. - Theme policy: Cached alongside forum metadata. Same invalidation path.
- User preference: Looked up from the AppView's indexed
membershiprecords (local DB query, not a PDS fetch per request).
Built-in Preset Themes#
Built-in presets ship as canonical records on the atbb.space PDS, published and maintained by the atBB project. Fresh installations reference these canonical records in their default themePolicy using live (URI-only) themeRefs — no local seeding step required.
| Preset | Color Scheme | Canonical rkey |
|---|---|---|
| Neobrutal Light (default light) | light | neobrutal-light |
| Neobrutal Dark (default dark) | dark | neobrutal-dark |
| Clean Light | light | clean-light |
| Clean Dark | dark | clean-dark |
| Classic BB | light | classic-bb |
Canonical URIs follow the pattern: at://did:web:atbb.space/space.atbb.forum.theme/<rkey>
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.
The bundled JSON files in apps/web/src/styles/presets/ serve two purposes:
- Hardcoded fallback — used directly if no theme policy can be resolved (no network, no PDS write yet).
- Deployment pipeline source — the release script reads these files and writes them to
atbb.space's PDS.
Deployment Pipeline#
When atBB releases a new version with updated preset tokens, a release script:
- Reads the bundled preset JSON files (canonical source of truth in the app repo)
- Writes them to
atbb.space's PDS under stable rkeys using the Forum DID's signing keys (same URI every release; new CID only when content changes) - Skips records where token values are unchanged (idempotent)
Forums using live (URI-only) references pick up changes after cache expiry. Forums using pinned (URI+CID) references remain on the locked version until the operator updates.
Local Escape Hatch#
Operators who want zero external dependencies run:
atbb bootstrap --local-presets
This command:
- Reads the bundled preset JSON files
- Writes them to the forum's own PDS under the same stable rkeys
- Updates
themePolicyto point at the local URIs instead ofatbb.space
After this, the forum is fully self-contained. The admin can still customize individual presets via the theme editor.
CSS Architecture#
File Structure (future)#
packages/web/src/
styles/
reset.css # Minimal normalize/reset
theme.css # All component styles using var(--token) references
presets/
neobrutal-light.json # Hardcoded fallback + deployment pipeline source
neobrutal-dark.json # Hardcoded fallback + deployment pipeline source
clean-light.json # Hardcoded fallback + deployment pipeline source
clean-dark.json # Hardcoded fallback + deployment pipeline source
classic-bb.json # Hardcoded fallback + deployment pipeline source
The JSON files are not seeded into each forum's PDS on bootstrap. They serve as the hardcoded fallback (step 4 of the resolution waterfall) and as the source of truth for the release pipeline that publishes canonical records to atbb.space. See Built-in Preset Themes for the full model.
Base Stylesheet Approach#
theme.css is written once and never changes per-theme. It references custom properties exclusively:
/* Example — not final */
body {
font-family: var(--font-body);
font-size: var(--font-size-base);
line-height: var(--line-height-body);
color: var(--color-text);
background: var(--color-bg);
}
.card {
background: var(--color-surface);
border: var(--border-width) solid var(--color-border);
border-radius: var(--card-radius);
box-shadow: var(--card-shadow);
padding: var(--space-md);
}
.btn-primary {
background: var(--color-primary);
color: var(--color-surface);
border: var(--border-width) solid var(--color-border);
border-radius: var(--button-radius);
box-shadow: var(--button-shadow);
font-weight: var(--font-weight-bold);
padding: var(--space-sm) var(--space-md);
}
.btn-primary:hover {
background: var(--color-primary-hover);
transform: translate(2px, 2px);
box-shadow: 2px 2px 0 var(--color-shadow);
}
Server-Side Token Injection#
BaseLayout renders the active theme's tokens as a <style> block:
// Pseudocode — future implementation
const BaseLayout: FC<PropsWithChildren<LayoutProps>> = (props) => {
// Theme resolved server-side before render (see Theme Resolution section)
const theme = props.resolvedTheme;
const policy = props.themePolicy;
return (
<html lang="en">
<head>
<meta http-equiv="Accept-CH" content="Sec-CH-Prefers-Color-Scheme" />
<title>{props.title ?? "atBB Forum"}</title>
<style>{`:root { ${tokensToCss(theme.tokens)} }`}</style>
<link rel="stylesheet" href="/static/reset.css" />
<link rel="stylesheet" href="/static/theme.css" />
{theme.fontUrls?.map(url => (
<link rel="stylesheet" href={url} />
))}
{theme.cssOverrides && (
<style>{theme.cssOverrides}</style>
)}
<script src="https://unpkg.com/htmx.org@2.0.4" />
</head>
<body>
<header>
{/* ... nav ... */}
<button onclick="toggleColorScheme()">Light/Dark</button>
{policy.allowUserChoice && props.user && (
<ThemePicker
themes={props.availableThemes}
current={props.user.preferredTheme}
/>
)}
</header>
<main>{props.children}</main>
</body>
</html>
);
};
Theme Resolution#
When the web server handles a request, it resolves which theme to render using a waterfall:
1. User preference
Is the user logged in?
AND has a preferredTheme set on their membership record?
AND does the forum's themePolicy.allowUserChoice == true?
AND is preferredTheme.uri still in themePolicy.availableThemes?
AND (if preferredTheme.cid is set) does cid match current theme record?
→ Use their preferred theme.
2. Color scheme default
Read color scheme preference:
a. Cookie: atbb-color-scheme=light|dark
b. HTTP header: Sec-CH-Prefers-Color-Scheme (client hint)
c. Default: light
→ Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly.
If themeRef.cid is set, verify CID on fetch; mismatch → fall through.
If themeRef.cid is absent, fetch live current record at the URI.
3. Hardcoded fallback
If no theme policy exists, the resolved theme can't be loaded,
or the atbb.space PDS is unreachable:
→ Use the built-in neobrutal token values from the bundled JSON files
(no PDS or network needed, always works offline).
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.
Light/Dark Toggle#
The one piece of client interactivity that doesn't need HTMX — a vanilla JS color scheme toggle:
<!-- In the site header/footer -->
<button onclick="toggleColorScheme()">Light/Dark</button>
<script>
function toggleColorScheme() {
const current = document.cookie.match(/atbb-color-scheme=(light|dark)/)?.[1] ?? 'light';
const next = current === 'light' ? 'dark' : 'light';
document.cookie = `atbb-color-scheme=${next};path=/;max-age=31536000`;
location.reload();
}
</script>
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.
User Theme Picker#
For logged-in users (when themePolicy.allowUserChoice is true):
- A dropdown or modal in the user's settings area (or a compact picker in the site header)
- Shows the admin's curated
availableThemeslist with names and color swatch previews - Selecting one writes
preferredThemeto the user's membership record on their PDS - An "Auto (follow forum default)" option clears the preference, falling back to the light/dark defaults
- Implemented as a standard HTMX form —
hx-patchto the API, swap a success indicator
Implementation Phases#
This work is post-MVP. Suggested ordering:
Theme Phase 1: Foundation#
- Add
reset.cssandtheme.csswith custom property references - Hardcode neobrutal light tokens in
BaseLayoutas the default - Style all existing views (homepage, category, topic, compose, admin)
- Add static file serving (
/static/route via HonoserveStatic) - No admin editor, no dynamic themes — just ship a good-looking default
Theme Phase 2: Light/Dark + Token System#
- Define
space.atbb.forum.themelexicon (withcolorSchemefield usingknownValues) - Define
space.atbb.forum.themePolicylexicon as separate singleton (withthemeRefstrongRef wrapper) - Build
tokensToCss()utility - Ship built-in preset JSON files (neobrutal light + dark, clean light + dark, classic)
- Load theme policy + resolved theme from Forum DID's PDS (with caching)
- Inject tokens dynamically in
BaseLayoutbased on theme resolution waterfall - Add light/dark toggle (cookie-based, vanilla JS, ~6 lines)
- Add
Sec-CH-Prefers-Color-Schemeclient hint support as fallback - AppView endpoints:
GET /api/themes,GET /api/themes/:rkey,GET /api/theme-policy(REST, not XRPC)
Theme Phase 3: Admin Theme Management#
- CSS sanitization (mandatory gate): Server-side sanitization for
cssOverridesand freeformtokensmap before rendering. Raw CSS in<style>tags is a real exfiltration vector viaurl(),@import,@font-face. Must sanitize before this phase ships. - Admin endpoints:
POST/PUT/DELETE /api/themes,PUT /api/forum/theme-policy(AppView REST, not XRPC) - Theme list management UI (create, duplicate, delete, availability toggles)
- Default light/dark assignment dropdowns
allowUserChoicekill-switch toggle- Token editor with grouped controls + live preview via HTMX
- Import/export (JSON download/upload)
- Database additions:
themestable following(did, rkey, cid, indexed_at)pattern
Theme Phase 4: User Choice#
- Add
preferredThemefield tospace.atbb.membershiplexicon - User endpoint:
PATCH /api/membership/theme(AppView REST, not XRPC) - Theme picker UI for logged-in users (dropdown in settings or site header)
- "Auto (follow forum default)" option to clear preference
- Database additions:
preferred_theme_uricolumn onmembershipstable (nullable) - AppView indexes
preferredThemefrom membership records for fast lookup
Theme Phase 5: Polish#
- CSS override editor for advanced admins (with sanitization)
- Preset gallery expansion
- Theme sharing between forums (export includes metadata for discovery)
- HTMX-based theme swap without full page reload (stretch)
Open Questions#
- 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:
fontUrlsmust be constrained to HTTPS and consider an allowlist (Google Fonts, self-hosted paths) — arbitrary font URLs leak user IPs to third parties. - Theme record size limits. AT Proto records have size limits. If
cssOverridesgets large, might need to store it as a blob reference instead of inline. - 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?
- 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.
- Stale user preferences. If an admin removes a theme from
availableThemeswhile users have it as theirpreferredTheme, the resolution waterfall handles this gracefully (falls through to the color scheme default). But should we also notify affected users or silently degrade?