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
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

ATB-61: Built-in Preset Themes — Implementation Plan#

Branch: root/atb-61-ship-built-in-theme-presets Design doc: docs/theming-plan.md — Built-in Preset Themes section Linear: ATB-61


Overview#

Built-in presets are published as canonical records on atbb.space's PDS. Fresh installs reference them via live (URI-only) themeRefs. This requires:

  1. A lexicon change (themeRef.cid → optional) with a cascading TypeScript fix across 4 files
  2. A DB migration (themeCid → nullable)
  3. Three new preset JSON files + tests
  4. A release pipeline script to publish presets to atbb.space
  5. A --local-presets bootstrap escape hatch

Phase 1: Lexicon + DB migration + TypeScript cascade#

1.1 — Update themePolicy.yaml#

File: packages/lexicon/lexicons/space/atbb/forum/themePolicy.yaml

Replace the themeRef def. Remove the com.atproto.repo.strongRef wrapper; make cid optional:

themeRef:
  type: object
  description: >-
    A reference to a theme record. When 'cid' is present the reference is
    pinned — the appview verifies CID on fetch and falls through on mismatch.
    When 'cid' is absent the reference is live — always resolves the current
    record at the URI (used by default for canonical atbb.space presets).
  required:
    - uri
  properties:
    uri:
      type: string
      format: at-uri
      description: AT-URI of the space.atbb.forum.theme record.
    cid:
      type: string
      format: cid
      description: >-
        Optional CID. When set, the appview pins to this exact record version.
        When absent, always resolves the current record at the URI.

Then rebuild: pnpm --filter @atbb/lexicon build

1.2 — DB schema: make themeCid nullable#

Files:

  • packages/db/src/schema.tstheme_policy_available_themes.themeCid: .notNull() → nullable
  • packages/db/src/schema.sqlite.ts — same change

Run to generate migration: pnpm --filter @atbb/appview db:generate Run to apply: pnpm --filter @atbb/appview db:migrate

1.3 — Fix indexer.ts#

File: apps/appview/src/lib/indexer.ts

The themePolicyConfig accesses themeRef.theme.uri and .theme.cid via the old nested wrapper. After the lexicon change these become themeRef.uri and themeRef.cid.

// Before
defaultLightThemeUri: record.defaultLightTheme.theme.uri,
defaultDarkThemeUri: record.defaultDarkTheme.theme.uri,
// ...
themeUri: themeRef.theme.uri,
themeCid: themeRef.theme.cid,

// After
defaultLightThemeUri: record.defaultLightTheme.uri,
defaultDarkThemeUri: record.defaultDarkTheme.uri,
// ...
themeUri: themeRef.uri,
themeCid: themeRef.cid ?? null,

1.4 — Simplify admin.ts PUT /api/admin/theme-policy#

File: apps/appview/src/routes/admin.ts

The existing route auto-fills missing CIDs from the local DB and rejects URIs not found locally. This must be removed — atbb.space URIs won't be in the local DB, and CID is now genuinely optional (live refs).

Remove the entire CID-fill block (roughly lines 1483–1527):

  • isMissingCid, uriToCid, needsLookup, unresolvedUris, uriToCid DB lookup, rejection check

Replace resolvedThemes map with a passthrough:

const resolvedThemes = typedAvailableThemes.map((t) => ({
  uri: t.uri,
  cid: typeof t.cid === "string" && t.cid !== "" ? t.cid : undefined,
}));

Update PDS record write to use flat themeRef (no theme: wrapper):

availableThemes: resolvedThemes.map((t) => ({
  uri: t.uri,
  ...(t.cid !== undefined ? { cid: t.cid } : {}),
})),
defaultLightTheme: { uri: lightTheme.uri, ...(lightTheme.cid !== undefined ? { cid: lightTheme.cid } : {}) },
defaultDarkTheme:  { uri: darkTheme.uri,  ...(darkTheme.cid !== undefined ? { cid: darkTheme.cid } : {}) },

1.5 — Fix theme-resolution.ts#

File: apps/web/src/lib/theme-resolution.ts

Update ThemePolicyResponse.availableThemescid is now optional:

availableThemes: Array<{ uri: string; cid?: string }>;

Update the CID lookup and check:

const expectedCid =
  policy.availableThemes.find((t) => t.uri === defaultUri)?.cid ?? null;
// expectedCid === null means live ref — skip CID check (already does this, just update the type)

Also update admin-themes.tsx which has the same ThemePolicy type locally (line 33):

availableThemes: Array<{ uri: string; cid?: string }>;

1.6 — Update admin.test.ts#

File: apps/appview/src/routes/__tests__/admin.test.ts

Update the "writes PDS record with themeRef wrapper structure" test (line 3201):

  • Rename to "writes PDS record with flat themeRef structure"
  • Change assertions: { uri: lightUri, cid: "bafylight" } (no theme: wrapper)
  • Add a test: "accepts themeRef without cid (live ref)" — submit a body with no cid fields and verify the PDS record has themeRefs without cid

1.7 — Verify Phase 1#

pnpm --filter @atbb/lexicon build
pnpm --filter @atbb/appview test
pnpm --filter @atbb/web test

