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:
- A lexicon change (
themeRef.cid→ optional) with a cascading TypeScript fix across 4 files - A DB migration (
themeCid→ nullable) - Three new preset JSON files + tests
- A release pipeline script to publish presets to
atbb.space - A
--local-presetsbootstrap 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.ts—theme_policy_available_themes.themeCid:.notNull()→ nullablepackages/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,uriToCidDB 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.availableThemes — cid 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" }(notheme:wrapper) - Add a test: "accepts themeRef without cid (live ref)" — submit a body with no
cidfields and verify the PDS record hasthemeRefs withoutcid
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.jsonapps/web/src/styles/presets/clean-dark.jsonapps/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 (
#1a1a2eor 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_KEYenv vars - Uses
ForumAgent(or directAtpAgent) toputRecordeach 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-runflag 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:
- Read preset JSON files
putRecordeach toFORUM_DID's PDS under same stable rkeysputRecordthethemePolicysingleton pointing atat://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
cidfields — 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-runcorrectly diffs changed vs unchanged presets