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

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#

  1. Admin control without code. Forum admins pick a theme or tweak design tokens from an admin panel — no CSS authoring needed.
  2. Theme-as-data. Themes are serializable JSON stored as AT Proto records on the Forum DID's PDS, making them portable and versionable.
  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.
  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.
  5. 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              │
└─────────────────────────────┘         └───────────────────────────────────┘
  1. Theme records live on the Forum DID's PDS as space.atbb.forum.theme records. A forum can have many saved themes.
  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.
  3. 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>.
  4. A single base stylesheet (theme.css) references only custom properties — never hardcoded colors or sizes. Swapping property values completely changes the look.
  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.

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#

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 colorScheme badges (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 light or dark mode
  • 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 an hx-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 + colorScheme fields serialized.
  • Theme policy changes (defaults, available list, allowUserChoice) write to the space.atbb.forum.themePolicy singleton 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: Cookie or equivalent for HTTP caching.
  • Theme policy: Cached alongside forum metadata. Same invalidation path.
  • User preference: Looked up from the AppView's indexed membership records (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:

  1. Hardcoded fallback — used directly if no theme policy can be resolved (no network, no PDS write yet).
  2. 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:

  1. Reads the bundled preset JSON files (canonical source of truth in the app repo)
  2. 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)
  3. 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:

  1. Reads the bundled preset JSON files
  2. Writes them to the forum's own PDS under the same stable rkeys
  3. Updates themePolicy to point at the local URIs instead of atbb.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 availableThemes list with names and color swatch previews
  • Selecting one writes preferredTheme to 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-patch to the API, swap a success indicator

Implementation Phases#

This work is post-MVP. Suggested ordering:

Theme Phase 1: Foundation#

  • Add reset.css and theme.css with custom property references
  • Hardcode neobrutal light tokens in BaseLayout as the default
  • Style all existing views (homepage, category, topic, compose, admin)
  • Add static file serving (/static/ route via Hono serveStatic)
  • No admin editor, no dynamic themes — just ship a good-looking default

Theme Phase 2: Light/Dark + Token System#

  • Define space.atbb.forum.theme lexicon (with colorScheme field using knownValues)
  • Define space.atbb.forum.themePolicy lexicon as separate singleton (with themeRef strongRef 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 BaseLayout based on theme resolution waterfall
  • Add light/dark toggle (cookie-based, vanilla JS, ~6 lines)
  • Add Sec-CH-Prefers-Color-Scheme client 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 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.
  • 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
  • allowUserChoice kill-switch toggle
  • Token editor with grouped controls + live preview via HTMX
  • Import/export (JSON download/upload)
  • Database additions: themes table following (did, rkey, cid, indexed_at) pattern

Theme Phase 4: User Choice#

  • Add preferredTheme field to space.atbb.membership lexicon
  • 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_uri column on memberships table (nullable)
  • AppView indexes preferredTheme from 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#

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