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)

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

+2461 -166
+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 }
+3 -1
apps/appview/package.json
··· 15 15 "db:migrate": "drizzle-kit migrate --config=drizzle.postgres.config.ts", 16 16 "db:generate:sqlite": "drizzle-kit generate --config=drizzle.sqlite.config.ts", 17 17 "db:migrate:sqlite": "drizzle-kit migrate --config=drizzle.sqlite.config.ts", 18 - "migrate-permissions": "tsx --env-file=../../.env scripts/migrate-permissions.ts" 18 + "migrate-permissions": "tsx --env-file=../../.env scripts/migrate-permissions.ts", 19 + "publish-presets": "tsx --env-file=../../.env scripts/publish-presets.ts", 20 + "bootstrap-local-presets": "tsx --env-file=../../.env scripts/bootstrap-local-presets.ts" 19 21 }, 20 22 "dependencies": { 21 23 "@atbb/atproto": "workspace:*",
+129
apps/appview/scripts/bootstrap-local-presets.ts
··· 1 + /** 2 + * Escape-hatch script: writes all built-in preset theme records to the forum's 3 + * own PDS and updates themePolicy to reference those local URIs instead of 4 + * the canonical atbb.space records. 5 + * 6 + * Use this for self-hosted forums that want zero external theme dependencies. 7 + * Safe to re-run — putRecord is upsert. The operator can still customize 8 + * individual presets in the admin theme editor afterward. 9 + * 10 + * Usage: 11 + * pnpm --filter @atbb/appview bootstrap-local-presets 12 + * pnpm --filter @atbb/appview bootstrap-local-presets --dry-run 13 + * 14 + * Required env vars: 15 + * PDS_URL — Forum's PDS endpoint 16 + * FORUM_HANDLE — Forum service account handle 17 + * FORUM_PASSWORD — Forum service account password 18 + */ 19 + import { AtpAgent } from "@atproto/api"; 20 + import { readFileSync } from "fs"; 21 + import { fileURLToPath } from "url"; 22 + import { dirname, join } from "path"; 23 + 24 + const __dirname = dirname(fileURLToPath(import.meta.url)); 25 + const PRESET_DIR = join(__dirname, "../../web/src/styles/presets"); 26 + 27 + const PRESETS = [ 28 + { rkey: "neobrutal-light", name: "Neobrutal Light", colorScheme: "light" as const }, 29 + { rkey: "neobrutal-dark", name: "Neobrutal Dark", colorScheme: "dark" as const }, 30 + { rkey: "clean-light", name: "Clean Light", colorScheme: "light" as const }, 31 + { rkey: "clean-dark", name: "Clean Dark", colorScheme: "dark" as const }, 32 + { rkey: "classic-bb", name: "Classic BB", colorScheme: "light" as const }, 33 + ] as const; 34 + 35 + const isDryRun = process.argv.includes("--dry-run"); 36 + 37 + const pdsUrl = process.env.PDS_URL; 38 + const handle = process.env.FORUM_HANDLE; 39 + const password = process.env.FORUM_PASSWORD; 40 + 41 + if (!pdsUrl || !handle || !password) { 42 + console.error("Required environment variables: PDS_URL, FORUM_HANDLE, FORUM_PASSWORD"); 43 + process.exit(1); 44 + } 45 + 46 + async function run() { 47 + if (isDryRun) console.log("[dry-run] No records will be written.\n"); 48 + 49 + const agent = new AtpAgent({ service: pdsUrl! }); 50 + await agent.login({ identifier: handle!, password: password! }); 51 + 52 + const did = agent.session?.did; 53 + if (!did) throw new Error("Login succeeded but session has no DID"); 54 + 55 + console.log(`Authenticated as ${handle} (${did})`); 56 + console.log(`Writing ${PRESETS.length} preset records to local PDS (${pdsUrl})\n`); 57 + 58 + const now = new Date().toISOString(); 59 + const localUris: Array<{ rkey: string; uri: string }> = []; 60 + 61 + for (const preset of PRESETS) { 62 + const tokens = JSON.parse( 63 + readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 64 + ) as Record<string, string>; 65 + 66 + const uri = `at://${did}/space.atbb.forum.theme/${preset.rkey}`; 67 + localUris.push({ rkey: preset.rkey, uri }); 68 + 69 + if (isDryRun) { 70 + console.log(` ~ ${preset.name} — would write ${uri}`); 71 + continue; 72 + } 73 + 74 + await agent.com.atproto.repo.putRecord({ 75 + repo: did, 76 + collection: "space.atbb.forum.theme", 77 + rkey: preset.rkey, 78 + record: { 79 + $type: "space.atbb.forum.theme", 80 + name: preset.name, 81 + colorScheme: preset.colorScheme, 82 + tokens, 83 + createdAt: now, 84 + updatedAt: now, 85 + }, 86 + }); 87 + 88 + console.log(` ✓ ${preset.name} — written at ${uri}`); 89 + } 90 + 91 + // Build themePolicy with local URI-only refs (no cid = live refs pointing at this forum's PDS) 92 + const lightUri = localUris.find((t) => t.rkey === "neobrutal-light")!.uri; 93 + const darkUri = localUris.find((t) => t.rkey === "neobrutal-dark")!.uri; 94 + const available = localUris.map((t) => ({ uri: t.uri })); 95 + 96 + console.log("\nWriting themePolicy with local refs..."); 97 + 98 + if (isDryRun) { 99 + console.log(` ~ themePolicy — would write ${available.length} local refs`); 100 + console.log(` defaultLightTheme: ${lightUri}`); 101 + console.log(` defaultDarkTheme: ${darkUri}`); 102 + } else { 103 + await agent.com.atproto.repo.putRecord({ 104 + repo: did, 105 + collection: "space.atbb.forum.themePolicy", 106 + rkey: "self", 107 + record: { 108 + $type: "space.atbb.forum.themePolicy", 109 + availableThemes: available, 110 + defaultLightTheme: { uri: lightUri }, 111 + defaultDarkTheme: { uri: darkUri }, 112 + allowUserChoice: true, 113 + updatedAt: now, 114 + }, 115 + }); 116 + 117 + console.log(" ✓ themePolicy written"); 118 + console.log(` defaultLightTheme: ${lightUri}`); 119 + console.log(` defaultDarkTheme: ${darkUri}`); 120 + } 121 + 122 + console.log("\nDone. This forum now uses only local preset refs (no atbb.space dependency)."); 123 + console.log("You can still customize presets in the admin theme editor."); 124 + } 125 + 126 + run().catch((err) => { 127 + console.error("Failed:", err instanceof Error ? err.message : String(err)); 128 + process.exit(1); 129 + });
+131
apps/appview/scripts/publish-presets.ts
··· 1 + /** 2 + * Release pipeline script: publishes canonical built-in preset theme records 3 + * to the atbb.space PDS. Safe to re-run — uses putRecord (upsert semantics). 4 + * Skips presets whose token values are already up to date. 5 + * 6 + * Usage: 7 + * pnpm --filter @atbb/appview publish-presets 8 + * pnpm --filter @atbb/appview publish-presets --dry-run 9 + * 10 + * Required env vars: 11 + * PDS_URL — Forum PDS endpoint (e.g. https://pds.atbb.space) 12 + * FORUM_HANDLE — Forum service account handle 13 + * FORUM_PASSWORD — Forum service account password 14 + */ 15 + import { AtpAgent } from "@atproto/api"; 16 + import { readFileSync } from "fs"; 17 + import { fileURLToPath } from "url"; 18 + import { dirname, join } from "path"; 19 + 20 + const __dirname = dirname(fileURLToPath(import.meta.url)); 21 + const PRESET_DIR = join(__dirname, "../../web/src/styles/presets"); 22 + 23 + const PRESETS = [ 24 + { rkey: "neobrutal-light", name: "Neobrutal Light", colorScheme: "light" as const }, 25 + { rkey: "neobrutal-dark", name: "Neobrutal Dark", colorScheme: "dark" as const }, 26 + { rkey: "clean-light", name: "Clean Light", colorScheme: "light" as const }, 27 + { rkey: "clean-dark", name: "Clean Dark", colorScheme: "dark" as const }, 28 + { rkey: "classic-bb", name: "Classic BB", colorScheme: "light" as const }, 29 + ] as const; 30 + 31 + const isDryRun = process.argv.includes("--dry-run"); 32 + 33 + const pdsUrl = process.env.PDS_URL; 34 + const handle = process.env.FORUM_HANDLE; 35 + const password = process.env.FORUM_PASSWORD; 36 + 37 + if (!pdsUrl || !handle || !password) { 38 + console.error("Required environment variables: PDS_URL, FORUM_HANDLE, FORUM_PASSWORD"); 39 + process.exit(1); 40 + } 41 + 42 + /** Stable JSON string for token comparison — sorted keys, ignores ordering differences. */ 43 + function stableTokensJson(tokens: Record<string, string>): string { 44 + return JSON.stringify(Object.fromEntries(Object.entries(tokens).sort())); 45 + } 46 + 47 + async function run() { 48 + if (isDryRun) console.log("[dry-run] No records will be written.\n"); 49 + 50 + const agent = new AtpAgent({ service: pdsUrl! }); 51 + await agent.login({ identifier: handle!, password: password! }); 52 + 53 + const did = agent.session?.did; 54 + if (!did) throw new Error("Login succeeded but session has no DID"); 55 + 56 + console.log(`Authenticated as ${handle} (${did})`); 57 + console.log(`Publishing ${PRESETS.length} presets to ${pdsUrl}\n`); 58 + 59 + const now = new Date().toISOString(); 60 + let written = 0; 61 + let skipped = 0; 62 + 63 + for (const preset of PRESETS) { 64 + const tokens = JSON.parse( 65 + readFileSync(join(PRESET_DIR, `${preset.rkey}.json`), "utf-8") 66 + ) as Record<string, string>; 67 + 68 + // Fetch existing record to check for changes and preserve createdAt 69 + let existingCreatedAt: string | null = null; 70 + let alreadyCurrent = false; 71 + 72 + try { 73 + const res = await agent.com.atproto.repo.getRecord({ 74 + repo: did, 75 + collection: "space.atbb.forum.theme", 76 + rkey: preset.rkey, 77 + }); 78 + const existing = res.data.value as Record<string, unknown>; 79 + existingCreatedAt = (existing.createdAt as string) ?? null; 80 + 81 + if (stableTokensJson(existing.tokens as Record<string, string>) === stableTokensJson(tokens)) { 82 + alreadyCurrent = true; 83 + } 84 + } catch { 85 + // Record doesn't exist yet — will create 86 + } 87 + 88 + if (alreadyCurrent) { 89 + console.log(` ✓ ${preset.name} — unchanged, skipping`); 90 + skipped++; 91 + continue; 92 + } 93 + 94 + if (isDryRun) { 95 + const action = existingCreatedAt ? "update" : "create"; 96 + console.log(` ~ ${preset.name} — would ${action}`); 97 + written++; 98 + continue; 99 + } 100 + 101 + const record: Record<string, unknown> = { 102 + $type: "space.atbb.forum.theme", 103 + name: preset.name, 104 + colorScheme: preset.colorScheme, 105 + tokens, 106 + createdAt: existingCreatedAt ?? now, 107 + }; 108 + // Only set updatedAt on updates, not initial creates 109 + if (existingCreatedAt) { 110 + record.updatedAt = now; 111 + } 112 + 113 + await agent.com.atproto.repo.putRecord({ 114 + repo: did, 115 + collection: "space.atbb.forum.theme", 116 + rkey: preset.rkey, 117 + record, 118 + }); 119 + 120 + const action = existingCreatedAt ? "updated" : "created"; 121 + console.log(` ✓ ${preset.name} — ${action}`); 122 + written++; 123 + } 124 + 125 + console.log(`\nDone. ${written} written, ${skipped} unchanged.`); 126 + } 127 + 128 + run().catch((err) => { 129 + console.error("Failed:", err instanceof Error ? err.message : String(err)); 130 + process.exit(1); 131 + });
+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 () => {
+7 -45
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 1490 const lightTheme = resolvedThemes.find((t) => t.uri === defaultLightThemeUri)!; ··· 1540 1501 record: { 1541 1502 $type: "space.atbb.forum.themePolicy", 1542 1503 availableThemes: resolvedThemes.map((t) => ({ 1543 - theme: { uri: t.uri, cid: t.cid }, 1504 + uri: t.uri, 1505 + ...(t.cid !== undefined ? { cid: t.cid } : {}), 1544 1506 })), 1545 - defaultLightTheme: { theme: { uri: lightTheme.uri, cid: lightTheme.cid } }, 1546 - defaultDarkTheme: { theme: { uri: darkTheme.uri, cid: darkTheme.cid } }, 1507 + defaultLightTheme: { uri: lightTheme.uri, ...(lightTheme.cid !== undefined ? { cid: lightTheme.cid } : {}) }, 1508 + defaultDarkTheme: { uri: darkTheme.uri, ...(darkTheme.cid !== undefined ? { cid: darkTheme.cid } : {}) }, 1547 1509 allowUserChoice: resolvedAllowUserChoice, 1548 1510 updatedAt: new Date().toISOString(), 1549 1511 },
+5 -4
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 { ··· 123 123 const rkey = parseRkeyFromUri(defaultUri); 124 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 + }
+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.
+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).