Phase 2: Remaining 3 preset JSON files#

2.1 — Design token values#

Files to create:

  • apps/web/src/styles/presets/clean-light.json
  • apps/web/src/styles/presets/clean-dark.json
  • apps/web/src/styles/presets/classic-bb.json

Each must contain all tokens from REQUIRED_TOKENS in presets.test.ts.

Clean Light — minimal, airy, soft:

  • Rounded corners (radius: "6px")
  • Thinner borders (border-width: "1px")
  • Soft shadows (card-shadow: "0 2px 8px rgba(0,0,0,0.08)")
  • Neutral palette (white bg, near-black text, blue primary)

Clean Dark — same proportions as Clean Light on dark surfaces:

  • Dark bg (#1a1a2e or similar), elevated surface (#16213e)
  • Muted borders, soft glow shadows

Classic BB — phpBB/vBulletin nostalgia:

  • Blue header palette (color-primary: "#336699")
  • Gray surfaces (color-bg: "#efeff0", color-surface: "#ffffff")
  • Smaller base font (font-size-base: "13px")
  • Slight radius (radius: "3px")
  • Thin borders (border-width: "1px")

2.2 — Update preset tests#

File: apps/web/src/styles/presets/__tests__/presets.test.ts

Add describe blocks for clean-light, clean-dark, and classic-bb matching the existing neobrutal test patterns.

2.3 — Update admin-themes.tsx THEME_PRESETS#

File: apps/web/src/routes/admin-themes.tsx

Import new presets and add to THEME_PRESETS map:

import cleanLight from "../styles/presets/clean-light.json";
import cleanDark from "../styles/presets/clean-dark.json";
import classicBb from "../styles/presets/classic-bb.json";

const THEME_PRESETS: Record<string, Record<string, string>> = {
  "neobrutal-light": neobrutalLight as Record<string, string>,
  "neobrutal-dark":  neobrutalDark  as Record<string, string>,
  "clean-light":     cleanLight     as Record<string, string>,
  "clean-dark":      cleanDark      as Record<string, string>,
  "classic-bb":      classicBb      as Record<string, string>,
  "blank": {},
};

2.4 — Verify Phase 2#

pnpm --filter @atbb/web test

Phase 3: Deployment pipeline script#

3.1 — Write scripts/publish-presets.ts#

File: apps/appview/scripts/publish-presets.ts

Script that publishes all 5 preset JSON files to atbb.space's PDS as space.atbb.forum.theme records under stable rkeys.

Key behaviors:

  • Reads preset files from ../../web/src/styles/presets/
  • Connects to atbb.space PDS using FORUM_DID + FORUM_DID_KEY env vars
  • Uses ForumAgent (or direct AtpAgent) to putRecord each preset
  • Rkeys: neobrutal-light, neobrutal-dark, clean-light, clean-dark, classic-bb
  • Idempotent: fetches existing record, compares token content, skips if unchanged
  • Dry-run mode: --dry-run flag prints what would change without writing

Add to package.json:

"publish-presets": "tsx --env-file=../../.env scripts/publish-presets.ts"

3.2 — Default themePolicy initialization#

Add a defaultThemePolicy helper (or extend the bootstrap script) that writes a themePolicy record with live refs to atbb.space when setting up a fresh forum. This runs as part of forum initialization, not as a periodic job.

The default policy:

{
  $type: "space.atbb.forum.themePolicy",
  availableThemes: [
    { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" },
    { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" },
    { uri: "at://did:web:atbb.space/space.atbb.forum.theme/clean-light" },
    { uri: "at://did:web:atbb.space/space.atbb.forum.theme/clean-dark" },
    { uri: "at://did:web:atbb.space/space.atbb.forum.theme/classic-bb" },
  ],
  defaultLightTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" },
  defaultDarkTheme:  { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" },
  allowUserChoice: true,
  updatedAt: new Date().toISOString(),
}

Phase 4: Local escape hatch#

4.1 — Write scripts/bootstrap-local-presets.ts#

File: apps/appview/scripts/bootstrap-local-presets.ts

Command that mirrors canonical presets onto the forum's own PDS and rewrites the themePolicy to point locally.

Steps:

  1. Read preset JSON files
  2. putRecord each to FORUM_DID's PDS under same stable rkeys
  3. putRecord the themePolicy singleton pointing at at://FORUM_DID/space.atbb.forum.theme/*

Add to package.json:

"bootstrap-local-presets": "tsx --env-file=../../.env scripts/bootstrap-local-presets.ts"

Document in CONTRIBUTING.md and the deployment guide.


Verification Checklist (all phases)#

pnpm --filter @atbb/lexicon build       # Lexicon + TS types regenerated
pnpm --filter @atbb/appview test        # All appview tests pass
pnpm --filter @atbb/web test            # All web tests pass (including 3 new presets)
pnpm test                               # Full suite via turbo

Manual:

  • Admin theme policy UI: submit with no cid fields — verify PDS record has URI-only themeRefs
  • Resolution waterfall: live ref fetches current theme; CID mismatch falls through to fallback
  • "Start from preset" dropdown populates all 5 presets in create form
  • publish-presets --dry-run correctly diffs changed vs unchanged presets