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

fix: include theme and themePolicy collections in backfill (#100)

* fix: include theme and themePolicy collections in backfill

Themes and theme policies were handled by the firehose but omitted from
FORUM_OWNED_COLLECTIONS and COLLECTION_HANDLER_MAP, so a backfill after
restart would never replay them from the PDS — causing 404s when the web
app tried to resolve a theme URI that existed on the PDS but not in the DB.

* fix: address review feedback on theme backfill PR

- Add syncRepoRecords dispatch tests for space.atbb.forum.theme and
space.atbb.forum.themePolicy — proves handleThemeCreate and
handleThemePolicyCreate are actually invoked, catches renames silently
- Add test verifying TypeError propagates when handler method is absent
on Indexer (covers the as-any cast gap)
- Re-throw isProgrammingError in syncRepoRecords outer catch so handler
bugs are not silently logged as pds_error
- Add null guard in themePolicyConfig.toInsertValues / toUpdateValues for
missing defaultLightTheme/defaultDarkTheme refs; returns null to skip
the insert rather than crashing with TypeError on malformed records

* fix: add missing CSS for settings page and theme swatch preview

Swatch spans were invisible because <span> collapses to zero size without
explicit dimensions. Also adds layout styles for settings-page, banners,
and form that were never written.

authored by

Malpercio and committed by
GitHub
5d56f072 b77c68ea

+247 -27
+120 -11
apps/appview/src/lib/__tests__/backfill-manager.test.ts
··· 138 138 mockIndexer = { 139 139 handlePostCreate: vi.fn().mockResolvedValue(true), 140 140 handleForumCreate: vi.fn().mockResolvedValue(true), 141 + handleThemeCreate: vi.fn().mockResolvedValue(true), 142 + handleThemePolicyCreate: vi.fn().mockResolvedValue(true), 141 143 } as unknown as Indexer; 142 144 }); 143 145 ··· 287 289 expect(stats.errors).toBe(1); 288 290 consoleSpy.mockRestore(); 289 291 }); 292 + 293 + it("dispatches handleThemeCreate for space.atbb.forum.theme records", async () => { 294 + const mockAgent = new AtpAgent({ service: "https://pds.example.com" }); 295 + (mockAgent.com.atproto.repo.listRecords as any).mockResolvedValueOnce({ 296 + data: { 297 + records: [{ 298 + uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark", 299 + cid: "bafytheme1", 300 + value: { 301 + $type: "space.atbb.forum.theme", 302 + name: "Neobrutal Dark", 303 + colorScheme: "dark", 304 + tokens: { "color-bg": "#1a1a1a" }, 305 + createdAt: "2026-01-01T00:00:00Z", 306 + }, 307 + }], 308 + cursor: undefined, 309 + }, 310 + }); 311 + 312 + manager.setIndexer(mockIndexer); 313 + const stats = await manager.syncRepoRecords( 314 + "did:web:atbb.space", 315 + "space.atbb.forum.theme", 316 + mockAgent 317 + ); 318 + 319 + expect(stats.recordsFound).toBe(1); 320 + expect(stats.recordsIndexed).toBe(1); 321 + expect(stats.errors).toBe(0); 322 + expect(mockIndexer.handleThemeCreate).toHaveBeenCalledTimes(1); 323 + expect(mockIndexer.handleThemeCreate).toHaveBeenCalledWith( 324 + expect.objectContaining({ 325 + did: "did:web:atbb.space", 326 + commit: expect.objectContaining({ 327 + rkey: "neobrutal-dark", 328 + cid: "bafytheme1", 329 + record: expect.objectContaining({ name: "Neobrutal Dark", colorScheme: "dark" }), 330 + }), 331 + }) 332 + ); 333 + }); 334 + 335 + it("dispatches handleThemePolicyCreate for space.atbb.forum.themePolicy records", async () => { 336 + const mockAgent = new AtpAgent({ service: "https://pds.example.com" }); 337 + (mockAgent.com.atproto.repo.listRecords as any).mockResolvedValueOnce({ 338 + data: { 339 + records: [{ 340 + uri: "at://did:web:atbb.space/space.atbb.forum.themePolicy/self", 341 + cid: "bafypolicy1", 342 + value: { 343 + $type: "space.atbb.forum.themePolicy", 344 + availableThemes: [ 345 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 346 + ], 347 + defaultLightTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 348 + defaultDarkTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 349 + allowUserChoice: true, 350 + }, 351 + }], 352 + cursor: undefined, 353 + }, 354 + }); 355 + 356 + manager.setIndexer(mockIndexer); 357 + const stats = await manager.syncRepoRecords( 358 + "did:web:atbb.space", 359 + "space.atbb.forum.themePolicy", 360 + mockAgent 361 + ); 362 + 363 + expect(stats.recordsFound).toBe(1); 364 + expect(stats.recordsIndexed).toBe(1); 365 + expect(stats.errors).toBe(0); 366 + expect(mockIndexer.handleThemePolicyCreate).toHaveBeenCalledTimes(1); 367 + expect(mockIndexer.handleThemePolicyCreate).toHaveBeenCalledWith( 368 + expect.objectContaining({ 369 + did: "did:web:atbb.space", 370 + commit: expect.objectContaining({ 371 + rkey: "self", 372 + cid: "bafypolicy1", 373 + }), 374 + }) 375 + ); 376 + }); 377 + 378 + it("returns error stats when handler method is missing on Indexer (as-any cast gap)", async () => { 379 + // COLLECTION_HANDLER_MAP entry exists but the method is absent on the indexer. 380 + // .bind() on undefined throws TypeError which propagates out of syncRepoRecords 381 + // and fails performBackfill's outer catch rather than being silently swallowed. 382 + const brokenIndexer = {} as unknown as Indexer; 383 + manager.setIndexer(brokenIndexer); 384 + 385 + const mockAgent = new AtpAgent({ service: "https://pds.example.com" }); 386 + // listRecords would never be called — the TypeError fires before the do-while 387 + await expect( 388 + manager.syncRepoRecords("did:web:atbb.space", "space.atbb.forum.theme", mockAgent) 389 + ).rejects.toThrow(TypeError); 390 + }); 290 391 }); 291 392 292 393 describe("performBackfill", () => { ··· 306 407 handleMembershipCreate: vi.fn().mockResolvedValue(true), 307 408 handlePostCreate: vi.fn().mockResolvedValue(true), 308 409 handleModActionCreate: vi.fn().mockResolvedValue(true), 410 + handleThemeCreate: vi.fn().mockResolvedValue(true), 411 + handleThemePolicyCreate: vi.fn().mockResolvedValue(true), 309 412 } as unknown as Indexer; 310 413 }); 311 414 ··· 458 561 }); 459 562 460 563 it("CatchUp: syncs user-owned collections and aggregates counts", async () => { 461 - // Phase 1 (5 FORUM_OWNED_COLLECTIONS) must return empty so its records don't 564 + // Phase 1 (7 FORUM_OWNED_COLLECTIONS) must return empty so its records don't 462 565 // pollute the count. Phase 2: 2 users × 2 USER_OWNED_COLLECTIONS × 1 record = 4. 463 566 const emptyPage = { data: { records: [], cursor: undefined } }; 464 567 const recordPage = { ··· 473 576 }; 474 577 475 578 const mockListRecords = vi.fn() 476 - .mockResolvedValueOnce(emptyPage) // space.atbb.forum.forum (Phase 1 call 1) 477 - .mockResolvedValueOnce(emptyPage) // space.atbb.forum.category (Phase 1 call 2) 478 - .mockResolvedValueOnce(emptyPage) // space.atbb.forum.board (Phase 1 call 3) 479 - .mockResolvedValueOnce(emptyPage) // space.atbb.forum.role (Phase 1 call 4) 480 - .mockResolvedValueOnce(emptyPage) // space.atbb.modAction (Phase 1 call 5) 579 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.forum (Phase 1 call 1) 580 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.category (Phase 1 call 2) 581 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.board (Phase 1 call 3) 582 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.role (Phase 1 call 4) 583 + .mockResolvedValueOnce(emptyPage) // space.atbb.modAction (Phase 1 call 5) 584 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.theme (Phase 1 call 6) 585 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.themePolicy (Phase 1 call 7) 481 586 .mockResolvedValue(recordPage); // all Phase 2 user collection calls 482 587 483 588 mockDb = { ··· 524 629 const emptyPage = { data: { records: [], cursor: undefined } }; 525 630 526 631 const mockListRecords = vi.fn() 527 - .mockResolvedValueOnce(emptyPage) // space.atbb.forum.forum (Phase 1 call 1) 528 - .mockResolvedValueOnce(emptyPage) // space.atbb.forum.category (Phase 1 call 2) 529 - .mockResolvedValueOnce(emptyPage) // space.atbb.forum.board (Phase 1 call 3) 530 - .mockResolvedValueOnce(emptyPage) // space.atbb.forum.role (Phase 1 call 4) 531 - .mockResolvedValueOnce(emptyPage) // space.atbb.modAction (Phase 1 call 5) 632 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.forum (Phase 1 call 1) 633 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.category (Phase 1 call 2) 634 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.board (Phase 1 call 3) 635 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.role (Phase 1 call 4) 636 + .mockResolvedValueOnce(emptyPage) // space.atbb.modAction (Phase 1 call 5) 637 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.theme (Phase 1 call 6) 638 + .mockResolvedValueOnce(emptyPage) // space.atbb.forum.themePolicy (Phase 1 call 7) 532 639 // user1: both collections succeed, 1 record each 533 640 .mockResolvedValueOnce({ data: { records: [{ 534 641 uri: "at://did:plc:user1/space.atbb.membership/self", ··· 682 789 handleMembershipCreate: vi.fn().mockResolvedValue(true), 683 790 handlePostCreate: vi.fn().mockResolvedValue(true), 684 791 handleModActionCreate: vi.fn().mockResolvedValue(true), 792 + handleThemeCreate: vi.fn().mockResolvedValue(true), 793 + handleThemePolicyCreate: vi.fn().mockResolvedValue(true), 685 794 } as unknown as Indexer; 686 795 }); 687 796
+5
apps/appview/src/lib/backfill-manager.ts
··· 19 19 "space.atbb.forum.board", 20 20 "space.atbb.forum.role", 21 21 "space.atbb.modAction", 22 + "space.atbb.forum.theme", 23 + "space.atbb.forum.themePolicy", 22 24 ] as const; 23 25 24 26 export const USER_OWNED_COLLECTIONS = [ ··· 34 36 "space.atbb.forum.role": "handleRoleCreate", 35 37 "space.atbb.membership": "handleMembershipCreate", 36 38 "space.atbb.modAction": "handleModActionCreate", 39 + "space.atbb.forum.theme": "handleThemeCreate", 40 + "space.atbb.forum.themePolicy": "handleThemePolicyCreate", 37 41 }; 38 42 39 43 export enum BackfillStatus { ··· 146 150 } 147 151 } while (cursor); 148 152 } catch (error) { 153 + if (isProgrammingError(error)) throw error; 149 154 stats.errors++; 150 155 this.logger.error("backfill.pds_error", { 151 156 event: "backfill.pds_error",
+34 -16
apps/appview/src/lib/indexer.ts
··· 390 390 name: "ThemePolicy", 391 391 table: themePolicies, 392 392 deleteStrategy: "hard", 393 - toInsertValues: async (event, record) => ({ 394 - did: event.did, 395 - rkey: event.commit.rkey, 396 - cid: event.commit.cid, 397 - defaultLightThemeUri: record.defaultLightTheme.uri, 398 - defaultDarkThemeUri: record.defaultDarkTheme.uri, 399 - allowUserChoice: record.allowUserChoice, 400 - indexedAt: new Date(), 401 - }), 402 - toUpdateValues: async (event, record) => ({ 403 - cid: event.commit.cid, 404 - defaultLightThemeUri: record.defaultLightTheme.uri, 405 - defaultDarkThemeUri: record.defaultDarkTheme.uri, 406 - allowUserChoice: record.allowUserChoice, 407 - indexedAt: new Date(), 408 - }), 393 + toInsertValues: async (event, record) => { 394 + if (!record.defaultLightTheme?.uri || !record.defaultDarkTheme?.uri) { 395 + this.logger.warn("ThemePolicy record missing required theme refs — skipping", { 396 + did: event.did, 397 + rkey: event.commit.rkey, 398 + }); 399 + return null; 400 + } 401 + return { 402 + did: event.did, 403 + rkey: event.commit.rkey, 404 + cid: event.commit.cid, 405 + defaultLightThemeUri: record.defaultLightTheme.uri, 406 + defaultDarkThemeUri: record.defaultDarkTheme.uri, 407 + allowUserChoice: record.allowUserChoice, 408 + indexedAt: new Date(), 409 + }; 410 + }, 411 + toUpdateValues: async (event, record) => { 412 + if (!record.defaultLightTheme?.uri || !record.defaultDarkTheme?.uri) { 413 + this.logger.warn("ThemePolicy record missing required theme refs — skipping update", { 414 + did: event.did, 415 + rkey: event.commit.rkey, 416 + }); 417 + return null; 418 + } 419 + return { 420 + cid: event.commit.cid, 421 + defaultLightThemeUri: record.defaultLightTheme.uri, 422 + defaultDarkThemeUri: record.defaultDarkTheme.uri, 423 + allowUserChoice: record.allowUserChoice, 424 + indexedAt: new Date(), 425 + }; 426 + }, 409 427 afterUpsert: async (_event, record, policyId, tx) => { 410 428 // Atomically replace all available-theme rows for this policy 411 429 await tx
+88
apps/web/public/static/css/theme.css
··· 1180 1180 font-size: var(--font-size-sm); 1181 1181 padding: var(--space-xs) var(--space-sm); 1182 1182 } 1183 + 1184 + /* ─── Settings Page ──────────────────────────────────────────────────────── */ 1185 + 1186 + .settings-page { 1187 + max-width: 40rem; 1188 + margin: 0 auto; 1189 + padding: var(--space-xl) var(--space-md); 1190 + } 1191 + 1192 + .settings-page h1 { 1193 + margin-bottom: var(--space-lg); 1194 + } 1195 + 1196 + .settings-page section { 1197 + margin-bottom: var(--space-xl); 1198 + } 1199 + 1200 + .settings-page h2 { 1201 + margin-bottom: var(--space-md); 1202 + } 1203 + 1204 + .settings-banner { 1205 + padding: var(--space-sm) var(--space-md); 1206 + border-radius: var(--radius); 1207 + border: var(--border-width) solid var(--color-border); 1208 + margin-bottom: var(--space-md); 1209 + background: var(--color-surface); 1210 + } 1211 + 1212 + .settings-banner--success { 1213 + border-color: var(--color-success); 1214 + color: var(--color-success); 1215 + } 1216 + 1217 + .settings-banner--error { 1218 + border-color: var(--color-danger); 1219 + color: var(--color-danger); 1220 + } 1221 + 1222 + .settings-form { 1223 + display: flex; 1224 + flex-direction: column; 1225 + gap: var(--space-md); 1226 + } 1227 + 1228 + .settings-form__field { 1229 + display: flex; 1230 + flex-direction: column; 1231 + gap: var(--space-xs); 1232 + } 1233 + 1234 + .settings-form__field label { 1235 + font-weight: var(--font-weight-bold); 1236 + font-size: var(--font-size-sm); 1237 + } 1238 + 1239 + .settings-form__submit { 1240 + align-self: flex-start; 1241 + } 1242 + 1243 + /* ─── Theme Swatch Preview ───────────────────────────────────────────────── */ 1244 + 1245 + .theme-preview { 1246 + display: flex; 1247 + align-items: center; 1248 + gap: var(--space-sm); 1249 + padding: var(--space-xs) 0; 1250 + min-height: 2rem; 1251 + } 1252 + 1253 + .theme-preview__name { 1254 + font-size: var(--font-size-sm); 1255 + color: var(--color-text-muted); 1256 + } 1257 + 1258 + .theme-preview__swatches { 1259 + display: flex; 1260 + gap: 4px; 1261 + } 1262 + 1263 + .theme-preview__swatch { 1264 + display: inline-block; 1265 + width: 1.25rem; 1266 + height: 1.25rem; 1267 + border-radius: var(--radius); 1268 + border: 1px solid rgba(0, 0, 0, 0.15); 1269 + flex-shrink: 0; 1270 + }