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

feat(theming): ship built-in preset themes with canonical atbb.space refs (ATB-61) (#93)

* feat(theming): ship built-in preset themes with canonical atbb.space refs (ATB-61)

Redesigns themeRef to use optional CID (live vs pinned refs), ships 5 complete
preset token sets, and adds release pipeline + escape-hatch scripts.

Lexicon:
- themePolicy#themeRef: replace strongRef wrapper with flat { uri, cid? }
uri-only = live ref (auto-updates); uri+cid = pinned ref (version-locked)

DB:
- theme_policy_available_themes.theme_cid: DROP NOT NULL (migration 0014)

Presets:
- Add clean-light.json, clean-dark.json, classic-bb.json (3 new presets)
- All 5 presets bundled as hardcoded fallback + deployment pipeline source

AppView:
- indexer: update themeRef field access (.theme.uri -> .uri, .theme.cid -> .cid)
- admin PUT /api/admin/theme-policy: remove CID-autofill/DB-lookup block;
pass CID through when provided, omit when absent; flat PDS record write
- theme-resolution: cid optional in ThemePolicyResponse type; split warning
for "URI not in availableThemes" vs "absent CID on live ref"

Scripts:
- publish-presets.ts: idempotent release pipeline script; skips unchanged
presets by comparing sorted token JSON; preserves createdAt on updates
- bootstrap-local-presets.ts: escape-hatch for zero-external-deps installs;
writes presets to forum's own PDS, rewrites themePolicy to local URIs

Docs:
- theming-plan.md: document canonical-presets design, live/pinned ref model,
deployment pipeline, local escape hatch, updated resolution waterfall
- ATB-61 Linear issue updated with new scope and acceptance criteria

* fix(atb-61): address PR review feedback on preset theme implementation

- Fix rkey regex to allow hyphens (/^[a-z0-9-]+$/i) — all 5 preset rkeys
contain hyphens (neobrutal-light, clean-dark, etc.) and were silently
falling back to the hardcoded theme
- Add live-ref resolveTheme test verifying CID check is skipped when
expectedCid is null (canonical atbb.space presets ship without CID)
- Add ThemePolicy indexer tests verifying flat .uri field access and
null themeCid for live refs
- Fix bare catch in publish-presets.ts to only swallow 404 (record not
found) and rethrow all other errors
- Add isRecordCurrent() helper including name/colorScheme in change
detection, not just tokens
- Replace non-null assertions on .find() in admin.ts with explicit guards
- Log existing themePolicy before overwriting in bootstrap-local-presets.ts
- Update Bruno Update Theme Policy docs: remove stale CID-lookup comment

* test(css-sanitizer): prove @IMPORT and EXPRESSION() case-insensitive handling

The sanitizer uses .toLowerCase() before comparing atrule/function names, so
uppercase obfuscation variants are already caught. These two tests document
that assumption explicitly so future changes can't silently break it.

* refactor(cli): move theme preset scripts into atbb CLI as theme subcommands

Moves `publish-presets.ts` and `bootstrap-local-presets.ts` from
`apps/appview/scripts/` into the `@atbb/cli` package as
`atbb theme publish-canonical` and `atbb theme bootstrap-local`.

Both commands sit alongside the existing init/category/board commands
and reuse the CLI's ForumAgent auth infrastructure. The appview npm
scripts that wrapped the old scripts are removed.

authored by

Malpercio and committed by
GitHub
61a234b5 fb9491c9

+2701 -170
+1
apps/appview/drizzle/0014_dry_madrox.sql
··· 1 + ALTER TABLE "theme_policy_available_themes" ALTER COLUMN "theme_cid" DROP NOT NULL;
+1526
apps/appview/drizzle/meta/0014_snapshot.json
··· 1 + { 2 + "id": "904a2091-92b9-4fb5-accf-b30dc9a1e9bf", 3 + "prevId": "50219693-221b-49a6-8363-f9de0bf6afdd", 4 + "version": "7", 5 + "dialect": "postgresql", 6 + "tables": { 7 + "public.backfill_errors": { 8 + "name": "backfill_errors", 9 + "schema": "", 10 + "columns": { 11 + "id": { 12 + "name": "id", 13 + "type": "bigserial", 14 + "primaryKey": true, 15 + "notNull": true 16 + }, 17 + "backfill_id": { 18 + "name": "backfill_id", 19 + "type": "bigint", 20 + "primaryKey": false, 21 + "notNull": true 22 + }, 23 + "did": { 24 + "name": "did", 25 + "type": "text", 26 + "primaryKey": false, 27 + "notNull": true 28 + }, 29 + "collection": { 30 + "name": "collection", 31 + "type": "text", 32 + "primaryKey": false, 33 + "notNull": true 34 + }, 35 + "error_message": { 36 + "name": "error_message", 37 + "type": "text", 38 + "primaryKey": false, 39 + "notNull": true 40 + }, 41 + "created_at": { 42 + "name": "created_at", 43 + "type": "timestamp with time zone", 44 + "primaryKey": false, 45 + "notNull": true 46 + } 47 + }, 48 + "indexes": { 49 + "backfill_errors_backfill_id_idx": { 50 + "name": "backfill_errors_backfill_id_idx", 51 + "columns": [ 52 + { 53 + "expression": "backfill_id", 54 + "isExpression": false, 55 + "asc": true, 56 + "nulls": "last" 57 + } 58 + ], 59 + "isUnique": false, 60 + "concurrently": false, 61 + "method": "btree", 62 + "with": {} 63 + } 64 + }, 65 + "foreignKeys": { 66 + "backfill_errors_backfill_id_backfill_progress_id_fk": { 67 + "name": "backfill_errors_backfill_id_backfill_progress_id_fk", 68 + "tableFrom": "backfill_errors", 69 + "tableTo": "backfill_progress", 70 + "columnsFrom": [ 71 + "backfill_id" 72 + ], 73 + "columnsTo": [ 74 + "id" 75 + ], 76 + "onDelete": "no action", 77 + "onUpdate": "no action" 78 + } 79 + }, 80 + "compositePrimaryKeys": {}, 81 + "uniqueConstraints": {}, 82 + "policies": {}, 83 + "checkConstraints": {}, 84 + "isRLSEnabled": false 85 + }, 86 + "public.backfill_progress": { 87 + "name": "backfill_progress", 88 + "schema": "", 89 + "columns": { 90 + "id": { 91 + "name": "id", 92 + "type": "bigserial", 93 + "primaryKey": true, 94 + "notNull": true 95 + }, 96 + "status": { 97 + "name": "status", 98 + "type": "text", 99 + "primaryKey": false, 100 + "notNull": true 101 + }, 102 + "backfill_type": { 103 + "name": "backfill_type", 104 + "type": "text", 105 + "primaryKey": false, 106 + "notNull": true 107 + }, 108 + "last_processed_did": { 109 + "name": "last_processed_did", 110 + "type": "text", 111 + "primaryKey": false, 112 + "notNull": false 113 + }, 114 + "dids_total": { 115 + "name": "dids_total", 116 + "type": "integer", 117 + "primaryKey": false, 118 + "notNull": true, 119 + "default": 0 120 + }, 121 + "dids_processed": { 122 + "name": "dids_processed", 123 + "type": "integer", 124 + "primaryKey": false, 125 + "notNull": true, 126 + "default": 0 127 + }, 128 + "records_indexed": { 129 + "name": "records_indexed", 130 + "type": "integer", 131 + "primaryKey": false, 132 + "notNull": true, 133 + "default": 0 134 + }, 135 + "started_at": { 136 + "name": "started_at", 137 + "type": "timestamp with time zone", 138 + "primaryKey": false, 139 + "notNull": true 140 + }, 141 + "completed_at": { 142 + "name": "completed_at", 143 + "type": "timestamp with time zone", 144 + "primaryKey": false, 145 + "notNull": false 146 + }, 147 + "error_message": { 148 + "name": "error_message", 149 + "type": "text", 150 + "primaryKey": false, 151 + "notNull": false 152 + } 153 + }, 154 + "indexes": {}, 155 + "foreignKeys": {}, 156 + "compositePrimaryKeys": {}, 157 + "uniqueConstraints": {}, 158 + "policies": {}, 159 + "checkConstraints": {}, 160 + "isRLSEnabled": false 161 + }, 162 + "public.boards": { 163 + "name": "boards", 164 + "schema": "", 165 + "columns": { 166 + "id": { 167 + "name": "id", 168 + "type": "bigserial", 169 + "primaryKey": true, 170 + "notNull": true 171 + }, 172 + "did": { 173 + "name": "did", 174 + "type": "text", 175 + "primaryKey": false, 176 + "notNull": true 177 + }, 178 + "rkey": { 179 + "name": "rkey", 180 + "type": "text", 181 + "primaryKey": false, 182 + "notNull": true 183 + }, 184 + "cid": { 185 + "name": "cid", 186 + "type": "text", 187 + "primaryKey": false, 188 + "notNull": true 189 + }, 190 + "name": { 191 + "name": "name", 192 + "type": "text", 193 + "primaryKey": false, 194 + "notNull": true 195 + }, 196 + "description": { 197 + "name": "description", 198 + "type": "text", 199 + "primaryKey": false, 200 + "notNull": false 201 + }, 202 + "slug": { 203 + "name": "slug", 204 + "type": "text", 205 + "primaryKey": false, 206 + "notNull": false 207 + }, 208 + "sort_order": { 209 + "name": "sort_order", 210 + "type": "integer", 211 + "primaryKey": false, 212 + "notNull": false 213 + }, 214 + "category_id": { 215 + "name": "category_id", 216 + "type": "bigint", 217 + "primaryKey": false, 218 + "notNull": false 219 + }, 220 + "category_uri": { 221 + "name": "category_uri", 222 + "type": "text", 223 + "primaryKey": false, 224 + "notNull": true 225 + }, 226 + "created_at": { 227 + "name": "created_at", 228 + "type": "timestamp with time zone", 229 + "primaryKey": false, 230 + "notNull": true 231 + }, 232 + "indexed_at": { 233 + "name": "indexed_at", 234 + "type": "timestamp with time zone", 235 + "primaryKey": false, 236 + "notNull": true 237 + } 238 + }, 239 + "indexes": { 240 + "boards_did_rkey_idx": { 241 + "name": "boards_did_rkey_idx", 242 + "columns": [ 243 + { 244 + "expression": "did", 245 + "isExpression": false, 246 + "asc": true, 247 + "nulls": "last" 248 + }, 249 + { 250 + "expression": "rkey", 251 + "isExpression": false, 252 + "asc": true, 253 + "nulls": "last" 254 + } 255 + ], 256 + "isUnique": true, 257 + "concurrently": false, 258 + "method": "btree", 259 + "with": {} 260 + }, 261 + "boards_category_id_idx": { 262 + "name": "boards_category_id_idx", 263 + "columns": [ 264 + { 265 + "expression": "category_id", 266 + "isExpression": false, 267 + "asc": true, 268 + "nulls": "last" 269 + } 270 + ], 271 + "isUnique": false, 272 + "concurrently": false, 273 + "method": "btree", 274 + "with": {} 275 + } 276 + }, 277 + "foreignKeys": { 278 + "boards_category_id_categories_id_fk": { 279 + "name": "boards_category_id_categories_id_fk", 280 + "tableFrom": "boards", 281 + "tableTo": "categories", 282 + "columnsFrom": [ 283 + "category_id" 284 + ], 285 + "columnsTo": [ 286 + "id" 287 + ], 288 + "onDelete": "no action", 289 + "onUpdate": "no action" 290 + } 291 + }, 292 + "compositePrimaryKeys": {}, 293 + "uniqueConstraints": {}, 294 + "policies": {}, 295 + "checkConstraints": {}, 296 + "isRLSEnabled": false 297 + }, 298 + "public.categories": { 299 + "name": "categories", 300 + "schema": "", 301 + "columns": { 302 + "id": { 303 + "name": "id", 304 + "type": "bigserial", 305 + "primaryKey": true, 306 + "notNull": true 307 + }, 308 + "did": { 309 + "name": "did", 310 + "type": "text", 311 + "primaryKey": false, 312 + "notNull": true 313 + }, 314 + "rkey": { 315 + "name": "rkey", 316 + "type": "text", 317 + "primaryKey": false, 318 + "notNull": true 319 + }, 320 + "cid": { 321 + "name": "cid", 322 + "type": "text", 323 + "primaryKey": false, 324 + "notNull": true 325 + }, 326 + "name": { 327 + "name": "name", 328 + "type": "text", 329 + "primaryKey": false, 330 + "notNull": true 331 + }, 332 + "description": { 333 + "name": "description", 334 + "type": "text", 335 + "primaryKey": false, 336 + "notNull": false 337 + }, 338 + "slug": { 339 + "name": "slug", 340 + "type": "text", 341 + "primaryKey": false, 342 + "notNull": false 343 + }, 344 + "sort_order": { 345 + "name": "sort_order", 346 + "type": "integer", 347 + "primaryKey": false, 348 + "notNull": false 349 + }, 350 + "forum_id": { 351 + "name": "forum_id", 352 + "type": "bigint", 353 + "primaryKey": false, 354 + "notNull": false 355 + }, 356 + "created_at": { 357 + "name": "created_at", 358 + "type": "timestamp with time zone", 359 + "primaryKey": false, 360 + "notNull": true 361 + }, 362 + "indexed_at": { 363 + "name": "indexed_at", 364 + "type": "timestamp with time zone", 365 + "primaryKey": false, 366 + "notNull": true 367 + } 368 + }, 369 + "indexes": { 370 + "categories_did_rkey_idx": { 371 + "name": "categories_did_rkey_idx", 372 + "columns": [ 373 + { 374 + "expression": "did", 375 + "isExpression": false, 376 + "asc": true, 377 + "nulls": "last" 378 + }, 379 + { 380 + "expression": "rkey", 381 + "isExpression": false, 382 + "asc": true, 383 + "nulls": "last" 384 + } 385 + ], 386 + "isUnique": true, 387 + "concurrently": false, 388 + "method": "btree", 389 + "with": {} 390 + } 391 + }, 392 + "foreignKeys": { 393 + "categories_forum_id_forums_id_fk": { 394 + "name": "categories_forum_id_forums_id_fk", 395 + "tableFrom": "categories", 396 + "tableTo": "forums", 397 + "columnsFrom": [ 398 + "forum_id" 399 + ], 400 + "columnsTo": [ 401 + "id" 402 + ], 403 + "onDelete": "no action", 404 + "onUpdate": "no action" 405 + } 406 + }, 407 + "compositePrimaryKeys": {}, 408 + "uniqueConstraints": {}, 409 + "policies": {}, 410 + "checkConstraints": {}, 411 + "isRLSEnabled": false 412 + }, 413 + "public.firehose_cursor": { 414 + "name": "firehose_cursor", 415 + "schema": "", 416 + "columns": { 417 + "service": { 418 + "name": "service", 419 + "type": "text", 420 + "primaryKey": true, 421 + "notNull": true, 422 + "default": "'jetstream'" 423 + }, 424 + "cursor": { 425 + "name": "cursor", 426 + "type": "bigint", 427 + "primaryKey": false, 428 + "notNull": true 429 + }, 430 + "updated_at": { 431 + "name": "updated_at", 432 + "type": "timestamp with time zone", 433 + "primaryKey": false, 434 + "notNull": true 435 + } 436 + }, 437 + "indexes": {}, 438 + "foreignKeys": {}, 439 + "compositePrimaryKeys": {}, 440 + "uniqueConstraints": {}, 441 + "policies": {}, 442 + "checkConstraints": {}, 443 + "isRLSEnabled": false 444 + }, 445 + "public.forums": { 446 + "name": "forums", 447 + "schema": "", 448 + "columns": { 449 + "id": { 450 + "name": "id", 451 + "type": "bigserial", 452 + "primaryKey": true, 453 + "notNull": true 454 + }, 455 + "did": { 456 + "name": "did", 457 + "type": "text", 458 + "primaryKey": false, 459 + "notNull": true 460 + }, 461 + "rkey": { 462 + "name": "rkey", 463 + "type": "text", 464 + "primaryKey": false, 465 + "notNull": true 466 + }, 467 + "cid": { 468 + "name": "cid", 469 + "type": "text", 470 + "primaryKey": false, 471 + "notNull": true 472 + }, 473 + "name": { 474 + "name": "name", 475 + "type": "text", 476 + "primaryKey": false, 477 + "notNull": true 478 + }, 479 + "description": { 480 + "name": "description", 481 + "type": "text", 482 + "primaryKey": false, 483 + "notNull": false 484 + }, 485 + "indexed_at": { 486 + "name": "indexed_at", 487 + "type": "timestamp with time zone", 488 + "primaryKey": false, 489 + "notNull": true 490 + } 491 + }, 492 + "indexes": { 493 + "forums_did_rkey_idx": { 494 + "name": "forums_did_rkey_idx", 495 + "columns": [ 496 + { 497 + "expression": "did", 498 + "isExpression": false, 499 + "asc": true, 500 + "nulls": "last" 501 + }, 502 + { 503 + "expression": "rkey", 504 + "isExpression": false, 505 + "asc": true, 506 + "nulls": "last" 507 + } 508 + ], 509 + "isUnique": true, 510 + "concurrently": false, 511 + "method": "btree", 512 + "with": {} 513 + } 514 + }, 515 + "foreignKeys": {}, 516 + "compositePrimaryKeys": {}, 517 + "uniqueConstraints": {}, 518 + "policies": {}, 519 + "checkConstraints": {}, 520 + "isRLSEnabled": false 521 + }, 522 + "public.memberships": { 523 + "name": "memberships", 524 + "schema": "", 525 + "columns": { 526 + "id": { 527 + "name": "id", 528 + "type": "bigserial", 529 + "primaryKey": true, 530 + "notNull": true 531 + }, 532 + "did": { 533 + "name": "did", 534 + "type": "text", 535 + "primaryKey": false, 536 + "notNull": true 537 + }, 538 + "rkey": { 539 + "name": "rkey", 540 + "type": "text", 541 + "primaryKey": false, 542 + "notNull": true 543 + }, 544 + "cid": { 545 + "name": "cid", 546 + "type": "text", 547 + "primaryKey": false, 548 + "notNull": true 549 + }, 550 + "forum_id": { 551 + "name": "forum_id", 552 + "type": "bigint", 553 + "primaryKey": false, 554 + "notNull": false 555 + }, 556 + "forum_uri": { 557 + "name": "forum_uri", 558 + "type": "text", 559 + "primaryKey": false, 560 + "notNull": true 561 + }, 562 + "role": { 563 + "name": "role", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false 567 + }, 568 + "role_uri": { 569 + "name": "role_uri", 570 + "type": "text", 571 + "primaryKey": false, 572 + "notNull": false 573 + }, 574 + "joined_at": { 575 + "name": "joined_at", 576 + "type": "timestamp with time zone", 577 + "primaryKey": false, 578 + "notNull": false 579 + }, 580 + "created_at": { 581 + "name": "created_at", 582 + "type": "timestamp with time zone", 583 + "primaryKey": false, 584 + "notNull": true 585 + }, 586 + "indexed_at": { 587 + "name": "indexed_at", 588 + "type": "timestamp with time zone", 589 + "primaryKey": false, 590 + "notNull": true 591 + } 592 + }, 593 + "indexes": { 594 + "memberships_did_rkey_idx": { 595 + "name": "memberships_did_rkey_idx", 596 + "columns": [ 597 + { 598 + "expression": "did", 599 + "isExpression": false, 600 + "asc": true, 601 + "nulls": "last" 602 + }, 603 + { 604 + "expression": "rkey", 605 + "isExpression": false, 606 + "asc": true, 607 + "nulls": "last" 608 + } 609 + ], 610 + "isUnique": true, 611 + "concurrently": false, 612 + "method": "btree", 613 + "with": {} 614 + }, 615 + "memberships_did_idx": { 616 + "name": "memberships_did_idx", 617 + "columns": [ 618 + { 619 + "expression": "did", 620 + "isExpression": false, 621 + "asc": true, 622 + "nulls": "last" 623 + } 624 + ], 625 + "isUnique": false, 626 + "concurrently": false, 627 + "method": "btree", 628 + "with": {} 629 + } 630 + }, 631 + "foreignKeys": { 632 + "memberships_did_users_did_fk": { 633 + "name": "memberships_did_users_did_fk", 634 + "tableFrom": "memberships", 635 + "tableTo": "users", 636 + "columnsFrom": [ 637 + "did" 638 + ], 639 + "columnsTo": [ 640 + "did" 641 + ], 642 + "onDelete": "no action", 643 + "onUpdate": "no action" 644 + }, 645 + "memberships_forum_id_forums_id_fk": { 646 + "name": "memberships_forum_id_forums_id_fk", 647 + "tableFrom": "memberships", 648 + "tableTo": "forums", 649 + "columnsFrom": [ 650 + "forum_id" 651 + ], 652 + "columnsTo": [ 653 + "id" 654 + ], 655 + "onDelete": "no action", 656 + "onUpdate": "no action" 657 + } 658 + }, 659 + "compositePrimaryKeys": {}, 660 + "uniqueConstraints": {}, 661 + "policies": {}, 662 + "checkConstraints": {}, 663 + "isRLSEnabled": false 664 + }, 665 + "public.mod_actions": { 666 + "name": "mod_actions", 667 + "schema": "", 668 + "columns": { 669 + "id": { 670 + "name": "id", 671 + "type": "bigserial", 672 + "primaryKey": true, 673 + "notNull": true 674 + }, 675 + "did": { 676 + "name": "did", 677 + "type": "text", 678 + "primaryKey": false, 679 + "notNull": true 680 + }, 681 + "rkey": { 682 + "name": "rkey", 683 + "type": "text", 684 + "primaryKey": false, 685 + "notNull": true 686 + }, 687 + "cid": { 688 + "name": "cid", 689 + "type": "text", 690 + "primaryKey": false, 691 + "notNull": true 692 + }, 693 + "action": { 694 + "name": "action", 695 + "type": "text", 696 + "primaryKey": false, 697 + "notNull": true 698 + }, 699 + "subject_did": { 700 + "name": "subject_did", 701 + "type": "text", 702 + "primaryKey": false, 703 + "notNull": false 704 + }, 705 + "subject_post_uri": { 706 + "name": "subject_post_uri", 707 + "type": "text", 708 + "primaryKey": false, 709 + "notNull": false 710 + }, 711 + "forum_id": { 712 + "name": "forum_id", 713 + "type": "bigint", 714 + "primaryKey": false, 715 + "notNull": false 716 + }, 717 + "reason": { 718 + "name": "reason", 719 + "type": "text", 720 + "primaryKey": false, 721 + "notNull": false 722 + }, 723 + "created_by": { 724 + "name": "created_by", 725 + "type": "text", 726 + "primaryKey": false, 727 + "notNull": true 728 + }, 729 + "expires_at": { 730 + "name": "expires_at", 731 + "type": "timestamp with time zone", 732 + "primaryKey": false, 733 + "notNull": false 734 + }, 735 + "created_at": { 736 + "name": "created_at", 737 + "type": "timestamp with time zone", 738 + "primaryKey": false, 739 + "notNull": true 740 + }, 741 + "indexed_at": { 742 + "name": "indexed_at", 743 + "type": "timestamp with time zone", 744 + "primaryKey": false, 745 + "notNull": true 746 + } 747 + }, 748 + "indexes": { 749 + "mod_actions_did_rkey_idx": { 750 + "name": "mod_actions_did_rkey_idx", 751 + "columns": [ 752 + { 753 + "expression": "did", 754 + "isExpression": false, 755 + "asc": true, 756 + "nulls": "last" 757 + }, 758 + { 759 + "expression": "rkey", 760 + "isExpression": false, 761 + "asc": true, 762 + "nulls": "last" 763 + } 764 + ], 765 + "isUnique": true, 766 + "concurrently": false, 767 + "method": "btree", 768 + "with": {} 769 + }, 770 + "mod_actions_subject_did_idx": { 771 + "name": "mod_actions_subject_did_idx", 772 + "columns": [ 773 + { 774 + "expression": "subject_did", 775 + "isExpression": false, 776 + "asc": true, 777 + "nulls": "last" 778 + } 779 + ], 780 + "isUnique": false, 781 + "concurrently": false, 782 + "method": "btree", 783 + "with": {} 784 + }, 785 + "mod_actions_subject_post_uri_idx": { 786 + "name": "mod_actions_subject_post_uri_idx", 787 + "columns": [ 788 + { 789 + "expression": "subject_post_uri", 790 + "isExpression": false, 791 + "asc": true, 792 + "nulls": "last" 793 + } 794 + ], 795 + "isUnique": false, 796 + "concurrently": false, 797 + "method": "btree", 798 + "with": {} 799 + } 800 + }, 801 + "foreignKeys": { 802 + "mod_actions_forum_id_forums_id_fk": { 803 + "name": "mod_actions_forum_id_forums_id_fk", 804 + "tableFrom": "mod_actions", 805 + "tableTo": "forums", 806 + "columnsFrom": [ 807 + "forum_id" 808 + ], 809 + "columnsTo": [ 810 + "id" 811 + ], 812 + "onDelete": "no action", 813 + "onUpdate": "no action" 814 + } 815 + }, 816 + "compositePrimaryKeys": {}, 817 + "uniqueConstraints": {}, 818 + "policies": {}, 819 + "checkConstraints": {}, 820 + "isRLSEnabled": false 821 + }, 822 + "public.posts": { 823 + "name": "posts", 824 + "schema": "", 825 + "columns": { 826 + "id": { 827 + "name": "id", 828 + "type": "bigserial", 829 + "primaryKey": true, 830 + "notNull": true 831 + }, 832 + "did": { 833 + "name": "did", 834 + "type": "text", 835 + "primaryKey": false, 836 + "notNull": true 837 + }, 838 + "rkey": { 839 + "name": "rkey", 840 + "type": "text", 841 + "primaryKey": false, 842 + "notNull": true 843 + }, 844 + "cid": { 845 + "name": "cid", 846 + "type": "text", 847 + "primaryKey": false, 848 + "notNull": true 849 + }, 850 + "title": { 851 + "name": "title", 852 + "type": "text", 853 + "primaryKey": false, 854 + "notNull": false 855 + }, 856 + "text": { 857 + "name": "text", 858 + "type": "text", 859 + "primaryKey": false, 860 + "notNull": true 861 + }, 862 + "forum_uri": { 863 + "name": "forum_uri", 864 + "type": "text", 865 + "primaryKey": false, 866 + "notNull": false 867 + }, 868 + "board_uri": { 869 + "name": "board_uri", 870 + "type": "text", 871 + "primaryKey": false, 872 + "notNull": false 873 + }, 874 + "board_id": { 875 + "name": "board_id", 876 + "type": "bigint", 877 + "primaryKey": false, 878 + "notNull": false 879 + }, 880 + "root_post_id": { 881 + "name": "root_post_id", 882 + "type": "bigint", 883 + "primaryKey": false, 884 + "notNull": false 885 + }, 886 + "parent_post_id": { 887 + "name": "parent_post_id", 888 + "type": "bigint", 889 + "primaryKey": false, 890 + "notNull": false 891 + }, 892 + "root_uri": { 893 + "name": "root_uri", 894 + "type": "text", 895 + "primaryKey": false, 896 + "notNull": false 897 + }, 898 + "parent_uri": { 899 + "name": "parent_uri", 900 + "type": "text", 901 + "primaryKey": false, 902 + "notNull": false 903 + }, 904 + "created_at": { 905 + "name": "created_at", 906 + "type": "timestamp with time zone", 907 + "primaryKey": false, 908 + "notNull": true 909 + }, 910 + "indexed_at": { 911 + "name": "indexed_at", 912 + "type": "timestamp with time zone", 913 + "primaryKey": false, 914 + "notNull": true 915 + }, 916 + "banned_by_mod": { 917 + "name": "banned_by_mod", 918 + "type": "boolean", 919 + "primaryKey": false, 920 + "notNull": true, 921 + "default": false 922 + }, 923 + "deleted_by_user": { 924 + "name": "deleted_by_user", 925 + "type": "boolean", 926 + "primaryKey": false, 927 + "notNull": true, 928 + "default": false 929 + } 930 + }, 931 + "indexes": { 932 + "posts_did_rkey_idx": { 933 + "name": "posts_did_rkey_idx", 934 + "columns": [ 935 + { 936 + "expression": "did", 937 + "isExpression": false, 938 + "asc": true, 939 + "nulls": "last" 940 + }, 941 + { 942 + "expression": "rkey", 943 + "isExpression": false, 944 + "asc": true, 945 + "nulls": "last" 946 + } 947 + ], 948 + "isUnique": true, 949 + "concurrently": false, 950 + "method": "btree", 951 + "with": {} 952 + }, 953 + "posts_forum_uri_idx": { 954 + "name": "posts_forum_uri_idx", 955 + "columns": [ 956 + { 957 + "expression": "forum_uri", 958 + "isExpression": false, 959 + "asc": true, 960 + "nulls": "last" 961 + } 962 + ], 963 + "isUnique": false, 964 + "concurrently": false, 965 + "method": "btree", 966 + "with": {} 967 + }, 968 + "posts_board_id_idx": { 969 + "name": "posts_board_id_idx", 970 + "columns": [ 971 + { 972 + "expression": "board_id", 973 + "isExpression": false, 974 + "asc": true, 975 + "nulls": "last" 976 + } 977 + ], 978 + "isUnique": false, 979 + "concurrently": false, 980 + "method": "btree", 981 + "with": {} 982 + }, 983 + "posts_board_uri_idx": { 984 + "name": "posts_board_uri_idx", 985 + "columns": [ 986 + { 987 + "expression": "board_uri", 988 + "isExpression": false, 989 + "asc": true, 990 + "nulls": "last" 991 + } 992 + ], 993 + "isUnique": false, 994 + "concurrently": false, 995 + "method": "btree", 996 + "with": {} 997 + }, 998 + "posts_root_post_id_idx": { 999 + "name": "posts_root_post_id_idx", 1000 + "columns": [ 1001 + { 1002 + "expression": "root_post_id", 1003 + "isExpression": false, 1004 + "asc": true, 1005 + "nulls": "last" 1006 + } 1007 + ], 1008 + "isUnique": false, 1009 + "concurrently": false, 1010 + "method": "btree", 1011 + "with": {} 1012 + } 1013 + }, 1014 + "foreignKeys": { 1015 + "posts_did_users_did_fk": { 1016 + "name": "posts_did_users_did_fk", 1017 + "tableFrom": "posts", 1018 + "tableTo": "users", 1019 + "columnsFrom": [ 1020 + "did" 1021 + ], 1022 + "columnsTo": [ 1023 + "did" 1024 + ], 1025 + "onDelete": "no action", 1026 + "onUpdate": "no action" 1027 + }, 1028 + "posts_board_id_boards_id_fk": { 1029 + "name": "posts_board_id_boards_id_fk", 1030 + "tableFrom": "posts", 1031 + "tableTo": "boards", 1032 + "columnsFrom": [ 1033 + "board_id" 1034 + ], 1035 + "columnsTo": [ 1036 + "id" 1037 + ], 1038 + "onDelete": "no action", 1039 + "onUpdate": "no action" 1040 + }, 1041 + "posts_root_post_id_posts_id_fk": { 1042 + "name": "posts_root_post_id_posts_id_fk", 1043 + "tableFrom": "posts", 1044 + "tableTo": "posts", 1045 + "columnsFrom": [ 1046 + "root_post_id" 1047 + ], 1048 + "columnsTo": [ 1049 + "id" 1050 + ], 1051 + "onDelete": "no action", 1052 + "onUpdate": "no action" 1053 + }, 1054 + "posts_parent_post_id_posts_id_fk": { 1055 + "name": "posts_parent_post_id_posts_id_fk", 1056 + "tableFrom": "posts", 1057 + "tableTo": "posts", 1058 + "columnsFrom": [ 1059 + "parent_post_id" 1060 + ], 1061 + "columnsTo": [ 1062 + "id" 1063 + ], 1064 + "onDelete": "no action", 1065 + "onUpdate": "no action" 1066 + } 1067 + }, 1068 + "compositePrimaryKeys": {}, 1069 + "uniqueConstraints": {}, 1070 + "policies": {}, 1071 + "checkConstraints": {}, 1072 + "isRLSEnabled": false 1073 + }, 1074 + "public.role_permissions": { 1075 + "name": "role_permissions", 1076 + "schema": "", 1077 + "columns": { 1078 + "role_id": { 1079 + "name": "role_id", 1080 + "type": "bigint", 1081 + "primaryKey": false, 1082 + "notNull": true 1083 + }, 1084 + "permission": { 1085 + "name": "permission", 1086 + "type": "text", 1087 + "primaryKey": false, 1088 + "notNull": true 1089 + } 1090 + }, 1091 + "indexes": {}, 1092 + "foreignKeys": { 1093 + "role_permissions_role_id_roles_id_fk": { 1094 + "name": "role_permissions_role_id_roles_id_fk", 1095 + "tableFrom": "role_permissions", 1096 + "tableTo": "roles", 1097 + "columnsFrom": [ 1098 + "role_id" 1099 + ], 1100 + "columnsTo": [ 1101 + "id" 1102 + ], 1103 + "onDelete": "cascade", 1104 + "onUpdate": "no action" 1105 + } 1106 + }, 1107 + "compositePrimaryKeys": { 1108 + "role_permissions_role_id_permission_pk": { 1109 + "name": "role_permissions_role_id_permission_pk", 1110 + "columns": [ 1111 + "role_id", 1112 + "permission" 1113 + ] 1114 + } 1115 + }, 1116 + "uniqueConstraints": {}, 1117 + "policies": {}, 1118 + "checkConstraints": {}, 1119 + "isRLSEnabled": false 1120 + }, 1121 + "public.roles": { 1122 + "name": "roles", 1123 + "schema": "", 1124 + "columns": { 1125 + "id": { 1126 + "name": "id", 1127 + "type": "bigserial", 1128 + "primaryKey": true, 1129 + "notNull": true 1130 + }, 1131 + "did": { 1132 + "name": "did", 1133 + "type": "text", 1134 + "primaryKey": false, 1135 + "notNull": true 1136 + }, 1137 + "rkey": { 1138 + "name": "rkey", 1139 + "type": "text", 1140 + "primaryKey": false, 1141 + "notNull": true 1142 + }, 1143 + "cid": { 1144 + "name": "cid", 1145 + "type": "text", 1146 + "primaryKey": false, 1147 + "notNull": true 1148 + }, 1149 + "name": { 1150 + "name": "name", 1151 + "type": "text", 1152 + "primaryKey": false, 1153 + "notNull": true 1154 + }, 1155 + "description": { 1156 + "name": "description", 1157 + "type": "text", 1158 + "primaryKey": false, 1159 + "notNull": false 1160 + }, 1161 + "priority": { 1162 + "name": "priority", 1163 + "type": "integer", 1164 + "primaryKey": false, 1165 + "notNull": true 1166 + }, 1167 + "created_at": { 1168 + "name": "created_at", 1169 + "type": "timestamp with time zone", 1170 + "primaryKey": false, 1171 + "notNull": true 1172 + }, 1173 + "indexed_at": { 1174 + "name": "indexed_at", 1175 + "type": "timestamp with time zone", 1176 + "primaryKey": false, 1177 + "notNull": true 1178 + } 1179 + }, 1180 + "indexes": { 1181 + "roles_did_rkey_idx": { 1182 + "name": "roles_did_rkey_idx", 1183 + "columns": [ 1184 + { 1185 + "expression": "did", 1186 + "isExpression": false, 1187 + "asc": true, 1188 + "nulls": "last" 1189 + }, 1190 + { 1191 + "expression": "rkey", 1192 + "isExpression": false, 1193 + "asc": true, 1194 + "nulls": "last" 1195 + } 1196 + ], 1197 + "isUnique": true, 1198 + "concurrently": false, 1199 + "method": "btree", 1200 + "with": {} 1201 + }, 1202 + "roles_did_idx": { 1203 + "name": "roles_did_idx", 1204 + "columns": [ 1205 + { 1206 + "expression": "did", 1207 + "isExpression": false, 1208 + "asc": true, 1209 + "nulls": "last" 1210 + } 1211 + ], 1212 + "isUnique": false, 1213 + "concurrently": false, 1214 + "method": "btree", 1215 + "with": {} 1216 + }, 1217 + "roles_did_name_idx": { 1218 + "name": "roles_did_name_idx", 1219 + "columns": [ 1220 + { 1221 + "expression": "did", 1222 + "isExpression": false, 1223 + "asc": true, 1224 + "nulls": "last" 1225 + }, 1226 + { 1227 + "expression": "name", 1228 + "isExpression": false, 1229 + "asc": true, 1230 + "nulls": "last" 1231 + } 1232 + ], 1233 + "isUnique": false, 1234 + "concurrently": false, 1235 + "method": "btree", 1236 + "with": {} 1237 + } 1238 + }, 1239 + "foreignKeys": {}, 1240 + "compositePrimaryKeys": {}, 1241 + "uniqueConstraints": {}, 1242 + "policies": {}, 1243 + "checkConstraints": {}, 1244 + "isRLSEnabled": false 1245 + }, 1246 + "public.theme_policies": { 1247 + "name": "theme_policies", 1248 + "schema": "", 1249 + "columns": { 1250 + "id": { 1251 + "name": "id", 1252 + "type": "bigserial", 1253 + "primaryKey": true, 1254 + "notNull": true 1255 + }, 1256 + "did": { 1257 + "name": "did", 1258 + "type": "text", 1259 + "primaryKey": false, 1260 + "notNull": true 1261 + }, 1262 + "rkey": { 1263 + "name": "rkey", 1264 + "type": "text", 1265 + "primaryKey": false, 1266 + "notNull": true 1267 + }, 1268 + "cid": { 1269 + "name": "cid", 1270 + "type": "text", 1271 + "primaryKey": false, 1272 + "notNull": true 1273 + }, 1274 + "default_light_theme_uri": { 1275 + "name": "default_light_theme_uri", 1276 + "type": "text", 1277 + "primaryKey": false, 1278 + "notNull": true 1279 + }, 1280 + "default_dark_theme_uri": { 1281 + "name": "default_dark_theme_uri", 1282 + "type": "text", 1283 + "primaryKey": false, 1284 + "notNull": true 1285 + }, 1286 + "allow_user_choice": { 1287 + "name": "allow_user_choice", 1288 + "type": "boolean", 1289 + "primaryKey": false, 1290 + "notNull": true 1291 + }, 1292 + "indexed_at": { 1293 + "name": "indexed_at", 1294 + "type": "timestamp with time zone", 1295 + "primaryKey": false, 1296 + "notNull": true 1297 + } 1298 + }, 1299 + "indexes": { 1300 + "theme_policies_did_rkey_idx": { 1301 + "name": "theme_policies_did_rkey_idx", 1302 + "columns": [ 1303 + { 1304 + "expression": "did", 1305 + "isExpression": false, 1306 + "asc": true, 1307 + "nulls": "last" 1308 + }, 1309 + { 1310 + "expression": "rkey", 1311 + "isExpression": false, 1312 + "asc": true, 1313 + "nulls": "last" 1314 + } 1315 + ], 1316 + "isUnique": true, 1317 + "concurrently": false, 1318 + "method": "btree", 1319 + "with": {} 1320 + } 1321 + }, 1322 + "foreignKeys": {}, 1323 + "compositePrimaryKeys": {}, 1324 + "uniqueConstraints": {}, 1325 + "policies": {}, 1326 + "checkConstraints": {}, 1327 + "isRLSEnabled": false 1328 + }, 1329 + "public.theme_policy_available_themes": { 1330 + "name": "theme_policy_available_themes", 1331 + "schema": "", 1332 + "columns": { 1333 + "policy_id": { 1334 + "name": "policy_id", 1335 + "type": "bigint", 1336 + "primaryKey": false, 1337 + "notNull": true 1338 + }, 1339 + "theme_uri": { 1340 + "name": "theme_uri", 1341 + "type": "text", 1342 + "primaryKey": false, 1343 + "notNull": true 1344 + }, 1345 + "theme_cid": { 1346 + "name": "theme_cid", 1347 + "type": "text", 1348 + "primaryKey": false, 1349 + "notNull": false 1350 + } 1351 + }, 1352 + "indexes": {}, 1353 + "foreignKeys": { 1354 + "theme_policy_available_themes_policy_id_theme_policies_id_fk": { 1355 + "name": "theme_policy_available_themes_policy_id_theme_policies_id_fk", 1356 + "tableFrom": "theme_policy_available_themes", 1357 + "tableTo": "theme_policies", 1358 + "columnsFrom": [ 1359 + "policy_id" 1360 + ], 1361 + "columnsTo": [ 1362 + "id" 1363 + ], 1364 + "onDelete": "cascade", 1365 + "onUpdate": "no action" 1366 + } 1367 + }, 1368 + "compositePrimaryKeys": { 1369 + "theme_policy_available_themes_policy_id_theme_uri_pk": { 1370 + "name": "theme_policy_available_themes_policy_id_theme_uri_pk", 1371 + "columns": [ 1372 + "policy_id", 1373 + "theme_uri" 1374 + ] 1375 + } 1376 + }, 1377 + "uniqueConstraints": {}, 1378 + "policies": {}, 1379 + "checkConstraints": {}, 1380 + "isRLSEnabled": false 1381 + }, 1382 + "public.themes": { 1383 + "name": "themes", 1384 + "schema": "", 1385 + "columns": { 1386 + "id": { 1387 + "name": "id", 1388 + "type": "bigserial", 1389 + "primaryKey": true, 1390 + "notNull": true 1391 + }, 1392 + "did": { 1393 + "name": "did", 1394 + "type": "text", 1395 + "primaryKey": false, 1396 + "notNull": true 1397 + }, 1398 + "rkey": { 1399 + "name": "rkey", 1400 + "type": "text", 1401 + "primaryKey": false, 1402 + "notNull": true 1403 + }, 1404 + "cid": { 1405 + "name": "cid", 1406 + "type": "text", 1407 + "primaryKey": false, 1408 + "notNull": true 1409 + }, 1410 + "name": { 1411 + "name": "name", 1412 + "type": "text", 1413 + "primaryKey": false, 1414 + "notNull": true 1415 + }, 1416 + "color_scheme": { 1417 + "name": "color_scheme", 1418 + "type": "text", 1419 + "primaryKey": false, 1420 + "notNull": true 1421 + }, 1422 + "tokens": { 1423 + "name": "tokens", 1424 + "type": "jsonb", 1425 + "primaryKey": false, 1426 + "notNull": true 1427 + }, 1428 + "css_overrides": { 1429 + "name": "css_overrides", 1430 + "type": "text", 1431 + "primaryKey": false, 1432 + "notNull": false 1433 + }, 1434 + "font_urls": { 1435 + "name": "font_urls", 1436 + "type": "text[]", 1437 + "primaryKey": false, 1438 + "notNull": false 1439 + }, 1440 + "created_at": { 1441 + "name": "created_at", 1442 + "type": "timestamp with time zone", 1443 + "primaryKey": false, 1444 + "notNull": true 1445 + }, 1446 + "indexed_at": { 1447 + "name": "indexed_at", 1448 + "type": "timestamp with time zone", 1449 + "primaryKey": false, 1450 + "notNull": true 1451 + } 1452 + }, 1453 + "indexes": { 1454 + "themes_did_rkey_idx": { 1455 + "name": "themes_did_rkey_idx", 1456 + "columns": [ 1457 + { 1458 + "expression": "did", 1459 + "isExpression": false, 1460 + "asc": true, 1461 + "nulls": "last" 1462 + }, 1463 + { 1464 + "expression": "rkey", 1465 + "isExpression": false, 1466 + "asc": true, 1467 + "nulls": "last" 1468 + } 1469 + ], 1470 + "isUnique": true, 1471 + "concurrently": false, 1472 + "method": "btree", 1473 + "with": {} 1474 + } 1475 + }, 1476 + "foreignKeys": {}, 1477 + "compositePrimaryKeys": {}, 1478 + "uniqueConstraints": {}, 1479 + "policies": {}, 1480 + "checkConstraints": {}, 1481 + "isRLSEnabled": false 1482 + }, 1483 + "public.users": { 1484 + "name": "users", 1485 + "schema": "", 1486 + "columns": { 1487 + "did": { 1488 + "name": "did", 1489 + "type": "text", 1490 + "primaryKey": true, 1491 + "notNull": true 1492 + }, 1493 + "handle": { 1494 + "name": "handle", 1495 + "type": "text", 1496 + "primaryKey": false, 1497 + "notNull": false 1498 + }, 1499 + "indexed_at": { 1500 + "name": "indexed_at", 1501 + "type": "timestamp with time zone", 1502 + "primaryKey": false, 1503 + "notNull": true 1504 + } 1505 + }, 1506 + "indexes": {}, 1507 + "foreignKeys": {}, 1508 + "compositePrimaryKeys": {}, 1509 + "uniqueConstraints": {}, 1510 + "policies": {}, 1511 + "checkConstraints": {}, 1512 + "isRLSEnabled": false 1513 + } 1514 + }, 1515 + "enums": {}, 1516 + "schemas": {}, 1517 + "sequences": {}, 1518 + "roles": {}, 1519 + "policies": {}, 1520 + "views": {}, 1521 + "_meta": { 1522 + "columns": {}, 1523 + "schemas": {}, 1524 + "tables": {} 1525 + } 1526 + }
+7
apps/appview/drizzle/meta/_journal.json
··· 99 99 "when": 1772482052223, 100 100 "tag": "0013_add_theme_tables", 101 101 "breakpoints": true 102 + }, 103 + { 104 + "idx": 14, 105 + "version": "7", 106 + "when": 1772851617906, 107 + "tag": "0014_dry_madrox", 108 + "breakpoints": true 102 109 } 103 110 ] 104 111 }
+134
apps/appview/src/lib/__tests__/indexer.test.ts
··· 1287 1287 }); 1288 1288 1289 1289 1290 + describe("ThemePolicy Handler", () => { 1291 + /** 1292 + * Creates a tracking DB for themePolicy tests. 1293 + * ThemePolicy's genericCreate path uses afterUpsert, which requires: 1294 + * 1st insert (themePolicies): .values().returning([{id}]) 1295 + * delete (themePolicyAvailableThemes): .where() 1296 + * 2nd insert (themePolicyAvailableThemes): .values() 1297 + */ 1298 + function createThemePolicyTrackingDb() { 1299 + let insertCallCount = 0; 1300 + let policyInsertValues: any = null; 1301 + let availableThemesInsertValues: any = null; 1302 + 1303 + const db = { 1304 + transaction: vi.fn().mockImplementation(async (callback: (tx: any) => Promise<any>) => { 1305 + const tx = { 1306 + insert: vi.fn().mockImplementation(() => ({ 1307 + values: vi.fn().mockImplementation((vals: any) => { 1308 + insertCallCount++; 1309 + if (insertCallCount === 1) { 1310 + policyInsertValues = vals; 1311 + return { returning: vi.fn().mockResolvedValue([{ id: 1n }]) }; 1312 + } 1313 + availableThemesInsertValues = vals; 1314 + return Promise.resolve(undefined); 1315 + }), 1316 + })), 1317 + delete: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) }), 1318 + update: vi.fn(), 1319 + select: vi.fn().mockReturnValue({ 1320 + from: vi.fn().mockReturnValue({ 1321 + where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), 1322 + }), 1323 + }), 1324 + }; 1325 + return await callback(tx); 1326 + }), 1327 + } as unknown as Database; 1328 + 1329 + return { 1330 + db, 1331 + getPolicyInsertValues: () => policyInsertValues, 1332 + getAvailableThemesInsertValues: () => availableThemesInsertValues, 1333 + }; 1334 + } 1335 + 1336 + it("indexes themePolicy with flat themeRef URIs — live refs (no CID)", async () => { 1337 + // This test verifies the field access uses .uri directly (not .theme.uri from old strongRef). 1338 + // If the old .defaultLightTheme.theme.uri path were used, this would throw TypeError. 1339 + const { db: trackingDb, getPolicyInsertValues, getAvailableThemesInsertValues } = 1340 + createThemePolicyTrackingDb(); 1341 + const themePolicyIndexer = new Indexer(trackingDb, mockLogger); 1342 + 1343 + const event: CommitCreateEvent<"space.atbb.forum.themePolicy"> = { 1344 + did: "did:plc:forum", 1345 + time_us: 1234567890, 1346 + kind: "commit", 1347 + commit: { 1348 + rev: "abc", 1349 + operation: "create", 1350 + collection: "space.atbb.forum.themePolicy", 1351 + rkey: "self", 1352 + cid: "cidPolicy", 1353 + record: { 1354 + $type: "space.atbb.forum.themePolicy", 1355 + defaultLightTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 1356 + defaultDarkTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 1357 + availableThemes: [ 1358 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 1359 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 1360 + ], 1361 + allowUserChoice: true, 1362 + updatedAt: "2026-01-01T00:00:00Z", 1363 + } as any, 1364 + }, 1365 + }; 1366 + 1367 + await expect(themePolicyIndexer.handleThemePolicyCreate(event)).resolves.not.toThrow(); 1368 + 1369 + const policyVals = getPolicyInsertValues(); 1370 + expect(policyVals).toBeDefined(); 1371 + expect(policyVals.defaultLightThemeUri).toBe( 1372 + "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" 1373 + ); 1374 + expect(policyVals.defaultDarkThemeUri).toBe( 1375 + "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" 1376 + ); 1377 + 1378 + const availableVals = getAvailableThemesInsertValues(); 1379 + expect(availableVals).toBeDefined(); 1380 + expect(availableVals[0].themeUri).toBe( 1381 + "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" 1382 + ); 1383 + // Live refs: no CID in record → themeCid must be null in DB row 1384 + expect(availableVals[0].themeCid).toBeNull(); 1385 + }); 1386 + 1387 + it("indexes themePolicy with pinned themeRefs (CID present)", async () => { 1388 + const { db: trackingDb, getAvailableThemesInsertValues } = 1389 + createThemePolicyTrackingDb(); 1390 + const themePolicyIndexer = new Indexer(trackingDb, mockLogger); 1391 + 1392 + const event: CommitCreateEvent<"space.atbb.forum.themePolicy"> = { 1393 + did: "did:plc:forum", 1394 + time_us: 1234567890, 1395 + kind: "commit", 1396 + commit: { 1397 + rev: "abc", 1398 + operation: "create", 1399 + collection: "space.atbb.forum.themePolicy", 1400 + rkey: "self", 1401 + cid: "cidPolicy2", 1402 + record: { 1403 + $type: "space.atbb.forum.themePolicy", 1404 + defaultLightTheme: { uri: "at://did:plc:forum/space.atbb.forum.theme/light1", cid: "bafylight" }, 1405 + defaultDarkTheme: { uri: "at://did:plc:forum/space.atbb.forum.theme/dark1", cid: "bafydark" }, 1406 + availableThemes: [ 1407 + { uri: "at://did:plc:forum/space.atbb.forum.theme/light1", cid: "bafylight" }, 1408 + { uri: "at://did:plc:forum/space.atbb.forum.theme/dark1", cid: "bafydark" }, 1409 + ], 1410 + allowUserChoice: false, 1411 + updatedAt: "2026-01-01T00:00:00Z", 1412 + } as any, 1413 + }, 1414 + }; 1415 + 1416 + await themePolicyIndexer.handleThemePolicyCreate(event); 1417 + 1418 + const availableVals = getAvailableThemesInsertValues(); 1419 + expect(availableVals[0].themeCid).toBe("bafylight"); 1420 + expect(availableVals[1].themeCid).toBe("bafydark"); 1421 + }); 1422 + }); 1423 + 1290 1424 describe("Ban enforcement — handleModActionCreate", () => { 1291 1425 it("calls applyBan when a ban mod action is created", async () => { 1292 1426 const mockBanEnforcer = (indexer as any).banEnforcer;
+6 -6
apps/appview/src/lib/indexer.ts
··· 394 394 did: event.did, 395 395 rkey: event.commit.rkey, 396 396 cid: event.commit.cid, 397 - defaultLightThemeUri: record.defaultLightTheme.theme.uri, 398 - defaultDarkThemeUri: record.defaultDarkTheme.theme.uri, 397 + defaultLightThemeUri: record.defaultLightTheme.uri, 398 + defaultDarkThemeUri: record.defaultDarkTheme.uri, 399 399 allowUserChoice: record.allowUserChoice, 400 400 indexedAt: new Date(), 401 401 }), 402 402 toUpdateValues: async (event, record) => ({ 403 403 cid: event.commit.cid, 404 - defaultLightThemeUri: record.defaultLightTheme.theme.uri, 405 - defaultDarkThemeUri: record.defaultDarkTheme.theme.uri, 404 + defaultLightThemeUri: record.defaultLightTheme.uri, 405 + defaultDarkThemeUri: record.defaultDarkTheme.uri, 406 406 allowUserChoice: record.allowUserChoice, 407 407 indexedAt: new Date(), 408 408 }), ··· 417 417 await tx.insert(themePolicyAvailableThemes).values( 418 418 available.map((themeRef) => ({ 419 419 policyId, 420 - themeUri: themeRef.theme.uri, 421 - themeCid: themeRef.theme.cid, 420 + themeUri: themeRef.uri, 421 + themeCid: themeRef.cid ?? null, 422 422 })) 423 423 ); 424 424 }
+39 -76
apps/appview/src/routes/__tests__/admin.test.ts
··· 3198 3198 expect(mockPutRecord).toHaveBeenCalledOnce(); 3199 3199 }); 3200 3200 3201 - it("writes PDS record with themeRef wrapper structure", async () => { 3201 + it("writes PDS record with flat themeRef structure (no theme: wrapper)", async () => { 3202 3202 await app.request("/api/admin/theme-policy", { 3203 3203 method: "PUT", 3204 3204 headers: { "Content-Type": "application/json" }, ··· 3207 3207 const call = mockPutRecord.mock.calls[0][0]; 3208 3208 expect(call.record.$type).toBe("space.atbb.forum.themePolicy"); 3209 3209 expect(call.rkey).toBe("self"); 3210 - // availableThemes wrapped in { theme: { uri, cid } } 3211 - expect(call.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3212 - expect(call.record.defaultLightTheme).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3213 - expect(call.record.defaultDarkTheme).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 3210 + // Flat themeRef: { uri, cid } — no nested theme: {} wrapper 3211 + expect(call.record.availableThemes[0]).toEqual({ uri: lightUri, cid: "bafylight" }); 3212 + expect(call.record.defaultLightTheme).toEqual({ uri: lightUri, cid: "bafylight" }); 3213 + expect(call.record.defaultDarkTheme).toEqual({ uri: darkUri, cid: "bafydark" }); 3214 3214 expect(call.record.allowUserChoice).toBe(true); 3215 3215 expect(typeof call.record.updatedAt).toBe("string"); 3216 3216 expect(call.collection).toBe("space.atbb.forum.themePolicy"); ··· 3274 3274 expect(body.error).toMatch(/availableThemes/i); 3275 3275 }); 3276 3276 3277 - it("accepts availableThemes with just uri (no cid) by looking up cid from DB", async () => { 3278 - // Insert a theme so the DB lookup will find it 3279 - const themeUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme1aa`; 3280 - await ctx.db.insert(themes).values({ 3281 - did: ctx.config.forumDid, 3282 - rkey: "3lbltheme1aa", 3283 - cid: "bafytheme1", 3284 - name: "Neobrutal Light", 3285 - colorScheme: "light", 3286 - tokens: {}, 3287 - createdAt: new Date(), 3288 - indexedAt: new Date(), 3289 - }); 3277 + it("accepts uri-only entries as live refs — writes themeRef without cid", async () => { 3278 + // URI without cid is a valid live ref (e.g. canonical atbb.space preset) 3279 + // No DB lookup or insertion required — CID is simply absent in the PDS record 3280 + const liveUri = `at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light`; 3290 3281 3291 3282 const res = await app.request("/api/admin/theme-policy", { 3292 3283 method: "PUT", 3293 3284 headers: { "Content-Type": "application/json" }, 3294 3285 body: JSON.stringify({ 3295 - defaultLightThemeUri: themeUri, 3296 - defaultDarkThemeUri: themeUri, 3286 + defaultLightThemeUri: liveUri, 3287 + defaultDarkThemeUri: liveUri, 3297 3288 allowUserChoice: true, 3298 - availableThemes: [{ uri: themeUri }], // no cid 3289 + availableThemes: [{ uri: liveUri }], // no cid — live ref 3299 3290 }), 3300 3291 }); 3301 3292 3302 3293 expect(res.status).toBe(200); 3303 3294 expect(mockPutRecord).toHaveBeenCalledOnce(); 3304 3295 const putCall = mockPutRecord.mock.calls[0][0]; 3305 - // The resolved entry should have the CID looked up from the DB 3306 - expect(putCall.record.availableThemes[0]).toEqual({ theme: { uri: themeUri, cid: "bafytheme1" } }); 3307 - expect(putCall.record.defaultLightTheme).toEqual({ theme: { uri: themeUri, cid: "bafytheme1" } }); 3308 - expect(putCall.record.defaultDarkTheme).toEqual({ theme: { uri: themeUri, cid: "bafytheme1" } }); 3296 + // Live ref has uri but no cid field 3297 + expect(putCall.record.availableThemes[0]).toEqual({ uri: liveUri }); 3298 + expect(putCall.record.defaultLightTheme).toEqual({ uri: liveUri }); 3299 + expect(putCall.record.defaultDarkTheme).toEqual({ uri: liveUri }); 3309 3300 }); 3310 3301 3311 - it("returns 400 when availableThemes contains a uri-only entry not found in DB", async () => { 3312 - // No theme inserted in DB — URI won't be found 3313 - const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown1`; 3302 + it("accepts live refs to external URIs not in local DB (e.g. atbb.space canonical presets)", async () => { 3303 + // Previously the route rejected URIs not found in the local DB. 3304 + // Now URI-only entries are valid live refs regardless of whether the theme is local. 3305 + const externalUri = `at://did:web:atbb.space/space.atbb.forum.theme/clean-light`; 3314 3306 3315 3307 const res = await app.request("/api/admin/theme-policy", { 3316 3308 method: "PUT", 3317 3309 headers: { "Content-Type": "application/json" }, 3318 3310 body: JSON.stringify({ 3319 - defaultLightThemeUri: unknownUri, 3320 - defaultDarkThemeUri: unknownUri, 3311 + defaultLightThemeUri: externalUri, 3312 + defaultDarkThemeUri: externalUri, 3321 3313 allowUserChoice: true, 3322 - availableThemes: [{ uri: unknownUri }], // no cid, not in DB 3314 + availableThemes: [{ uri: externalUri }], 3323 3315 }), 3324 3316 }); 3325 3317 3326 - expect(res.status).toBe(400); 3327 - const body = await res.json(); 3328 - expect(body.error).toMatch(/unknown theme uri/i); 3329 - expect(mockPutRecord).not.toHaveBeenCalled(); 3318 + expect(res.status).toBe(200); 3319 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3330 3320 }); 3331 3321 3332 - it("returns 400 when availableThemes entry has cid: \"\" (empty string is not a valid CID)", async () => { 3333 - // Explicit cid: "" must be treated the same as absent cid — not a valid strongRef CID 3334 - const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown2`; 3322 + it("treats cid: \"\" as absent and writes a live ref (no cid in PDS record)", async () => { 3323 + const themeUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lbltheme2aa`; 3335 3324 3336 3325 const res = await app.request("/api/admin/theme-policy", { 3337 3326 method: "PUT", 3338 3327 headers: { "Content-Type": "application/json" }, 3339 3328 body: JSON.stringify({ 3340 - defaultLightThemeUri: unknownUri, 3341 - defaultDarkThemeUri: unknownUri, 3329 + defaultLightThemeUri: themeUri, 3330 + defaultDarkThemeUri: themeUri, 3342 3331 allowUserChoice: true, 3343 - availableThemes: [{ uri: unknownUri, cid: "" }], // empty string cid, not in DB 3332 + availableThemes: [{ uri: themeUri, cid: "" }], // empty string → treated as absent 3344 3333 }), 3345 3334 }); 3346 3335 3347 - expect(res.status).toBe(400); 3348 - const body = await res.json(); 3349 - expect(body.error).toMatch(/unknown theme uri/i); 3350 - expect(mockPutRecord).not.toHaveBeenCalled(); 3336 + expect(res.status).toBe(200); 3337 + expect(mockPutRecord).toHaveBeenCalledOnce(); 3338 + const putCall = mockPutRecord.mock.calls[0][0]; 3339 + // Empty string cid is dropped — written as live ref 3340 + expect(putCall.record.availableThemes[0]).toEqual({ uri: themeUri }); 3351 3341 }); 3352 3342 3353 - it("returns 500 when DB query fails during uri-only CID lookup", async () => { 3354 - // Force needsLookup = true by omitting cid, then fail the DB select 3355 - const unknownUri = `at://${ctx.config.forumDid}/space.atbb.forum.theme/3lblunknown3`; 3356 - const dbSelectSpy = vi.spyOn(ctx.db, "select").mockImplementationOnce(() => { 3357 - throw new Error("Database connection lost"); 3358 - }); 3359 - 3360 - const res = await app.request("/api/admin/theme-policy", { 3361 - method: "PUT", 3362 - headers: { "Content-Type": "application/json" }, 3363 - body: JSON.stringify({ 3364 - defaultLightThemeUri: unknownUri, 3365 - defaultDarkThemeUri: unknownUri, 3366 - allowUserChoice: true, 3367 - availableThemes: [{ uri: unknownUri }], // no cid → triggers needsLookup 3368 - }), 3369 - }); 3370 - 3371 - expect(res.status).toBe(500); 3372 - const body = await res.json(); 3373 - expect(body.error).toMatch(/failed to look up theme data/i); 3374 - expect(mockPutRecord).not.toHaveBeenCalled(); 3375 - 3376 - dbSelectSpy.mockRestore(); 3377 - }); 3378 - 3379 - it("uses provided cid when entry already includes one (no DB lookup needed)", async () => { 3380 - // The existing validBody has cid on each entry — should use those directly 3343 + it("uses provided cid as-is when entry includes one (pinned ref)", async () => { 3381 3344 await app.request("/api/admin/theme-policy", { 3382 3345 method: "PUT", 3383 3346 headers: { "Content-Type": "application/json" }, 3384 3347 body: JSON.stringify(validBody), 3385 3348 }); 3386 3349 const putCall = mockPutRecord.mock.calls[0][0]; 3387 - // Should use the cids from the request, not from DB 3388 - expect(putCall.record.availableThemes[0]).toEqual({ theme: { uri: lightUri, cid: "bafylight" } }); 3389 - expect(putCall.record.availableThemes[1]).toEqual({ theme: { uri: darkUri, cid: "bafydark" } }); 3350 + // Pinned refs include both uri and cid 3351 + expect(putCall.record.availableThemes[0]).toEqual({ uri: lightUri, cid: "bafylight" }); 3352 + expect(putCall.record.availableThemes[1]).toEqual({ uri: darkUri, cid: "bafydark" }); 3390 3353 }); 3391 3354 3392 3355 it("returns 400 when defaultLightThemeUri is not in availableThemes", async () => {
+13 -47
apps/appview/src/routes/admin.ts
··· 1480 1480 1481 1481 const resolvedAllowUserChoice = typeof allowUserChoice === "boolean" ? allowUserChoice : true; 1482 1482 1483 - // Build URI→CID map from DB for entries that don't supply a valid cid. 1484 - // Treat cid: "" the same as absent — empty string is not a valid strongRef CID. 1485 - const isMissingCid = (t: { cid?: string }) => 1486 - typeof t.cid !== "string" || t.cid === ""; 1487 - let uriToCid = new Map<string, string>(); 1488 - const needsLookup = typedAvailableThemes.some(isMissingCid); 1489 - if (needsLookup) { 1490 - try { 1491 - const allThemes = await ctx.db 1492 - .select({ did: themes.did, rkey: themes.rkey, cid: themes.cid }) 1493 - .from(themes) 1494 - .where(eq(themes.did, ctx.config.forumDid)); 1495 - uriToCid = new Map( 1496 - allThemes.map((t) => [ 1497 - `at://${t.did}/space.atbb.forum.theme/${t.rkey}`, 1498 - t.cid, 1499 - ]) 1500 - ); 1501 - } catch (error) { 1502 - if (isProgrammingError(error)) throw error; 1503 - ctx.logger.error("Failed to look up theme CIDs from DB", { 1504 - operation: "PUT /api/admin/theme-policy", 1505 - error: error instanceof Error ? error.message : String(error), 1506 - }); 1507 - return c.json({ error: "Failed to look up theme data. Please try again later." }, 500); 1508 - } 1509 - } 1510 - 1511 - // Resolve CIDs: use provided cid if non-empty, otherwise look up from DB. 1512 - // Reject URIs that can't be resolved — "" is not a valid strongRef CID. 1513 - const unresolvedUris = typedAvailableThemes 1514 - .filter((t) => isMissingCid(t) && !uriToCid.has(t.uri)) 1515 - .map((t) => t.uri); 1516 - 1517 - if (unresolvedUris.length > 0) { 1518 - return c.json( 1519 - { error: `Unknown theme URIs in availableThemes: ${unresolvedUris.join(", ")}` }, 1520 - 400 1521 - ); 1522 - } 1523 - 1483 + // CID is optional — live refs (no cid) are valid for canonical atbb.space presets. 1484 + // Pass cid through when provided; omit it when absent or empty string. 1524 1485 const resolvedThemes = typedAvailableThemes.map((t) => ({ 1525 1486 uri: t.uri, 1526 - cid: !isMissingCid(t) ? t.cid! : uriToCid.get(t.uri)!, 1487 + cid: typeof t.cid === "string" && t.cid !== "" ? t.cid : undefined, 1527 1488 })); 1528 1489 1529 - const lightTheme = resolvedThemes.find((t) => t.uri === defaultLightThemeUri)!; 1530 - const darkTheme = resolvedThemes.find((t) => t.uri === defaultDarkThemeUri)!; 1490 + const lightTheme = resolvedThemes.find((t) => t.uri === defaultLightThemeUri); 1491 + const darkTheme = resolvedThemes.find((t) => t.uri === defaultDarkThemeUri); 1492 + if (!lightTheme || !darkTheme) { 1493 + // Both URIs were validated as present in availableThemes above — this is unreachable. 1494 + return c.json({ error: "Internal error: theme URIs not found in resolved themes" }, 500); 1495 + } 1531 1496 1532 1497 const { agent, error: agentError } = getForumAgentOrError(ctx, c, "PUT /api/admin/theme-policy"); 1533 1498 if (agentError) return agentError; ··· 1540 1505 record: { 1541 1506 $type: "space.atbb.forum.themePolicy", 1542 1507 availableThemes: resolvedThemes.map((t) => ({ 1543 - theme: { uri: t.uri, cid: t.cid }, 1508 + uri: t.uri, 1509 + ...(t.cid !== undefined ? { cid: t.cid } : {}), 1544 1510 })), 1545 - defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } }, 1546 - defaultDarkTheme: { theme: { uri: darkTheme.uri, cid: darkTheme.cid } }, 1511 + defaultLightTheme: { uri: lightTheme.uri, ...(lightTheme.cid !== undefined ? { cid: lightTheme.cid } : {}) }, 1512 + defaultDarkTheme: { uri: darkTheme.uri, ...(darkTheme.cid !== undefined ? { cid: darkTheme.cid } : {}) }, 1547 1513 allowUserChoice: resolvedAllowUserChoice, 1548 1514 updatedAt: new Date().toISOString(), 1549 1515 },
+27 -1
apps/web/src/lib/__tests__/theme-resolution.test.ts
··· 306 306 }); 307 307 308 308 it("returns FALLBACK_THEME when rkey contains path traversal characters", async () => { 309 - // rkey extracted from URI would be "..%2Fsecret" after split — fails /^[a-z0-9]+$/i 309 + // parseRkeyFromUri("at://did/col/../../secret") splits on "/" and returns parts[4] = ".." 310 + // ".." fails /^[a-z0-9-]+$/i, so we return FALLBACK_THEME without a theme fetch 310 311 mockFetch.mockResolvedValueOnce( 311 312 policyResponse({ 312 313 defaultLightThemeUri: "at://did/col/../../secret", ··· 316 317 expect(result.tokens).toEqual(FALLBACK_THEME.tokens); 317 318 // Only the policy fetch should have been made (no theme fetch) 318 319 expect(mockFetch).toHaveBeenCalledTimes(1); 320 + }); 321 + 322 + it("resolves theme from live ref (no CID in policy) without logging CID mismatch", async () => { 323 + // Live refs have no CID — canonical atbb.space presets ship this way. 324 + // The CID integrity check must be skipped when expectedCid is null. 325 + mockFetch 326 + .mockResolvedValueOnce( 327 + policyResponse({ 328 + availableThemes: [ 329 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbllight" }, // no cid 330 + { uri: "at://did:plc:forum/space.atbb.forum.theme/3lbldark" }, // no cid 331 + ], 332 + }) 333 + ) 334 + .mockResolvedValueOnce(themeResponse("light", "bafylight")); 335 + 336 + const result = await resolveTheme(APPVIEW, undefined, undefined); 337 + 338 + // Theme resolved successfully — live ref does not trigger CID mismatch 339 + expect(result.tokens["color-bg"]).toBe("#fff"); 340 + expect(result.colorScheme).toBe("light"); 341 + expect(mockLogger.warn).not.toHaveBeenCalledWith( 342 + expect.stringContaining("CID mismatch"), 343 + expect.any(Object) 344 + ); 319 345 }); 320 346 });
+6 -5
apps/web/src/lib/theme-resolution.ts
··· 54 54 defaultLightThemeUri: string | null; 55 55 defaultDarkThemeUri: string | null; 56 56 allowUserChoice: boolean; 57 - availableThemes: Array<{ uri: string; cid: string }>; 57 + availableThemes: Array<{ uri: string; cid?: string }>; 58 58 } 59 59 60 60 interface ThemeResponse { ··· 121 121 if (!defaultUri) return { ...FALLBACK_THEME, colorScheme }; 122 122 123 123 const rkey = parseRkeyFromUri(defaultUri); 124 - if (!rkey || !/^[a-z0-9]+$/i.test(rkey)) return { ...FALLBACK_THEME, colorScheme }; 124 + if (!rkey || !/^[a-z0-9-]+$/i.test(rkey)) return { ...FALLBACK_THEME, colorScheme }; 125 125 126 - const expectedCid = 127 - policy.availableThemes.find((t: { uri: string; cid: string }) => t.uri === defaultUri)?.cid ?? null; 128 - if (expectedCid === null) { 126 + const matchingTheme = policy.availableThemes.find((t) => t.uri === defaultUri); 127 + if (!matchingTheme) { 129 128 logger.warn("Theme URI not in availableThemes — skipping CID check", { 130 129 operation: "resolveTheme", 131 130 themeUri: defaultUri, 132 131 }); 133 132 } 133 + // cid may be absent for live refs (e.g. canonical atbb.space presets) — that is expected 134 + const expectedCid = matchingTheme?.cid ?? null; 134 135 135 136 // ── Step 4: Fetch theme ──────────────────────────────────────────────────── 136 137 let themeRes: Response;
+8 -2
apps/web/src/routes/admin-themes.tsx
··· 11 11 import { FALLBACK_THEME, type WebAppEnv } from "../lib/theme-resolution.js"; 12 12 import neobrutalLight from "../styles/presets/neobrutal-light.json"; 13 13 import neobrutalDark from "../styles/presets/neobrutal-dark.json"; 14 + import cleanLight from "../styles/presets/clean-light.json"; 15 + import cleanDark from "../styles/presets/clean-dark.json"; 16 + import classicBb from "../styles/presets/classic-bb.json"; 14 17 15 18 // ─── Types ───────────────────────────────────────────────────────────────── 16 19 ··· 30 33 defaultLightThemeUri: string | null; 31 34 defaultDarkThemeUri: string | null; 32 35 allowUserChoice: boolean; 33 - availableThemes: Array<{ uri: string; cid: string }>; 36 + availableThemes: Array<{ uri: string; cid?: string }>; 34 37 } 35 38 36 39 // Preset token maps — used by POST /admin/themes to seed tokens on creation 37 40 const THEME_PRESETS: Record<string, Record<string, string>> = { 38 41 "neobrutal-light": neobrutalLight as Record<string, string>, 39 - "neobrutal-dark": neobrutalDark as Record<string, string>, 42 + "neobrutal-dark": neobrutalDark as Record<string, string>, 43 + "clean-light": cleanLight as Record<string, string>, 44 + "clean-dark": cleanDark as Record<string, string>, 45 + "classic-bb": classicBb as Record<string, string>, 40 46 "blank": {}, 41 47 }; 42 48
+73
apps/web/src/styles/presets/__tests__/presets.test.ts
··· 1 1 import { describe, it, expect } from "vitest"; 2 2 import neobrutalLight from "../neobrutal-light.json"; 3 3 import neobrutalDark from "../neobrutal-dark.json"; 4 + import cleanLight from "../clean-light.json"; 5 + import cleanDark from "../clean-dark.json"; 6 + import classicBb from "../classic-bb.json"; 4 7 import { tokensToCss } from "../../../lib/theme.js"; 5 8 6 9 const REQUIRED_TOKENS = [ ··· 58 61 expect(css).toContain("--font-size-xs:"); 59 62 }); 60 63 }); 64 + 65 + describe("clean-light preset", () => { 66 + it("contains all required tokens", () => { 67 + for (const token of REQUIRED_TOKENS) { 68 + expect(cleanLight, `missing token: ${token}`).toHaveProperty(token); 69 + } 70 + }); 71 + 72 + it("has rounded corners (radius > 0)", () => { 73 + const radius = (cleanLight as Record<string, string>)["radius"]; 74 + expect(radius).not.toBe("0px"); 75 + }); 76 + 77 + it("has a different background color than neobrutal-light", () => { 78 + expect((cleanLight as Record<string, string>)["color-bg"]).not.toBe( 79 + (neobrutalLight as Record<string, string>)["color-bg"] 80 + ); 81 + }); 82 + 83 + it("produces valid CSS via tokensToCss", () => { 84 + const css = tokensToCss(cleanLight as Record<string, string>); 85 + expect(css).toContain("--color-bg:"); 86 + expect(css).toContain("--radius:"); 87 + }); 88 + }); 89 + 90 + describe("clean-dark preset", () => { 91 + it("contains all required tokens", () => { 92 + for (const token of REQUIRED_TOKENS) { 93 + expect(cleanDark, `missing token: ${token}`).toHaveProperty(token); 94 + } 95 + }); 96 + 97 + it("has a darker background than clean-light", () => { 98 + expect((cleanDark as Record<string, string>)["color-bg"]).not.toBe( 99 + (cleanLight as Record<string, string>)["color-bg"] 100 + ); 101 + }); 102 + 103 + it("produces valid CSS via tokensToCss", () => { 104 + const css = tokensToCss(cleanDark as Record<string, string>); 105 + expect(css).toContain("--color-bg:"); 106 + expect(css).toContain("--card-shadow:"); 107 + }); 108 + }); 109 + 110 + describe("classic-bb preset", () => { 111 + it("contains all required tokens", () => { 112 + for (const token of REQUIRED_TOKENS) { 113 + expect(classicBb, `missing token: ${token}`).toHaveProperty(token); 114 + } 115 + }); 116 + 117 + it("uses a smaller base font size than modern presets", () => { 118 + const classicBase = parseInt((classicBb as Record<string, string>)["font-size-base"]); 119 + const neobrutalBase = parseInt((neobrutalLight as Record<string, string>)["font-size-base"]); 120 + expect(classicBase).toBeLessThan(neobrutalBase); 121 + }); 122 + 123 + it("uses a serif-era font stack (Verdana)", () => { 124 + const fontBody = (classicBb as Record<string, string>)["font-body"]; 125 + expect(fontBody).toContain("Verdana"); 126 + }); 127 + 128 + it("produces valid CSS via tokensToCss", () => { 129 + const css = tokensToCss(classicBb as Record<string, string>); 130 + expect(css).toContain("--color-bg:"); 131 + expect(css).toContain("--font-size-base:"); 132 + }); 133 + });
+47
apps/web/src/styles/presets/classic-bb.json
··· 1 + { 2 + "color-bg": "#efeff0", 3 + "color-surface": "#ffffff", 4 + "color-text": "#333333", 5 + "color-text-muted": "#666666", 6 + "color-primary": "#336699", 7 + "color-primary-hover": "#224477", 8 + "color-secondary": "#5566aa", 9 + "color-border": "#bbbbbb", 10 + "color-shadow": "#999999", 11 + "color-success": "#336600", 12 + "color-warning": "#cc6600", 13 + "color-danger": "#cc0000", 14 + "color-code-bg": "#f4f4f4", 15 + "color-code-text": "#333333", 16 + "font-body": "Verdana, Arial, Helvetica, sans-serif", 17 + "font-heading": "Verdana, Arial, Helvetica, sans-serif", 18 + "font-mono": "'Courier New', Courier, monospace", 19 + "font-size-base": "13px", 20 + "font-size-sm": "11px", 21 + "font-size-xs": "10px", 22 + "font-size-lg": "15px", 23 + "font-size-xl": "18px", 24 + "font-size-2xl": "24px", 25 + "font-weight-normal": "400", 26 + "font-weight-bold": "700", 27 + "line-height-body": "1.5", 28 + "line-height-heading": "1.2", 29 + "space-xs": "3px", 30 + "space-sm": "6px", 31 + "space-md": "12px", 32 + "space-lg": "18px", 33 + "space-xl": "30px", 34 + "radius": "3px", 35 + "border-width": "1px", 36 + "shadow-offset": "1px", 37 + "content-width": "100%", 38 + "button-radius": "3px", 39 + "button-shadow": "1px 1px 1px var(--color-shadow)", 40 + "card-radius": "3px", 41 + "card-shadow": "1px 1px 2px var(--color-shadow)", 42 + "btn-press-hover": "0px", 43 + "btn-press-active": "1px", 44 + "input-radius": "2px", 45 + "input-border": "1px solid #bbbbbb", 46 + "nav-height": "50px" 47 + }
+47
apps/web/src/styles/presets/clean-dark.json
··· 1 + { 2 + "color-bg": "#0f172a", 3 + "color-surface": "#1e293b", 4 + "color-text": "#f1f5f9", 5 + "color-text-muted": "#94a3b8", 6 + "color-primary": "#60a5fa", 7 + "color-primary-hover": "#3b82f6", 8 + "color-secondary": "#a78bfa", 9 + "color-border": "#334155", 10 + "color-shadow": "rgba(0,0,0,0.4)", 11 + "color-success": "#4ade80", 12 + "color-warning": "#fbbf24", 13 + "color-danger": "#f87171", 14 + "color-code-bg": "#0d1117", 15 + "color-code-text": "#e2e8f0", 16 + "font-body": "system-ui, -apple-system, sans-serif", 17 + "font-heading": "system-ui, -apple-system, sans-serif", 18 + "font-mono": "ui-monospace, 'Cascadia Code', monospace", 19 + "font-size-base": "16px", 20 + "font-size-sm": "14px", 21 + "font-size-xs": "12px", 22 + "font-size-lg": "20px", 23 + "font-size-xl": "24px", 24 + "font-size-2xl": "32px", 25 + "font-weight-normal": "400", 26 + "font-weight-bold": "600", 27 + "line-height-body": "1.6", 28 + "line-height-heading": "1.25", 29 + "space-xs": "4px", 30 + "space-sm": "8px", 31 + "space-md": "16px", 32 + "space-lg": "24px", 33 + "space-xl": "40px", 34 + "radius": "8px", 35 + "border-width": "1px", 36 + "shadow-offset": "0px", 37 + "content-width": "100%", 38 + "button-radius": "6px", 39 + "button-shadow": "0 1px 2px rgba(0,0,0,0.2)", 40 + "card-radius": "8px", 41 + "card-shadow": "0 1px 3px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.2)", 42 + "btn-press-hover": "0px", 43 + "btn-press-active": "0px", 44 + "input-radius": "6px", 45 + "input-border": "1px solid #334155", 46 + "nav-height": "64px" 47 + }
+47
apps/web/src/styles/presets/clean-light.json
··· 1 + { 2 + "color-bg": "#f9fafb", 3 + "color-surface": "#ffffff", 4 + "color-text": "#111827", 5 + "color-text-muted": "#6b7280", 6 + "color-primary": "#2563eb", 7 + "color-primary-hover": "#1d4ed8", 8 + "color-secondary": "#7c3aed", 9 + "color-border": "#e5e7eb", 10 + "color-shadow": "rgba(0,0,0,0.08)", 11 + "color-success": "#16a34a", 12 + "color-warning": "#d97706", 13 + "color-danger": "#dc2626", 14 + "color-code-bg": "#f3f4f6", 15 + "color-code-text": "#1f2937", 16 + "font-body": "system-ui, -apple-system, sans-serif", 17 + "font-heading": "system-ui, -apple-system, sans-serif", 18 + "font-mono": "ui-monospace, 'Cascadia Code', monospace", 19 + "font-size-base": "16px", 20 + "font-size-sm": "14px", 21 + "font-size-xs": "12px", 22 + "font-size-lg": "20px", 23 + "font-size-xl": "24px", 24 + "font-size-2xl": "32px", 25 + "font-weight-normal": "400", 26 + "font-weight-bold": "600", 27 + "line-height-body": "1.6", 28 + "line-height-heading": "1.25", 29 + "space-xs": "4px", 30 + "space-sm": "8px", 31 + "space-md": "16px", 32 + "space-lg": "24px", 33 + "space-xl": "40px", 34 + "radius": "8px", 35 + "border-width": "1px", 36 + "shadow-offset": "0px", 37 + "content-width": "100%", 38 + "button-radius": "6px", 39 + "button-shadow": "0 1px 2px rgba(0,0,0,0.05)", 40 + "card-radius": "8px", 41 + "card-shadow": "0 1px 3px rgba(0,0,0,0.08), 0 1px 2px rgba(0,0,0,0.04)", 42 + "btn-press-hover": "0px", 43 + "btn-press-active": "0px", 44 + "input-radius": "6px", 45 + "input-border": "1px solid #e5e7eb", 46 + "nav-height": "64px" 47 + }
+2 -1
bruno/AppView API/Admin Themes/Update Theme Policy.bru
··· 34 34 35 35 Body: 36 36 - availableThemes (required): Non-empty array of { uri, cid? } theme references. 37 - cid is optional — if omitted, the AppView looks it up from the themes table by URI. 37 + cid is optional — if omitted, this is a live ref that always resolves to the current 38 + version of the theme record. Canonical atbb.space presets use live refs by default. 38 39 Both defaultLightThemeUri and defaultDarkThemeUri must be present in this list. 39 40 - defaultLightThemeUri (required): AT-URI of the default light-mode theme. 40 41 Must be in availableThemes.
+294
docs/plans/2026-03-07-atb61-preset-themes-implementation.md
··· 1 + # ATB-61: Built-in Preset Themes — Implementation Plan 2 + 3 + **Branch:** `root/atb-61-ship-built-in-theme-presets` 4 + **Design doc:** `docs/theming-plan.md` — Built-in Preset Themes section 5 + **Linear:** ATB-61 6 + 7 + --- 8 + 9 + ## Overview 10 + 11 + Built-in presets are published as canonical records on `atbb.space`'s PDS. Fresh installs reference them via live (URI-only) `themeRef`s. This requires: 12 + 13 + 1. A lexicon change (`themeRef.cid` → optional) with a cascading TypeScript fix across 4 files 14 + 2. A DB migration (`themeCid` → nullable) 15 + 3. Three new preset JSON files + tests 16 + 4. A release pipeline script to publish presets to `atbb.space` 17 + 5. A `--local-presets` bootstrap escape hatch 18 + 19 + --- 20 + 21 + ## Phase 1: Lexicon + DB migration + TypeScript cascade 22 + 23 + ### 1.1 — Update `themePolicy.yaml` 24 + 25 + **File:** `packages/lexicon/lexicons/space/atbb/forum/themePolicy.yaml` 26 + 27 + Replace the `themeRef` def. Remove the `com.atproto.repo.strongRef` wrapper; make `cid` optional: 28 + 29 + ```yaml 30 + themeRef: 31 + type: object 32 + description: >- 33 + A reference to a theme record. When 'cid' is present the reference is 34 + pinned — the appview verifies CID on fetch and falls through on mismatch. 35 + When 'cid' is absent the reference is live — always resolves the current 36 + record at the URI (used by default for canonical atbb.space presets). 37 + required: 38 + - uri 39 + properties: 40 + uri: 41 + type: string 42 + format: at-uri 43 + description: AT-URI of the space.atbb.forum.theme record. 44 + cid: 45 + type: string 46 + format: cid 47 + description: >- 48 + Optional CID. When set, the appview pins to this exact record version. 49 + When absent, always resolves the current record at the URI. 50 + ``` 51 + 52 + Then rebuild: `pnpm --filter @atbb/lexicon build` 53 + 54 + ### 1.2 — DB schema: make `themeCid` nullable 55 + 56 + **Files:** 57 + - `packages/db/src/schema.ts` — `theme_policy_available_themes.themeCid`: `.notNull()` → nullable 58 + - `packages/db/src/schema.sqlite.ts` — same change 59 + 60 + Run to generate migration: `pnpm --filter @atbb/appview db:generate` 61 + Run to apply: `pnpm --filter @atbb/appview db:migrate` 62 + 63 + ### 1.3 — Fix `indexer.ts` 64 + 65 + **File:** `apps/appview/src/lib/indexer.ts` 66 + 67 + 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`. 68 + 69 + ```typescript 70 + // Before 71 + defaultLightThemeUri: record.defaultLightTheme.theme.uri, 72 + defaultDarkThemeUri: record.defaultDarkTheme.theme.uri, 73 + // ... 74 + themeUri: themeRef.theme.uri, 75 + themeCid: themeRef.theme.cid, 76 + 77 + // After 78 + defaultLightThemeUri: record.defaultLightTheme.uri, 79 + defaultDarkThemeUri: record.defaultDarkTheme.uri, 80 + // ... 81 + themeUri: themeRef.uri, 82 + themeCid: themeRef.cid ?? null, 83 + ``` 84 + 85 + ### 1.4 — Simplify `admin.ts` PUT /api/admin/theme-policy 86 + 87 + **File:** `apps/appview/src/routes/admin.ts` 88 + 89 + 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). 90 + 91 + **Remove the entire CID-fill block** (roughly lines 1483–1527): 92 + - `isMissingCid`, `uriToCid`, `needsLookup`, `unresolvedUris`, `uriToCid` DB lookup, rejection check 93 + 94 + **Replace `resolvedThemes` map** with a passthrough: 95 + ```typescript 96 + const resolvedThemes = typedAvailableThemes.map((t) => ({ 97 + uri: t.uri, 98 + cid: typeof t.cid === "string" && t.cid !== "" ? t.cid : undefined, 99 + })); 100 + ``` 101 + 102 + **Update PDS record write** to use flat `themeRef` (no `theme:` wrapper): 103 + ```typescript 104 + availableThemes: resolvedThemes.map((t) => ({ 105 + uri: t.uri, 106 + ...(t.cid !== undefined ? { cid: t.cid } : {}), 107 + })), 108 + defaultLightTheme: { uri: lightTheme.uri, ...(lightTheme.cid !== undefined ? { cid: lightTheme.cid } : {}) }, 109 + defaultDarkTheme: { uri: darkTheme.uri, ...(darkTheme.cid !== undefined ? { cid: darkTheme.cid } : {}) }, 110 + ``` 111 + 112 + ### 1.5 — Fix `theme-resolution.ts` 113 + 114 + **File:** `apps/web/src/lib/theme-resolution.ts` 115 + 116 + Update `ThemePolicyResponse.availableThemes` — `cid` is now optional: 117 + ```typescript 118 + availableThemes: Array<{ uri: string; cid?: string }>; 119 + ``` 120 + 121 + Update the CID lookup and check: 122 + ```typescript 123 + const expectedCid = 124 + policy.availableThemes.find((t) => t.uri === defaultUri)?.cid ?? null; 125 + // expectedCid === null means live ref — skip CID check (already does this, just update the type) 126 + ``` 127 + 128 + Also update `admin-themes.tsx` which has the same `ThemePolicy` type locally (line 33): 129 + ```typescript 130 + availableThemes: Array<{ uri: string; cid?: string }>; 131 + ``` 132 + 133 + ### 1.6 — Update `admin.test.ts` 134 + 135 + **File:** `apps/appview/src/routes/__tests__/admin.test.ts` 136 + 137 + Update the "writes PDS record with themeRef wrapper structure" test (line 3201): 138 + - Rename to "writes PDS record with flat themeRef structure" 139 + - Change assertions: `{ uri: lightUri, cid: "bafylight" }` (no `theme:` wrapper) 140 + - Add a test: "accepts themeRef without cid (live ref)" — submit a body with no `cid` fields and verify the PDS record has `themeRef`s without `cid` 141 + 142 + ### 1.7 — Verify Phase 1 143 + 144 + ```sh 145 + pnpm --filter @atbb/lexicon build 146 + pnpm --filter @atbb/appview test 147 + pnpm --filter @atbb/web test 148 + ``` 149 + 150 + --- 151 + 152 + ## Phase 2: Remaining 3 preset JSON files 153 + 154 + ### 2.1 — Design token values 155 + 156 + **Files to create:** 157 + - `apps/web/src/styles/presets/clean-light.json` 158 + - `apps/web/src/styles/presets/clean-dark.json` 159 + - `apps/web/src/styles/presets/classic-bb.json` 160 + 161 + Each must contain all tokens from `REQUIRED_TOKENS` in `presets.test.ts`. 162 + 163 + **Clean Light** — minimal, airy, soft: 164 + - Rounded corners (`radius: "6px"`) 165 + - Thinner borders (`border-width: "1px"`) 166 + - Soft shadows (`card-shadow: "0 2px 8px rgba(0,0,0,0.08)"`) 167 + - Neutral palette (white bg, near-black text, blue primary) 168 + 169 + **Clean Dark** — same proportions as Clean Light on dark surfaces: 170 + - Dark bg (`#1a1a2e` or similar), elevated surface (`#16213e`) 171 + - Muted borders, soft glow shadows 172 + 173 + **Classic BB** — phpBB/vBulletin nostalgia: 174 + - Blue header palette (`color-primary: "#336699"`) 175 + - Gray surfaces (`color-bg: "#efeff0"`, `color-surface: "#ffffff"`) 176 + - Smaller base font (`font-size-base: "13px"`) 177 + - Slight radius (`radius: "3px"`) 178 + - Thin borders (`border-width: "1px"`) 179 + 180 + ### 2.2 — Update preset tests 181 + 182 + **File:** `apps/web/src/styles/presets/__tests__/presets.test.ts` 183 + 184 + Add `describe` blocks for `clean-light`, `clean-dark`, and `classic-bb` matching the existing neobrutal test patterns. 185 + 186 + ### 2.3 — Update admin-themes.tsx THEME_PRESETS 187 + 188 + **File:** `apps/web/src/routes/admin-themes.tsx` 189 + 190 + Import new presets and add to `THEME_PRESETS` map: 191 + ```typescript 192 + import cleanLight from "../styles/presets/clean-light.json"; 193 + import cleanDark from "../styles/presets/clean-dark.json"; 194 + import classicBb from "../styles/presets/classic-bb.json"; 195 + 196 + const THEME_PRESETS: Record<string, Record<string, string>> = { 197 + "neobrutal-light": neobrutalLight as Record<string, string>, 198 + "neobrutal-dark": neobrutalDark as Record<string, string>, 199 + "clean-light": cleanLight as Record<string, string>, 200 + "clean-dark": cleanDark as Record<string, string>, 201 + "classic-bb": classicBb as Record<string, string>, 202 + "blank": {}, 203 + }; 204 + ``` 205 + 206 + ### 2.4 — Verify Phase 2 207 + 208 + ```sh 209 + pnpm --filter @atbb/web test 210 + ``` 211 + 212 + --- 213 + 214 + ## Phase 3: Deployment pipeline script 215 + 216 + ### 3.1 — Write `scripts/publish-presets.ts` 217 + 218 + **File:** `apps/appview/scripts/publish-presets.ts` 219 + 220 + Script that publishes all 5 preset JSON files to `atbb.space`'s PDS as `space.atbb.forum.theme` records under stable rkeys. 221 + 222 + Key behaviors: 223 + - Reads preset files from `../../web/src/styles/presets/` 224 + - Connects to atbb.space PDS using `FORUM_DID` + `FORUM_DID_KEY` env vars 225 + - Uses `ForumAgent` (or direct `AtpAgent`) to `putRecord` each preset 226 + - Rkeys: `neobrutal-light`, `neobrutal-dark`, `clean-light`, `clean-dark`, `classic-bb` 227 + - Idempotent: fetches existing record, compares token content, skips if unchanged 228 + - Dry-run mode: `--dry-run` flag prints what would change without writing 229 + 230 + Add to `package.json`: 231 + ```json 232 + "publish-presets": "tsx --env-file=../../.env scripts/publish-presets.ts" 233 + ``` 234 + 235 + ### 3.2 — Default themePolicy initialization 236 + 237 + 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. 238 + 239 + The default policy: 240 + ```typescript 241 + { 242 + $type: "space.atbb.forum.themePolicy", 243 + availableThemes: [ 244 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 245 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 246 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/clean-light" }, 247 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/clean-dark" }, 248 + { uri: "at://did:web:atbb.space/space.atbb.forum.theme/classic-bb" }, 249 + ], 250 + defaultLightTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-light" }, 251 + defaultDarkTheme: { uri: "at://did:web:atbb.space/space.atbb.forum.theme/neobrutal-dark" }, 252 + allowUserChoice: true, 253 + updatedAt: new Date().toISOString(), 254 + } 255 + ``` 256 + 257 + --- 258 + 259 + ## Phase 4: Local escape hatch 260 + 261 + ### 4.1 — Write `scripts/bootstrap-local-presets.ts` 262 + 263 + **File:** `apps/appview/scripts/bootstrap-local-presets.ts` 264 + 265 + Command that mirrors canonical presets onto the forum's own PDS and rewrites the themePolicy to point locally. 266 + 267 + Steps: 268 + 1. Read preset JSON files 269 + 2. `putRecord` each to `FORUM_DID`'s PDS under same stable rkeys 270 + 3. `putRecord` the `themePolicy` singleton pointing at `at://FORUM_DID/space.atbb.forum.theme/*` 271 + 272 + Add to `package.json`: 273 + ```json 274 + "bootstrap-local-presets": "tsx --env-file=../../.env scripts/bootstrap-local-presets.ts" 275 + ``` 276 + 277 + Document in `CONTRIBUTING.md` and the deployment guide. 278 + 279 + --- 280 + 281 + ## Verification Checklist (all phases) 282 + 283 + ```sh 284 + pnpm --filter @atbb/lexicon build # Lexicon + TS types regenerated 285 + pnpm --filter @atbb/appview test # All appview tests pass 286 + pnpm --filter @atbb/web test # All web tests pass (including 3 new presets) 287 + pnpm test # Full suite via turbo 288 + ``` 289 + 290 + Manual: 291 + - [ ] Admin theme policy UI: submit with no `cid` fields — verify PDS record has URI-only themeRefs 292 + - [ ] Resolution waterfall: live ref fetches current theme; CID mismatch falls through to fallback 293 + - [ ] "Start from preset" dropdown populates all 5 presets in create form 294 + - [ ] `publish-presets --dry-run` correctly diffs changed vs unchanged presets
+72 -23
docs/theming-plan.md
··· 200 200 201 201 ### New: `space.atbb.forum.themePolicy` 202 202 203 - A new singleton record on the Forum DID for theme configuration, separate from the main forum record to allow independent updates without invalidating `strongRef`s to the forum record. 203 + 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. 204 204 205 205 ```yaml 206 206 lexiconId: space.atbb.forum.themePolicy ··· 210 210 defs: 211 211 themeRef: 212 212 type: object 213 - required: [theme] 213 + required: [uri] 214 214 properties: 215 - theme: 216 - type: ref 217 - ref: com.atproto.repo.strongRef # CID integrity check for theme records 215 + uri: 216 + type: string 217 + format: at-uri # AT-URI of the space.atbb.forum.theme record 218 + cid: 219 + type: string 220 + format: cid # Optional. When present: pins to exact version (strong). 221 + # When absent: resolves live current record (weak). 218 222 219 223 fields: 220 224 availableThemes: # Themes admins have enabled for users ··· 235 239 ``` 236 240 237 241 **Record ownership:** Forum DID. 242 + 243 + 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. 244 + 245 + | Reference type | `themeRef` shape | Behavior | 246 + |---|---|---| 247 + | Live | `{ uri: "at://atbb.space/..." }` | Auto-updates when atbb.space publishes new preset versions | 248 + | Pinned | `{ uri: "at://atbb.space/...", cid: "bafyrei..." }` | Locked to exact version; admin sees warning when a newer version exists | 249 + | Local copy | `{ uri: "at://forum-did/..." }` | Full autonomy, no external dependency | 238 250 239 251 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`. 240 252 ··· 320 332 321 333 ## Built-in Preset Themes 322 334 323 - Ship with a small set of presets that admins can use as starting points. Each preset ships with both a light and dark variant so forums have sensible defaults for both modes out of the box. 335 + 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) `themeRef`s — no local seeding step required. 324 336 325 - | Preset | Color Scheme | Description | 326 - |--------|-------------|-------------| 327 - | **Neobrutal Light** (default light) | light | Bold borders, solid shadows, warm off-white palette, sharp corners | 328 - | **Neobrutal Dark** (default dark) | dark | Same bold/chunky aesthetic on a dark background, muted shadows | 329 - | **Clean Light** | light | Minimal, airy, subtle shadows, rounded corners, neutral palette | 330 - | **Clean Dark** | dark | Soft dark surfaces, gentle borders, same airy spacing | 331 - | **Classic BB** | light | Nostalgic phpBB/vBulletin feel — blue headers, gray panels, small type | 337 + | Preset | Color Scheme | Canonical rkey | 338 + |--------|-------------|----------------| 339 + | **Neobrutal Light** (default light) | light | `neobrutal-light` | 340 + | **Neobrutal Dark** (default dark) | dark | `neobrutal-dark` | 341 + | **Clean Light** | light | `clean-light` | 342 + | **Clean Dark** | dark | `clean-dark` | 343 + | **Classic BB** | light | `classic-bb` | 344 + 345 + Canonical URIs follow the pattern: `at://did:web:atbb.space/space.atbb.forum.theme/<rkey>` 332 346 333 347 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. 334 348 349 + The bundled JSON files in `apps/web/src/styles/presets/` serve two purposes: 350 + 1. **Hardcoded fallback** — used directly if no theme policy can be resolved (no network, no PDS write yet). 351 + 2. **Deployment pipeline source** — the release script reads these files and writes them to `atbb.space`'s PDS. 352 + 353 + ### Deployment Pipeline 354 + 355 + When atBB releases a new version with updated preset tokens, a release script: 356 + 357 + 1. Reads the bundled preset JSON files (canonical source of truth in the app repo) 358 + 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) 359 + 3. Skips records where token values are unchanged (idempotent) 360 + 361 + 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. 362 + 363 + ### Local Escape Hatch 364 + 365 + Operators who want zero external dependencies run: 366 + 367 + ```sh 368 + atbb bootstrap --local-presets 369 + ``` 370 + 371 + This command: 372 + 373 + 1. Reads the bundled preset JSON files 374 + 2. Writes them to the forum's own PDS under the same stable rkeys 375 + 3. Updates `themePolicy` to point at the local URIs instead of `atbb.space` 376 + 377 + After this, the forum is fully self-contained. The admin can still customize individual presets via the theme editor. 378 + 335 379 --- 336 380 337 381 ## CSS Architecture ··· 344 388 reset.css # Minimal normalize/reset 345 389 theme.css # All component styles using var(--token) references 346 390 presets/ 347 - neobrutal-light.json # Token values for neobrutal light preset 348 - neobrutal-dark.json # Token values for neobrutal dark preset 349 - clean-light.json # Token values for clean light preset 350 - clean-dark.json # Token values for clean dark preset 351 - classic.json # Token values for classic BB preset 391 + neobrutal-light.json # Hardcoded fallback + deployment pipeline source 392 + neobrutal-dark.json # Hardcoded fallback + deployment pipeline source 393 + clean-light.json # Hardcoded fallback + deployment pipeline source 394 + clean-dark.json # Hardcoded fallback + deployment pipeline source 395 + classic-bb.json # Hardcoded fallback + deployment pipeline source 352 396 ``` 397 + 398 + 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](#built-in-preset-themes) for the full model. 353 399 354 400 ### Base Stylesheet Approach 355 401 ··· 447 493 AND has a preferredTheme set on their membership record? 448 494 AND does the forum's themePolicy.allowUserChoice == true? 449 495 AND is preferredTheme.uri still in themePolicy.availableThemes? 450 - AND does preferredTheme.cid match current theme record (integrity check)? 496 + AND (if preferredTheme.cid is set) does cid match current theme record? 451 497 → Use their preferred theme. 452 498 453 499 2. Color scheme default ··· 456 502 b. HTTP header: Sec-CH-Prefers-Color-Scheme (client hint) 457 503 c. Default: light 458 504 459 - → Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly 460 - (with CID integrity check via strongRef). 505 + → Use themePolicy.defaultDarkTheme or defaultLightTheme accordingly. 506 + If themeRef.cid is set, verify CID on fetch; mismatch → fall through. 507 + If themeRef.cid is absent, fetch live current record at the URI. 461 508 462 509 3. Hardcoded fallback 463 - If no theme policy exists or the resolved theme can't be loaded: 464 - → Use the built-in neobrutal token values (no PDS needed, works offline). 510 + If no theme policy exists, the resolved theme can't be loaded, 511 + or the atbb.space PDS is unreachable: 512 + → Use the built-in neobrutal token values from the bundled JSON files 513 + (no PDS or network needed, always works offline). 465 514 ``` 466 515 467 516 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.
+309
packages/cli/src/commands/theme.ts
··· 1 + import { defineCommand } from "citty"; 2 + import consola from "consola"; 3 + import { readFileSync } from "fs"; 4 + import { fileURLToPath } from "url"; 5 + import { dirname, join } from "path"; 6 + import { ForumAgent } from "@atbb/atproto"; 7 + import { loadCliConfig } from "../lib/config.js"; 8 + import { logger } from "../lib/logger.js"; 9 + 10 + const __dirname = dirname(fileURLToPath(import.meta.url)); 11 + const PRESET_DIR = join(__dirname, "../../../../apps/web/src/styles/presets"); 12 + 13 + const PRESETS = [ 14 + { rkey: "neobrutal-light", name: "Neobrutal Light", colorScheme: "light" as const }, 15 + { rkey: "neobrutal-dark", name: "Neobrutal Dark", colorScheme: "dark" as const }, 16 + { rkey: "clean-light", name: "Clean Light", colorScheme: "light" as const }, 17 + { rkey: "clean-dark", name: "Clean Dark", colorScheme: "dark" as const }, 18 + { rkey: "classic-bb", name: "Classic BB", colorScheme: "light" as const }, 19 + ] as const; 20 + 21 + /** Stable JSON string for comparison — sorted token keys, ignores ordering differences. */ 22 + function stableTokensJson(tokens: Record<string, string>): string { 23 + return JSON.stringify(Object.fromEntries(Object.entries(tokens).sort())); 24 + } 25 + 26 + /** True if the existing PDS record has the same content as the local preset. */ 27 + function isRecordCurrent( 28 + existing: Record<string, unknown>, 29 + preset: { name: string; colorScheme: string }, 30 + tokens: Record<string, string> 31 + ): boolean { 32 + return ( 33 + existing.name === preset.name && 34 + existing.colorScheme === preset.colorScheme && 35 + stableTokensJson(existing.tokens as Record<string, string>) === stableTokensJson(tokens) 36 + ); 37 + } 38 + 39 + /** 40 + * Authenticate using ForumAgent and return the raw agent + DID. 41 + * Theme commands only need PDS_URL, FORUM_HANDLE, FORUM_PASSWORD — 42 + * DATABASE_URL and FORUM_DID are not required. 43 + */ 44 + async function authenticate(config: ReturnType<typeof loadCliConfig>) { 45 + const themeEnvMissing = config.missing.filter( 46 + (v) => v !== "DATABASE_URL" && v !== "FORUM_DID" 47 + ); 48 + if (themeEnvMissing.length > 0) { 49 + consola.error("Missing required environment variables:"); 50 + for (const name of themeEnvMissing) consola.error(` - ${name}`); 51 + process.exit(1); 52 + } 53 + 54 + consola.start("Authenticating..."); 55 + const forumAgent = new ForumAgent( 56 + config.pdsUrl, config.forumHandle, config.forumPassword, logger 57 + ); 58 + 59 + try { 60 + await forumAgent.initialize(); 61 + } catch (error) { 62 + consola.error( 63 + `Failed to reach PDS (${config.pdsUrl}):`, 64 + error instanceof Error ? error.message : String(error) 65 + ); 66 + try { await forumAgent.shutdown(); } catch {} 67 + process.exit(1); 68 + } 69 + 70 + if (!forumAgent.isAuthenticated()) { 71 + const status = forumAgent.getStatus(); 72 + consola.error(`Authentication failed: ${status.error}`); 73 + await forumAgent.shutdown(); 74 + process.exit(1); 75 + } 76 + 77 + const agent = forumAgent.getAgent()!; 78 + const did = agent.session?.did; 79 + if (!did) { 80 + consola.error("Login succeeded but session has no DID"); 81 + await forumAgent.shutdown(); 82 + process.exit(1); 83 + } 84 + 85 + consola.success(`Authenticated as ${config.forumHandle} (${did})`); 86 + return { agent, did, forumAgent }; 87 + } 88 + 89 + // ── bootstrap-local ────────────────────────────────────────────────────────── 90 + 91 + const bootstrapLocalCommand = defineCommand({ 92 + meta: { 93 + name: "bootstrap-local", 94 + description: 95 + "Mirror built-in preset themes to your own PDS — zero external dependencies", 96 + }, 97 + args: { 98 + "dry-run": { 99 + type: "boolean", 100 + description: "Show what would be written without making any changes", 101 + default: false, 102 + }, 103 + }, 104 + async run({ args }) { 105 + const isDryRun = args["dry-run"]; 106 + consola.box("atBB — Bootstrap Local Presets" + (isDryRun ? " [dry-run]" : "")); 107 + 108 + const config = loadCliConfig(); 109 + const { agent, did, forumAgent } = await authenticate(config); 110 + 111 + consola.info(`Writing ${PRESETS.length} preset records to ${config.pdsUrl}`); 112 + if (isDryRun) consola.warn("Dry-run: no changes will be made."); 113 + consola.log(""); 114 + 115 + const now = new Date().toISOString(); 116 + const localUris: Array<{ rkey: string; uri: string }> = []; 117 + 118 + for (const preset of PRESETS) { 119 + const tokens = JSON.parse( 120 + readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 121 + ) as Record<string, string>; 122 + 123 + const uri = `at://${did}/space.atbb.forum.theme/${preset.rkey}`; 124 + localUris.push({ rkey: preset.rkey, uri }); 125 + 126 + if (isDryRun) { 127 + consola.info(` ~ ${preset.name} — would write ${uri}`); 128 + continue; 129 + } 130 + 131 + await agent.com.atproto.repo.putRecord({ 132 + repo: did, 133 + collection: "space.atbb.forum.theme", 134 + rkey: preset.rkey, 135 + record: { 136 + $type: "space.atbb.forum.theme", 137 + name: preset.name, 138 + colorScheme: preset.colorScheme, 139 + tokens, 140 + createdAt: now, 141 + updatedAt: now, 142 + }, 143 + }); 144 + consola.success(` ${preset.name}`); 145 + } 146 + 147 + const lightUri = localUris.find((t) => t.rkey === "neobrutal-light")!.uri; 148 + const darkUri = localUris.find((t) => t.rkey === "neobrutal-dark")!.uri; 149 + const available = localUris.map((t) => ({ uri: t.uri })); 150 + 151 + consola.log(""); 152 + 153 + // Show existing themePolicy before overwriting so operators can see what will change 154 + try { 155 + const existing = await agent.com.atproto.repo.getRecord({ 156 + repo: did, 157 + collection: "space.atbb.forum.themePolicy", 158 + rkey: "self", 159 + }); 160 + const rec = existing.data.value as Record<string, unknown>; 161 + const existingLight = (rec.defaultLightTheme as Record<string, string> | undefined)?.uri; 162 + const existingDark = (rec.defaultDarkTheme as Record<string, string> | undefined)?.uri; 163 + consola.info("Existing themePolicy:"); 164 + consola.info(` defaultLightTheme: ${existingLight ?? "(none)"}`); 165 + consola.info(` defaultDarkTheme: ${existingDark ?? "(none)"}`); 166 + } catch { 167 + consola.info("No existing themePolicy — will create."); 168 + } 169 + 170 + if (isDryRun) { 171 + consola.info(` ~ themePolicy — would write ${available.length} local refs`); 172 + consola.info(` defaultLightTheme: ${lightUri}`); 173 + consola.info(` defaultDarkTheme: ${darkUri}`); 174 + } else { 175 + await agent.com.atproto.repo.putRecord({ 176 + repo: did, 177 + collection: "space.atbb.forum.themePolicy", 178 + rkey: "self", 179 + record: { 180 + $type: "space.atbb.forum.themePolicy", 181 + availableThemes: available, 182 + defaultLightTheme: { uri: lightUri }, 183 + defaultDarkTheme: { uri: darkUri }, 184 + allowUserChoice: true, 185 + updatedAt: now, 186 + }, 187 + }); 188 + consola.success("themePolicy written"); 189 + consola.info(` defaultLightTheme: ${lightUri}`); 190 + consola.info(` defaultDarkTheme: ${darkUri}`); 191 + } 192 + 193 + await forumAgent.shutdown(); 194 + consola.log(""); 195 + consola.box( 196 + "Done — this forum now uses only local preset refs (no atbb.space dependency).\n" + 197 + "You can still customize presets in the admin theme editor." 198 + ); 199 + }, 200 + }); 201 + 202 + // ── publish-canonical ──────────────────────────────────────────────────────── 203 + 204 + const publishCanonicalCommand = defineCommand({ 205 + meta: { 206 + name: "publish-canonical", 207 + description: 208 + "[atbb.space only] Publish built-in preset themes to the canonical PDS. " + 209 + "Safe to re-run — uses upsert semantics, skips unchanged presets.", 210 + }, 211 + args: { 212 + "dry-run": { 213 + type: "boolean", 214 + description: "Show what would be written without making any changes", 215 + default: false, 216 + }, 217 + }, 218 + async run({ args }) { 219 + const isDryRun = args["dry-run"]; 220 + consola.box("atBB — Publish Canonical Presets" + (isDryRun ? " [dry-run]" : "")); 221 + 222 + const config = loadCliConfig(); 223 + const { agent, did, forumAgent } = await authenticate(config); 224 + 225 + consola.info(`Publishing ${PRESETS.length} presets to ${config.pdsUrl}`); 226 + if (isDryRun) consola.warn("Dry-run: no changes will be made."); 227 + consola.log(""); 228 + 229 + const now = new Date().toISOString(); 230 + let written = 0; 231 + let skipped = 0; 232 + 233 + for (const preset of PRESETS) { 234 + const tokens = JSON.parse( 235 + readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 236 + ) as Record<string, string>; 237 + 238 + // Fetch existing record to check for changes and preserve createdAt 239 + let existingCreatedAt: string | null = null; 240 + let alreadyCurrent = false; 241 + 242 + try { 243 + const res = await agent.com.atproto.repo.getRecord({ 244 + repo: did, 245 + collection: "space.atbb.forum.theme", 246 + rkey: preset.rkey, 247 + }); 248 + const existing = res.data.value as Record<string, unknown>; 249 + existingCreatedAt = (existing.createdAt as string) ?? null; 250 + if (isRecordCurrent(existing, preset, tokens)) alreadyCurrent = true; 251 + } catch (err: unknown) { 252 + // Only swallow 404 — record doesn't exist yet and will be created. 253 + // Re-throw anything else (network errors, auth failures, etc.). 254 + const status = (err as Record<string, unknown>).status; 255 + if (status !== 404) throw err; 256 + } 257 + 258 + if (alreadyCurrent) { 259 + consola.success(`${preset.name} — unchanged`); 260 + skipped++; 261 + continue; 262 + } 263 + 264 + const action = existingCreatedAt ? "updated" : "created"; 265 + 266 + if (isDryRun) { 267 + consola.info(` ~ ${preset.name} — would ${action}`); 268 + written++; 269 + continue; 270 + } 271 + 272 + const record: Record<string, unknown> = { 273 + $type: "space.atbb.forum.theme", 274 + name: preset.name, 275 + colorScheme: preset.colorScheme, 276 + tokens, 277 + createdAt: existingCreatedAt ?? now, 278 + }; 279 + // Only set updatedAt on updates, not initial creates 280 + if (existingCreatedAt) record.updatedAt = now; 281 + 282 + await agent.com.atproto.repo.putRecord({ 283 + repo: did, 284 + collection: "space.atbb.forum.theme", 285 + rkey: preset.rkey, 286 + record, 287 + }); 288 + consola.success(`${preset.name} — ${action}`); 289 + written++; 290 + } 291 + 292 + await forumAgent.shutdown(); 293 + consola.log(""); 294 + consola.info(`Done. ${written} written, ${skipped} unchanged.`); 295 + }, 296 + }); 297 + 298 + // ── theme command group ────────────────────────────────────────────────────── 299 + 300 + export const themeCommand = defineCommand({ 301 + meta: { 302 + name: "theme", 303 + description: "Manage forum themes and preset publishing", 304 + }, 305 + subCommands: { 306 + "bootstrap-local": bootstrapLocalCommand, 307 + "publish-canonical": publishCanonicalCommand, 308 + }, 309 + });
+2
packages/cli/src/index.ts
··· 3 3 import { initCommand } from "./commands/init.js"; 4 4 import { categoryCommand } from "./commands/category.js"; 5 5 import { boardCommand } from "./commands/board.js"; 6 + import { themeCommand } from "./commands/theme.js"; 6 7 7 8 const main = defineCommand({ 8 9 meta: { ··· 14 15 init: initCommand, 15 16 category: categoryCommand, 16 17 board: boardCommand, 18 + theme: themeCommand, 17 19 }, 18 20 }); 19 21
+22
packages/css-sanitizer/src/__tests__/index.test.ts
··· 79 79 expect(warnings.filter((w) => w.includes("@import"))).toHaveLength(2); 80 80 }); 81 81 82 + it("strips @IMPORT with uppercase obfuscation", () => { 83 + // CSS keywords are case-insensitive per spec. The sanitizer normalizes via 84 + // node.name.toLowerCase() before comparison, so @IMPORT is caught even if 85 + // css-tree preserves the original casing in the AST node name. 86 + const { css, warnings } = sanitizeCssOverrides( 87 + '@IMPORT "https://evil.com/steal.css";' 88 + ); 89 + expect(css).not.toContain("evil.com"); 90 + expect(warnings.some((w) => w.includes("@import"))).toBe(true); 91 + }); 92 + 82 93 // ── external url() in declarations ─────────────────────────────────────── 83 94 84 95 it("strips declarations with http:// url()", () => { ··· 166 177 "div { width: expression(alert(1)); }" 167 178 ); 168 179 expect(css).not.toContain("expression("); 180 + expect(warnings.some((w) => w.includes("width"))).toBe(true); 181 + }); 182 + 183 + it("strips EXPRESSION() with uppercase obfuscation", () => { 184 + // The sanitizer normalizes via inner.name.toLowerCase() before comparison, 185 + // so EXPRESSION() is caught regardless of how css-tree stores the function name. 186 + const { css, warnings } = sanitizeCssOverrides( 187 + "div { width: EXPRESSION(alert(1)); }" 188 + ); 189 + expect(css).not.toContain("EXPRESSION("); 190 + expect(css).not.toContain("alert("); 169 191 expect(warnings.some((w) => w.includes("width"))).toBe(true); 170 192 }); 171 193
+1 -1
packages/db/src/schema.sqlite.ts
··· 295 295 .notNull() 296 296 .references(() => themePolicies.id, { onDelete: "cascade" }), 297 297 themeUri: text("theme_uri").notNull(), 298 - themeCid: text("theme_cid").notNull(), 298 + themeCid: text("theme_cid"), 299 299 }, 300 300 (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 301 301 );
+1 -1
packages/db/src/schema.ts
··· 311 311 .notNull() 312 312 .references(() => themePolicies.id, { onDelete: "cascade" }), 313 313 themeUri: text("theme_uri").notNull(), 314 - themeCid: text("theme_cid").notNull(), 314 + themeCid: text("theme_cid"), 315 315 }, 316 316 (t) => [primaryKey({ columns: [t.policyId, t.themeUri] })] 317 317 );
+17 -7
packages/lexicon/lexicons/space/atbb/forum/themePolicy.yaml
··· 53 53 themeRef: 54 54 type: object 55 55 description: >- 56 - A reference to a theme record, wrapped in a named def for semantic 57 - clarity and future extensibility. 56 + A reference to a theme record. When 'cid' is present the reference is 57 + pinned — the appview verifies the CID on fetch and falls through to the 58 + next resolution step on mismatch. When 'cid' is absent the reference is 59 + live — it always resolves the current record at that URI. The default 60 + themePolicy shipped with atBB uses live refs to canonical preset records 61 + on the atbb.space PDS. 58 62 required: 59 - - theme 63 + - uri 60 64 properties: 61 - theme: 62 - type: ref 63 - ref: com.atproto.repo.strongRef 65 + uri: 66 + type: string 67 + format: at-uri 68 + description: AT-URI of the space.atbb.forum.theme record. 69 + cid: 70 + type: string 71 + format: cid 64 72 description: >- 65 - Strong reference (AT-URI + CID) to a space.atbb.forum.theme record. 73 + Optional CID. When set, pins to this exact record version (strong 74 + reference). When absent, always resolves the current record at the 75 + URI (live reference).