@bongos/core 1.20.58 → 1.20.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/.bongos-core.json +198 -108
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +10 -0
  4. package/clients/bongos-client/index.cjs +10 -0
  5. package/clients/bongos-client/index.d.ts +15 -2
  6. package/clients/bongos-client/index.mjs +10 -0
  7. package/docs/adr/0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md +52 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +317 -6
  10. package/docs/api-reference.md +8 -3
  11. package/docs/architecture.md +1 -0
  12. package/docs/copy-inventory.md +339 -330
  13. package/docs/copy-registry.json +524 -439
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +4 -1
  16. package/docs/page-readings.json +729 -678
  17. package/migrations/core_268_builders_main_discipline.sql +35 -0
  18. package/modules/copy-desk/artist-stats.js +56 -0
  19. package/modules/copy-desk/module.json +1 -1
  20. package/modules/copy-desk/page-status.js +39 -0
  21. package/modules/copy-desk/routes/copy-desk.js +7 -16
  22. package/modules/copy-desk/tests/copy_no_cms.mjs +10 -1
  23. package/modules/economy/credits-by-craft.js +208 -0
  24. package/modules/economy/reward.js +8 -0
  25. package/modules/hall-ui/public/atlas.html +1 -1
  26. package/modules/hall-ui/public/blockers.html +1 -1
  27. package/modules/hall-ui/public/board-room.html +1 -1
  28. package/modules/hall-ui/public/collab.html +1 -1
  29. package/modules/hall-ui/public/commands.html +1 -1
  30. package/modules/hall-ui/public/copy-desk.html +1 -1
  31. package/modules/hall-ui/public/deploy.html +1 -1
  32. package/modules/hall-ui/public/diagrams.html +1 -1
  33. package/modules/hall-ui/public/drachmae.html +1 -1
  34. package/modules/hall-ui/public/fleet.html +1 -1
  35. package/modules/hall-ui/public/gate.html +1 -1
  36. package/modules/hall-ui/public/goal-map.html +1 -1
  37. package/modules/hall-ui/public/goals.html +1 -1
  38. package/modules/hall-ui/public/government.html +1 -1
  39. package/modules/hall-ui/public/hall-render.js +2 -2
  40. package/modules/hall-ui/public/idea.html +1 -1
  41. package/modules/hall-ui/public/ideas.html +1 -1
  42. package/modules/hall-ui/public/index.html +1 -1
  43. package/modules/hall-ui/public/modules.html +1 -1
  44. package/modules/hall-ui/public/primer.html +1 -1
  45. package/modules/hall-ui/public/profile.html +1 -1
  46. package/modules/hall-ui/public/profile.js +1 -1
  47. package/modules/hall-ui/public/project-settings.html +1 -1
  48. package/modules/hall-ui/public/ranks.html +1 -1
  49. package/modules/hall-ui/public/roadmap.html +1 -1
  50. package/modules/hall-ui/public/roster.html +1 -1
  51. package/modules/hall-ui/public/sessions.html +1 -1
  52. package/modules/hall-ui/public/settings.html +1 -1
  53. package/modules/hall-ui/public/shell.js +5 -1
  54. package/modules/hall-ui/public/studio.html +1 -1
  55. package/modules/hall-ui/public/style.css +8 -0
  56. package/modules/hall-ui/public/task.html +1 -1
  57. package/modules/hall-ui/public/thinking.html +1 -1
  58. package/modules/hall-ui/public/tweak-editor.html +1 -1
  59. package/modules/hall-ui/public/watch.html +1 -1
  60. package/modules/hall-ui/public/work.html +1 -1
  61. package/modules/ideas/ideator-stats.js +149 -0
  62. package/modules/ideas/module.json +2 -1
  63. package/modules/ideas/routes/inbox.js +7 -0
  64. package/modules/lifecycle/activity.js +5 -2
  65. package/modules/lifecycle/db-analytics.js +4 -2
  66. package/modules/lifecycle/db-tasks.js +49 -1
  67. package/modules/lifecycle/db.js +3 -2
  68. package/modules/lifecycle/page-tweak-reads.js +23 -5
  69. package/modules/provisioning/look.js +169 -0
  70. package/modules/provisioning/migrations/provisioning_034_project_logo.sql +35 -0
  71. package/modules/provisioning/module.json +2 -1
  72. package/modules/provisioning/provisioning.js +4 -3
  73. package/modules/provisioning/routes/look.js +204 -0
  74. package/modules/provisioning/routes/provisioning.js +5 -59
  75. package/modules/provisioning/settings-push.js +76 -0
  76. package/modules/public-landing/public/assets/cosmos.css +1 -1
  77. package/modules/public-landing/public/projects.html +499 -28
  78. package/modules/public-landing/public/projects.probes.json +2 -2
  79. package/modules/public-landing/public/projects.states.json +6 -3
  80. package/modules/ui-design/kit/serve.js +50 -0
  81. package/package-lock.json +2 -2
  82. package/package.json +1 -1
  83. package/release-notes.json +12 -0
  84. package/scripts/gds/fitness.js +4 -0
  85. package/scripts/gds/run-unit-tests.js +5 -0
  86. package/src/bongos/db.js +44 -6
  87. package/src/bongos/role-stats.js +155 -0
  88. package/src/bongos/routes/builders.js +15 -2
  89. package/src/bongos/routes/me.js +11 -3
  90. package/src/bongos/routes.js +4 -1
  91. package/src/bongos/serve-internal.js +6 -0
  92. package/src/branding.js +26 -1
  93. package/src/hall-look.js +99 -0
  94. package/src/module-api.js +1 -1
  95. package/src/night-tint.js +26 -0
  96. package/tests/copy_desk_page_reads.mjs +1 -1
  97. package/tests/currency_label.mjs +7 -4
  98. package/tests/fixtures/page-ledger.mjs +1 -1
  99. package/tests/hall_tokens.mjs +10 -1
  100. package/tests/hall_tweak_editor.mjs +1 -1
  101. package/tests/look_night_tint.mjs +169 -0
  102. package/tests/main_discipline.mjs +81 -0
  103. package/tests/nav_permission_atoms.mjs +1 -1
  104. package/tests/profile_role_stats.mjs +416 -0
  105. package/tests/projects_hub.mjs +5 -4
  106. package/tests/projects_hub_app_status.mjs +1 -1
  107. package/tests/projects_hub_app_step.mjs +8 -7
  108. package/tests/projects_hub_look.mjs +255 -0
  109. package/tests/projects_hub_module_picker.mjs +2 -2
  110. package/tests/provision_settings_apply.mjs +12 -4
  111. package/tests/provisioning_look.mjs +376 -0
  112. package/tests/provisioning_settings.mjs +10 -7
  113. package/tests/provisioning_settings_apply.mjs +4 -4
  114. package/tests/provisioning_settings_env.mjs +4 -0
  115. package/tests/role_stats_db.mjs +237 -0
  116. package/tests/tweak_replaces_artist_review.mjs +1 -1
  117. package/tests/wizard_draft_resume.mjs +2 -2
  118. package/tests/wizard_front_door.mjs +2 -2
  119. package/tests/wizard_intent_resume.mjs +16 -15
@@ -159,7 +159,7 @@
159
159
  "steps": [["wait",900],["click","[data-disc-ask]"],["wait",200],["fill","#mDiscName","old-lantern"],["wait",100],["click","[data-disc-yes]"],["wait",900]],
160
160
  "expect": {"text":[["#mDisc .addr","This project is being disconnected."],["#mName","old-lantern"]],"hidden":["#mDiscName","[data-disc-ask]"]} },
161
161
  { "name": "the wizard driver (task 1003346): name, type, about, home (fresh; the stub grants repo access, so the new repository is named from the project), address (the handle's verdict), the review as a ledger, then the filing — the done panel with the standup rail and the human steps", "auth": true, "width": 1440, "url": "/projects?view=new",
162
- "steps": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500]],
162
+ "steps": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#panel-look button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500]],
163
163
  "expect": {"visible":["#panel-done","#liveProgress .prog","#liveProgress .prog .stages li.now","#humanSteps li"],"hidden":["#panel-review","#panel-name"],"text":[["#done-h","The drums are rolling"]],"attr":[["#panel-done","class","panel on"]]} },
164
164
  { "name": "the front door (task 1004417): Proceed is refused until the terms are ticked, the fork's demo door says the demo is not open yet, and Begin planet creation goes on to the name step with the rail back", "auth": true, "width": 1440, "url": "/projects?view=new",
165
165
  "steps": [["wait",600],["click","#panel-terms button[type=submit]"],["wait",300],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#wizDemo"],["wait",200],["click","#panel-fork button[type=submit]"],["wait",400]],
@@ -168,7 +168,7 @@
168
168
  "steps": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400]],
169
169
  "expect": {"visible":["#panel-fork",".doorPill","#wizDemo","#panel-fork button[type=submit]"],"hidden":["#view-new .steps"]} },
170
170
  { "name": "the wizard driver at 390: the rail folds to two rows of three and the walk still reaches the review", "auth": true, "width": 390, "url": "/projects?view=new",
171
- "steps": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400]],
171
+ "steps": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#panel-look button[type=submit]"],["wait",400]],
172
172
  "expect": {"visible":["#panel-review","#reviewList li","#doCreate"],"hidden":["#panel-address"],"text":[["#rev-h","Ready to roll"]]} }
173
173
  ]
174
174
  }
@@ -22,6 +22,7 @@
22
22
  "in-manage-address": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mAddress", "#mAddress h2", "#mDom .plain", "#mDomName", "#mDom [data-m-attach]", "#mDomWhat summary"], "count": [["#mDom .attach input[type=text]", 1], ["#mDom .attach button", 1]] } },
23
23
  "in-manage-planet": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mPlanetPick", "#mPick .pick .pickPreview .worldOrb .disc", "#mPick [data-pick-save]", "#mPick [data-pick-reset]", "#mPickWhat summary"], "count": [["#mPick .pickCells[role=\"radiogroup\"]", 3], ["#mPick .pickCell", 10], ["#mPick .pickCell.on", 3], ["#mPick .pickSw", 5], ["#mPick .pickSw.on", 1]], "attr": [["#mPick .pickPreview .worldOrb", "data-template", "pair-obsidian-tint"], ["#mOrb .worldOrb", "data-template", "pair-obsidian-tint"]], "checked": "#mPick input[name=\"mPickAccent\"][value=\"sea\"]", "text": [["#mPlanet .v small", "your choice."]] } },
24
24
  "in-manage-card": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mCard", "#mCardPick .pick .pickPreview .cardBand", "#mCardPick [data-card-save]", "#mCardWhat summary"], "count": [["#mCardPick .pickCells[role=\"radiogroup\"]", 1], ["#mCardPick .pickCell", 5], ["#mCardPick .pickCell.on", 1]], "checked": "#mCardPick input[name=\"mCardBackdrop\"][value=\"\"]", "attr": [["#mCardPick .cardBand", "data-own", ""]] } },
25
+ "in-manage-look": {"auth":true,"url":"/projects?view=manage&id=101","actions":[["wait",900],["focus","#mLkLook-grove"],["wait",200]],"expect":{"visible":["#mLook","#mLookPick .lookOpts","#mLkLook-grove","#mLkTint","#mLookSave",".lookPrev .mh.dark","#mLookWhat summary"]}},
25
26
  "in-manage-kind": { "auth": true, "url": "/projects?view=manage&id=101", "expect": {"visible":["#mKind","#mKindPick .modBand","#mKindWhat summary"],"count":[["#mKindPick .modOpt",5],["#mKindPick .modOpt[aria-pressed=\"true\"]",0]],"text":[["#mKindPick .modOpt b","Game"],["#mKindPick .finePrint","This project has never said what kind it is. Saying so puts the word beside its name wherever it is listed; it changes nothing else."]]} },
26
27
  "in-manage-invite": { "auth": true, "url": "/projects?view=manage&id=101", "expect": { "visible": ["#mInvite", "#mInvite h2", "#mInviteWho", "#mInvite [data-invite-send]"] } },
27
28
  "in-manage-invite-handoff": { "auth": true, "url": "/projects?view=manage&id=101", "actions": [["wait",900],["fill","#mInviteWho","octo-builder"],["click","#mInvite [data-invite-send]"],["wait",400]], "expect": {"visible":["#mInviteList a","#mInviteMsg"],"hidden":[]} },
@@ -49,9 +50,11 @@
49
50
  "in-new-address": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-address"],"hidden":["#panel-home","#qWrap"]} },
50
51
  "in-new-app": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-app","#appOpts","#appOptRender"],"hidden":["#panel-address","#panel-review","#qWrap"]} },
51
52
  "in-new-app-render": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#appOptRender input"],["wait",300]], "expect": {"visible":["#panel-app","#renderSetup","#f-renderKey","#renderCheck"],"hidden":["#renderWs","#renderSvc","#renderNew"]} },
52
- "in-new-review": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-review","#reviewList li"],"hidden":["#panel-address","#qWrap"]} },
53
- "in-new-done": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500]], "expect": {"visible":["#panel-done","#liveProgress .prog","#humanSteps li"],"hidden":["#panel-review","#qWrap"]} },
54
- "in-new-resume-offer": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500],["click","#projTrig"],["wait",350],["click","#menuProjects [data-nav=\"new\"]"],["wait",500]], "expect": {"visible":["#panel-terms","#wizResume","#wizResumeGo"],"hidden":["#panel-done","#panel-name"],"text":[["#wizResumeName","Mercury"]]} },
53
+ "in-new-look": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400]],"expect":{"visible":["#panel-look","#wizLook","#wizLook-chrome-world","#wizTint","#wizLogo",".lookPrev .mh.light",".lookPrev .mh.dark"],"hidden":["#panel-app","#panel-review"]}},
54
+ "in-new-look-custom": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#wizLook-custom"],["wait",200]],"expect":{"visible":["#panel-look","#wizAccent","#wizAccentNote"],"hidden":["#panel-review"]}},
55
+ "in-new-review": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#panel-look button[type=submit]"],["wait",400]], "expect": {"visible":["#panel-review","#reviewList li"],"hidden":["#panel-address","#qWrap"]} },
56
+ "in-new-done": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#panel-look button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500]], "expect": {"visible":["#panel-done","#liveProgress .prog","#humanSteps li"],"hidden":["#panel-review","#qWrap"]} },
57
+ "in-new-resume-offer": { "auth": true, "url": "/projects?view=new", "actions": [["wait",600],["click","#termsAck"],["click","#panel-terms button[type=submit]"],["wait",400],["click","#panel-fork button[type=submit]"],["wait",400],["fill","#newName","Mercury"],["click","#panel-name button[type=submit]"],["wait",400],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-type button[type=submit]"],["wait",400],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#teamCards .tpill[data-team=\"small-team\"]"],["click","#panel-detail button[type=submit]"],["wait",900],["click","#panel-modules button[type=submit]"],["wait",400],["click","#panel-home button[type=submit]"],["wait",400],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#panel-address button[type=submit]"],["wait",400],["click","#panel-app button[type=submit]"],["wait",400],["click","#panel-look button[type=submit]"],["wait",400],["click","#doCreate"],["wait",1500],["click","#projTrig"],["wait",350],["click","#menuProjects [data-nav=\"new\"]"],["wait",500]], "expect": {"visible":["#panel-terms","#wizResume","#wizResumeGo"],"hidden":["#panel-done","#panel-name"],"text":[["#wizResumeName","Mercury"]]} },
55
58
  "out-map-projects": { "auth": false, "actions": [["click", "#projTrig"], ["wait", 350]], "expect": { "expanded": true, "hidden": ["#menuProjects [data-nav=\"mine\"]"] } },
56
59
  "card": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://example.com\"]"], ["wait", 900]], "expect": { "dialog": true, "hasClass": [["#projCard", "has-art"]], "visible": ["#cardArt", "#cardFaces", "#cardBuildersFig", "#cardShippedFig", "#cardOpen", "#cardHall"], "text": [["#cardBuilders", "11"], ["#cardShipped", "0"], ["#cardRel", "you build here"], ["#cardRuns", "example.com"], ["#cardFaces .facesMore", "+6"]], "count": [["#cardFaces img", 5]], "href": [["#cardHall", "https://builders.example.com/"]] } },
57
60
  "card-noart": { "auth": true, "actions": [["dispatchClick", "#orbField a.worldOrb[data-origin=\"https://emersonian-circles.cloudbongos.com\"]"], ["wait", 600]], "expect": { "dialog": true, "hasClass": [["#projCard", "has-art", false]], "hidden": ["#cardArt", "#cardFaces", "#cardHall"], "visible": ["#cardBuildersFig", "#cardShippedFig", "#cardOpen"], "text": [["#cardShipped", "30+"], ["#cardRel", "you build here"]] } },
@@ -201,6 +201,8 @@ const STUB_VOCAB = {
201
201
  join_grant: ['view', 'apply', 'full'],
202
202
  artist_gate: ['off', 'advisory', 'strict'],
203
203
  spend_payer: ['none', 'project', 'builder'],
204
+ look: ['own', 'chrome-world', 'expedition', 'grove', 'blueprint', 'custom'], // the look + its night (task 1004421)
205
+ night_tint: ['on', 'off'],
204
206
  planet_template: null, // the template set is long; shape is checked, not membership
205
207
  planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
206
208
  card_backdrop: ['nebula', 'dust', 'deepfield', 'chrome'],
@@ -224,6 +226,53 @@ function settingsPatch(res, id, body) {
224
226
  : { queued: false, restart: false, message: 'Saved. The sky, your list and the card draw it this way from here; nothing on your project restarts for it.' };
225
227
  return json(res, 200, { ok: true, saved: FLOW_RECEIPTS.settings.settings, planet: PLANETS.get(String(id)) || NO_PLANET, push });
226
228
  }
229
+ // a project's look (task 1004421, ADR 0358): GET/PATCH …/look and PUT/DELETE …/logo,
230
+ // validated the way routes/look.js validates and REMEMBERED for the run, so the manage
231
+ // page repaints from what it saved. The accent is stored as sent — the real route nudges
232
+ // it, and the page shows its own copy of that nudge before it ever asks.
233
+ const LOOK_STATE = new Map();
234
+ const LOGO_TYPES = ['image/png', 'image/jpeg', 'image/webp'];
235
+ function lookOf(id) {
236
+ return LOOK_STATE.get(String(id)) || { look: 'own', night_tint: 'on', look_accent: null, logo_url: null };
237
+ }
238
+ const LOOK_RECEIPT = { reachable: true, queued: true, restart: true, message: 'Saved. Your project will restart to pick this up — until then it keeps its current setting.' };
239
+ function lookRoute(req, res, rest) {
240
+ const m = rest.match(/^\/provisioning\/instances\/(\d+)\/(look|logo)$/);
241
+ if (!m) return false;
242
+ if (!AUTH) { json(res, 401, UNAUTH); return true; }
243
+ const id = m[1], cur = lookOf(id);
244
+ if (m[2] === 'look' && req.method === 'GET') { json(res, 200, { ok: true, look: cur, options: { look: STUB_VOCAB.look, night_tint: STUB_VOCAB.night_tint }, logo_max_bytes: 262144 }); return true; }
245
+ if (m[2] === 'look' && req.method === 'PATCH') {
246
+ readBody(req, (body) => {
247
+ const bad = Object.keys(body).find((k) => !['look', 'night_tint', 'accent'].includes(k))
248
+ || (body.look !== undefined && !STUB_VOCAB.look.includes(body.look) && 'look')
249
+ || (body.night_tint !== undefined && !STUB_VOCAB.night_tint.includes(body.night_tint) && 'night_tint');
250
+ if (bad) return json(res, 400, { error: { code: 'validation_failed', message: `bad field: ${bad}` } });
251
+ if (body.accent !== undefined && !/^#?[0-9a-f]{6}$/i.test(String(body.accent))) return json(res, 400, { error: { code: 'bad_accent', message: 'An accent is a colour written #rrggbb, for example #b48cf2.' } });
252
+ const next = { ...cur, ...(body.look ? { look: body.look } : {}), ...(body.night_tint ? { night_tint: body.night_tint } : {}), ...(body.accent ? { look_accent: String(body.accent).toLowerCase() } : {}) };
253
+ if (next.look === 'custom' && !next.look_accent) return json(res, 400, { error: { code: 'accent_required', message: 'Your own look needs an accent colour — send accent as #rrggbb.' } });
254
+ LOOK_STATE.set(id, next);
255
+ return json(res, 200, { ok: true, look: next, push: LOOK_RECEIPT, adjusted: null });
256
+ });
257
+ return true;
258
+ }
259
+ if (m[2] === 'logo' && req.method === 'PUT') {
260
+ readBody(req, (body) => {
261
+ if (!LOGO_TYPES.includes(body.content_type) || typeof body.image_b64 !== 'string' || !body.image_b64) return json(res, 400, { error: { code: 'logo_bad_type', message: 'A logo must be a PNG, JPEG or WebP image.' } });
262
+ const next = { ...cur, logo_url: 'data:' + body.content_type + ';base64,' + body.image_b64 };
263
+ LOOK_STATE.set(id, next);
264
+ return json(res, 200, { ok: true, look: next, push: LOOK_RECEIPT });
265
+ });
266
+ return true;
267
+ }
268
+ if (m[2] === 'logo' && req.method === 'DELETE') {
269
+ const next = { ...cur, logo_url: null };
270
+ LOOK_STATE.set(id, next);
271
+ json(res, 200, { ok: true, look: next, push: LOOK_RECEIPT });
272
+ return true;
273
+ }
274
+ return false;
275
+ }
227
276
  // the row carries its planet: a saved pick for the run, else the fixture's own (or null)
228
277
  function withPlanet(inst) {
229
278
  const saved = PLANETS.get(String(inst.id));
@@ -433,6 +482,7 @@ const server = http.createServer((req, res) => {
433
482
  // the manage flows (task 1003345): every door on a row and on the manage page only ASKS
434
483
  // (ADR 0111) — the stub answers each ask with the route's receipt so the probes can drive
435
484
  // attach / restart / teardown / revive / join end to end; nothing changes in the fixture
485
+ if (lookRoute(req, res, rest)) return;
436
486
  if (req.method !== 'GET') {
437
487
  const ps = rest.match(/^\/provisioning\/instances\/(\d+)\/settings$/);
438
488
  if (ps && req.method === 'PATCH') return AUTH ? readBody(req, (body) => settingsPatch(res, ps[1], body)) : json(res, 401, UNAUTH);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.58",
3
+ "version": "1.20.60",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.58",
9
+ "version": "1.20.60",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.58",
3
+ "version": "1.20.60",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8465,5 +8465,17 @@
8465
8465
  "id": "1004423",
8466
8466
  "text": "A project that is being founded now has a genesis home at the top of its hall. The founder settles a plan in one click (each stage's length, the starting government by its real name with a plain explanation, and how many passe"
8467
8467
  }
8468
+ ],
8469
+ "1.20.59": [
8470
+ {
8471
+ "id": "1004433",
8472
+ "text": "The profile now knows each person by their own work. An artist's page counts pages approved, how many pages of each part of the site they have tweaked, and what pages earned them; an ideator's counts thoughts filed, how many b"
8473
+ }
8474
+ ],
8475
+ "1.20.60": [
8476
+ {
8477
+ "id": "1004421",
8478
+ "text": "Every project can now have its own look, so builders who work in several projects always know which hall they are in. Creating a project has a new Look step: four ready-made looks or your own colour, a switch to tint dark mode"
8479
+ }
8468
8480
  ]
8469
8481
  }
@@ -566,6 +566,10 @@ const KERNEL_FILES = [
566
566
  // config / identity (host context a module reads, never hardcodes)
567
567
  'src/instance-config.js',
568
568
  'src/branding.js',
569
+ // task 1004421 (ADR 0358): branding.js lays the chosen look over the pack through this
570
+ // file — the same identity/config category, requiring only instance-config and node
571
+ // built-ins, so it joins the roster rather than reading as a kernel→domain leak.
572
+ 'src/hall-look.js',
569
573
  'src/build-info.js',
570
574
  // task 1003476 (goal 1000072): the project's stale-claim timer — host configuration
571
575
  // a module reads rather than hardcodes, exactly the category instance-config.js and
@@ -155,6 +155,11 @@ const INTEGRATION = new Set([
155
155
  // predicate still returns 0 so the bug itself stays executable. Same
156
156
  // always-rolled-back transaction discipline as the entry above.
157
157
  'session_earnings_mirror_db',
158
+ // task 1004433: the credits-by-craft split and the per-person earnings
159
+ // fragment against real SQL. Both are questions about which ledger rows a LIKE
160
+ // pattern and a LATERAL unnest reach, which a stubbed client cannot answer.
161
+ // One transaction, always rolled back.
162
+ 'role_stats_db',
158
163
  // RENAMED by ADR 0174 (goal 1000068) — these entries said governance_* until task
159
164
  // 1003091. A curated set keyed by a stem that no longer exists silently stops
160
165
  // covering its file: both of these dropped OUT of the integration set and INTO the
package/src/bongos/db.js CHANGED
@@ -149,7 +149,27 @@ async function linkAnthropicAccount(builderId, { email, userId }) {
149
149
  // updated builder row, or throws { code: 'invalid_discipline', bad } if any
150
150
  // element is not offered by this instance.
151
151
 
152
- async function updateBuilderDisciplines(builderId, disciplines) {
152
+ // `mainDiscipline` (task 1004433, core_268) is the builder's own pick of MAIN
153
+ // role, which the canonical-order set cannot carry. Three answers:
154
+ // undefined not mentioned: the stored main role is KEPT while it is still in
155
+ // the new set, and cleared when the builder drops that craft (a main
156
+ // role they no longer practise would lead their profile otherwise);
157
+ // null cleared on purpose;
158
+ // a string set, and it must be one of the crafts in the new set
159
+ // (`invalid_main_discipline`): a main role is one of your roles.
160
+ // The write, spelled once so tests/role_stats_db.mjs runs the very statement
161
+ // against a real Postgres: $3 says whether the caller named a main role at all,
162
+ // $4 is that role (or null to clear it).
163
+ const UPDATE_BUILDER_DISCIPLINES_SQL =
164
+ `UPDATE builders
165
+ SET preferred_disciplines = $2,
166
+ main_discipline = CASE WHEN $3::boolean THEN $4::text
167
+ WHEN main_discipline = ANY($2::text[]) THEN main_discipline
168
+ ELSE NULL END
169
+ WHERE id = $1
170
+ RETURNING id, github_id, github_login, display_name, avatar_url, total_credits, karma, created_at, rank, preferred_disciplines, main_discipline`;
171
+
172
+ async function updateBuilderDisciplines(builderId, disciplines, { mainDiscipline } = {}) {
153
173
  const { enabledDisciplines } = require('../modules');
154
174
  const offered = enabledDisciplines();
155
175
  const list = Array.isArray(disciplines) ? disciplines : [];
@@ -161,12 +181,28 @@ async function updateBuilderDisciplines(builderId, disciplines) {
161
181
  throw err;
162
182
  }
163
183
  const normalized = offered.filter((d) => list.includes(d));
184
+ if (typeof mainDiscipline === 'string' && !normalized.includes(mainDiscipline)) {
185
+ const err = new Error(`main discipline ${mainDiscipline} is not one of the chosen disciplines`);
186
+ err.code = 'invalid_main_discipline';
187
+ err.bad = [mainDiscipline];
188
+ throw err;
189
+ }
190
+ const setMain = mainDiscipline !== undefined;
164
191
  const { rows } = await pool.query(
165
- `UPDATE builders
166
- SET preferred_disciplines = $2
167
- WHERE id = $1
168
- RETURNING id, github_id, github_login, display_name, avatar_url, total_credits, karma, created_at, rank, preferred_disciplines`,
169
- [builderId, normalized]
192
+ UPDATE_BUILDER_DISCIPLINES_SQL,
193
+ [builderId, normalized, setMain, setMain ? mainDiscipline : null]
194
+ );
195
+ return rows[0] ?? null;
196
+ }
197
+
198
+ // The two role facts the profile needs beside the public builder row (task
199
+ // 1004433): the preferred set and the chosen main role. Its own read, rather
200
+ // than a column added to getBuilderById, so the kernel's hot identity read is
201
+ // untouched by a profile feature.
202
+ async function getBuilderRoleChoice(builderId) {
203
+ const { rows } = await pool.query(
204
+ `SELECT preferred_disciplines, main_discipline FROM builders WHERE id = $1`,
205
+ [builderId]
170
206
  );
171
207
  return rows[0] ?? null;
172
208
  }
@@ -1065,6 +1101,8 @@ module.exports = {
1065
1101
  getBuilderByGithubId,
1066
1102
  hasInvitedAccessRequest,
1067
1103
  updateBuilderDisciplines,
1104
+ UPDATE_BUILDER_DISCIPLINES_SQL,
1105
+ getBuilderRoleChoice,
1068
1106
  RANKS,
1069
1107
  setBuilderRank,
1070
1108
  getLatestRankChangeForBuilder,
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/role-stats.js — EACH ROLE BY ITS OWN WORK: the reads behind the
4
+ // role-shaped profile (task 1004433 / WA6.RP03, goal 1000095, criterion
5
+ // wa6-roles-read-true-in-public). The owner-approved mock is
6
+ // docs/design/mocks/profile-roles/ (task 1004431); RP04 to RP07 draw from this.
7
+ //
8
+ // WHAT IT ANSWERS. For one builder: which of the three crafts they are shown in,
9
+ // which one leads, and each craft's own headline numbers.
10
+ //
11
+ // engineer `shipped`, works shipped (unchanged from today), and engineering credits
12
+ // artist pages approved, "N of M tweaked" per surface, credits from pages,
13
+ // this week (copy-desk's studio tally, the `copy-desk.artistStats`
14
+ // port)
15
+ // ideator thoughts filed, ignited, Full Ideas, ratified by the board, total
16
+ // size across every idea stream with the historical-estimate
17
+ // marker, and the share still alive (the sky's own reads, the
18
+ // `ideas.ideatorStats` port)
19
+ //
20
+ // Plus `credits_by_craft`, the ledger split of total_credits (economy's
21
+ // `reward.creditsByCraft`), which is what makes an artist or an ideator who has
22
+ // never shipped a task read as what they are.
23
+ //
24
+ // THE OWNER'S RULES (2026-09-30), and where each lives:
25
+ // • one person, one profile; the builder CHOOSES their main role
26
+ // (builders.main_discipline, core_268). Until they do, the main role is the
27
+ // craft they have earned most in, then their first preferred craft, then
28
+ // engineering, and `main_basis` says which of those it is, so a surface can
29
+ // tell a choice from a guess.
30
+ // • any OTHER role shows only once it has earned credits. `shown` is the main
31
+ // role first, then each craft with credits, in preferred order. A craft not
32
+ // in `shown` carries no stats block (null): its tab and chip do not exist.
33
+ // • decision (a): the identity band, the tally and "By the numbers" stay
34
+ // public, so everything here is counts and sums. No idea title, no page
35
+ // wording, no task list leaves this read; those stay behind a sign-in on the
36
+ // Activity routes. The answer is the same for every viewer.
37
+ //
38
+ // A port that is absent (a vanilla instance without the module) gives that
39
+ // block `null` fields rather than zeros: "could not ask" is not "nothing there".
40
+
41
+ const seams = require('../module-seams');
42
+
43
+ const CRAFTS = Object.freeze(['engineer', 'artist', 'ideator']);
44
+ // A discipline as a craft, asked of economy (reward.craftForPreference), the
45
+ // module that decides which craft a credit belongs to. One table, so a builder's
46
+ // chips and their credits cannot disagree (`ui`, the design discipline, is the
47
+ // artist's craft there). With no economy module there are no credits to split
48
+ // either, and only the three craft names read as crafts.
49
+ function craftOf(discipline) {
50
+ const reward = seams.resolveOptional('reward');
51
+ if (reward && typeof reward.craftForPreference === 'function') return reward.craftForPreference(discipline);
52
+ return CRAFTS.includes(discipline) ? discipline : null;
53
+ }
54
+
55
+ function preferredCrafts(preferred) {
56
+ const out = [];
57
+ for (const d of Array.isArray(preferred) ? preferred : []) {
58
+ const c = craftOf(d);
59
+ if (c && !out.includes(c)) out.push(c);
60
+ }
61
+ return out;
62
+ }
63
+
64
+ // resolveMainRole — pure. The chosen craft, else the earned one, else the
65
+ // preferred one, else engineering (the platform's default craft).
66
+ function resolveMainRole({ mainDiscipline = null, preferred = [], byCraft = null } = {}) {
67
+ const chosen = craftOf(mainDiscipline);
68
+ if (chosen) return { main: chosen, basis: 'chosen' };
69
+ if (byCraft) {
70
+ let best = null;
71
+ for (const c of CRAFTS) {
72
+ const n = Number(byCraft[c]) || 0;
73
+ if (n > 0 && (!best || n > best.n)) best = { c, n };
74
+ }
75
+ if (best) return { main: best.c, basis: 'earned' };
76
+ }
77
+ const pref = preferredCrafts(preferred);
78
+ if (pref.length) return { main: pref[0], basis: 'preferred' };
79
+ return { main: 'engineer', basis: 'default' };
80
+ }
81
+
82
+ // shownCrafts — pure. Main first, then every craft with credits, in the
83
+ // builder's preferred order and then CRAFTS order.
84
+ function shownCrafts({ main, preferred = [], byCraft = null } = {}) {
85
+ const order = [...preferredCrafts(preferred), ...CRAFTS].filter((c, i, a) => a.indexOf(c) === i);
86
+ const out = [main];
87
+ for (const c of order) {
88
+ if (c === main) continue;
89
+ if (byCraft && Number(byCraft[c]) > 0) out.push(c);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ // composeRoleStats — pure: the facts in, the profile's `roles` block out.
95
+ function composeRoleStats({ choice = null, byCraft = null, shippedCount = 0, artist = null, ideator = null } = {}) {
96
+ const preferred = (choice && choice.preferred_disciplines) || [];
97
+ const { main, basis } = resolveMainRole({ mainDiscipline: choice && choice.main_discipline, preferred, byCraft });
98
+ const shown = shownCrafts({ main, preferred, byCraft });
99
+ const credits = byCraft
100
+ ? { engineer: byCraft.engineer, artist: byCraft.artist, ideator: byCraft.ideator, other: byCraft.other, total: byCraft.total }
101
+ : null;
102
+ return {
103
+ main,
104
+ main_basis: basis,
105
+ shown,
106
+ preferred: preferredCrafts(preferred),
107
+ credits_by_craft: credits,
108
+ engineer: shown.includes('engineer')
109
+ ? { shipped: Number(shippedCount) || 0, credits: credits ? credits.engineer : null }
110
+ : null,
111
+ artist: shown.includes('artist') ? (artist || emptyArtist()) : null,
112
+ ideator: shown.includes('ideator') ? (ideator || emptyIdeator()) : null,
113
+ };
114
+ }
115
+
116
+ // The shapes a shown craft takes when its port could not be asked: every number
117
+ // null, so a renderer says "unknown", never "0".
118
+ function emptyArtist() {
119
+ return { pages_approved: null, pages: null, surfaces_touched: null, credits: null, this_week: null, surfaces: [], unavailable: true };
120
+ }
121
+ function emptyIdeator() {
122
+ return {
123
+ filed: null, ignited: null, full_ideas: null, ratified: null,
124
+ size: { total: null, paid: null, estimated: null, historical: null },
125
+ alive: { share: null, has_work: null, attributable: null },
126
+ unavailable: true,
127
+ };
128
+ }
129
+
130
+ // readRoleStats — the I/O half. The crafts are decided FIRST, from the ledger,
131
+ // so the heavier per-craft reads run only for a craft that will be shown.
132
+ async function readRoleStats(builderId, { shippedCount = 0, getRoleChoice } = {}) {
133
+ const reward = seams.resolveOptional('reward');
134
+ const [choice, byCraftMap] = await Promise.all([
135
+ getRoleChoice ? getRoleChoice(builderId) : null,
136
+ reward && typeof reward.creditsByCraft === 'function' ? reward.creditsByCraft([builderId]) : null,
137
+ ]);
138
+ const byCraft = byCraftMap ? (byCraftMap.get(String(builderId)) || null) : null;
139
+ const preferred = (choice && choice.preferred_disciplines) || [];
140
+ const { main } = resolveMainRole({ mainDiscipline: choice && choice.main_discipline, preferred, byCraft });
141
+ const shown = shownCrafts({ main, preferred, byCraft });
142
+
143
+ const artistPort = shown.includes('artist') ? seams.resolveOptional('copy-desk.artistStats') : null;
144
+ const ideatorPort = shown.includes('ideator') ? seams.resolveOptional('ideas.ideatorStats') : null;
145
+ const [artist, ideator] = await Promise.all([
146
+ typeof artistPort === 'function' ? artistPort(builderId) : null,
147
+ typeof ideatorPort === 'function' ? ideatorPort(builderId) : null,
148
+ ]);
149
+ return composeRoleStats({ choice, byCraft, shippedCount, artist, ideator });
150
+ }
151
+
152
+ // Only the reader is exported: the pure halves above are reached through it
153
+ // (tests/profile_role_stats.mjs drives them via the real route), and an export
154
+ // nothing imports is dead surface to the knip ratchet.
155
+ module.exports = { readRoleStats };
@@ -33,6 +33,7 @@ const { validateOrRespond, LIMITS, parseId } = require('./_helpers');
33
33
  // for the roster-visible checklist (read/shape) — never a direct require.
34
34
  const { pool } = require('../pool');
35
35
  const { publicReadRateLimitMiddleware } = require('../middleware/rate-limit');
36
+ const { readRoleStats } = require('../role-stats');
36
37
  const seams = require('../../module-seams');
37
38
  const { branding } = require('../../branding');
38
39
  // The project's stale-claim timer (task 1003476, goal 1000072) — kernel config, so a
@@ -503,6 +504,11 @@ module.exports = function buildBuildersRouter() {
503
504
  // monthly_budget_usd / system_role) are NEVER spread into the
504
505
  // JSON. A leak-prevention unit test (tests/profile_route.mjs) asserts their
505
506
  // absence + the 404-on-unknown-id behaviour.
507
+ // `roles` (task 1004433) is the one block that is NOT a re-presentation of the
508
+ // leaderboard: per-craft counts and sums. The owner made them public on
509
+ // 2026-09-30 (RP01 decision (a): the tally and By the numbers stay public, the
510
+ // trail, the earnings and every Activity tab need a sign-in), so the block
511
+ // carries counts only — no idea title, no page wording, no task list.
506
512
  // THROTTLED since task 1002804. The global limiter meters WRITES only
507
513
  // (rateLimitMiddleware returns early for GET) and the public read limiter is
508
514
  // mounted under /public/*, which this is not — so this route answered at line
@@ -535,12 +541,17 @@ module.exports = function buildBuildersRouter() {
535
541
  // the same indistinguishable answer cross_project already gives.
536
542
  // Optional-chained so an older hub provider without the function degrades
537
543
  // to null rather than throwing across a version window.
538
- const [shippedCount, recentShips, achievements, crossProject, handle] = await Promise.all([
539
- lc().countShippedTasksForBuilder(targetId),
544
+ const shippedCount = await lc().countShippedTasksForBuilder(targetId);
545
+ // roles: each craft by its own work (task 1004433 / WA6.RP03) — which
546
+ // crafts show, which leads, and each one's headline numbers. Counts and
547
+ // sums only, the same for every viewer: the owner's decision (a) keeps the
548
+ // tally and By the numbers public and everything under them signed-in.
549
+ const [recentShips, achievements, crossProject, handle, roles] = await Promise.all([
540
550
  lc().recentShippedTasksByBuilder(targetId, 5),
541
551
  reward ? reward.getBuilderAchievements(targetId) : [],
542
552
  hub ? hub.getPublicProfileExtras(b.github_id) : null,
543
553
  hub && hub.getPublicHandle ? hub.getPublicHandle(b.github_id) : null,
554
+ readRoleStats(targetId, { shippedCount, getRoleChoice: db.getBuilderRoleChoice }),
544
555
  ]);
545
556
  // WHITELIST — only the leaderboard-equivalent public field set. Never spread
546
557
  // the raw row: it carries anthropic_email / anthropic_user_id /
@@ -573,6 +584,7 @@ module.exports = function buildBuildersRouter() {
573
584
  shipped_count: shippedCount,
574
585
  recent_ships: recentShips,
575
586
  achievements,
587
+ roles,
576
588
  cross_project: crossProject,
577
589
  handle: handle || null,
578
590
  });
@@ -582,6 +594,7 @@ module.exports = function buildBuildersRouter() {
582
594
  shipped_count: shippedCount,
583
595
  recent_ships: recentShips,
584
596
  achievements,
597
+ roles,
585
598
  });
586
599
  } catch (err) {
587
600
  log.error('[gds] GET /builders/:id/profile', err);
@@ -554,16 +554,24 @@ module.exports = function buildMeRouter() {
554
554
  // profile. 422 invalid_discipline if any element is out of range.
555
555
  // rank: any-builder — self-service profile edit; documented "no rank gate" in the
556
556
  // handler comment above (a builder owns their own preference).
557
+ // `main_discipline` (optional, task 1004433): the builder's own MAIN role, one
558
+ // of the crafts in the same body; null clears it, absent keeps it (see
559
+ // db.updateBuilderDisciplines). 422 invalid_main_discipline otherwise.
557
560
  router.patch('/me/disciplines', auth.requireBuilder, async (req, res) => {
558
561
  if (validateOrRespond(req, res, {
559
562
  preferred_disciplines: { required: true, type: 'array', maxItems: 16, itemsType: 'string' },
563
+ main_discipline: { type: 'string', maxLength: 32 },
560
564
  })) return;
561
565
  const input = req.body?.preferred_disciplines;
562
566
  if (!Array.isArray(input)) {
563
567
  return res.fail('preferred_disciplines must be an array', 400);
564
568
  }
569
+ const body = req.body || {};
570
+ const mainOpt = Object.prototype.hasOwnProperty.call(body, 'main_discipline')
571
+ ? { mainDiscipline: body.main_discipline === null ? null : String(body.main_discipline) }
572
+ : {};
565
573
  try {
566
- const builder = await db.updateBuilderDisciplines(req.builder.id, input);
574
+ const builder = await db.updateBuilderDisciplines(req.builder.id, input, mainOpt);
567
575
  // The onboarding state machine carved into modules/onboarding/ (BV1.R81 /
568
576
  // ADR 0093 §1): resolve the `onboarding` kernel port for the mark + echo —
569
577
  // never a direct require of a module. Default-on (present in prod); degrades
@@ -587,8 +595,8 @@ module.exports = function buildMeRouter() {
587
595
  const onboarding = onboardingPort ? onboardingPort.shapeForApi(state, { shipCount, discordLinked: !!discordLink, cliAuthed: !!cliAuthed }) : null;
588
596
  res.json({ builder, onboarding });
589
597
  } catch (err) {
590
- if (err.code === 'invalid_discipline') {
591
- return res.fail('invalid_discipline', 422, { bad: err.bad });
598
+ if (err.code === 'invalid_discipline' || err.code === 'invalid_main_discipline') {
599
+ return res.fail(err.code, 422, { bad: err.bad });
592
600
  }
593
601
  throw err;
594
602
  }
@@ -111,7 +111,10 @@ function buildGdsRouter() {
111
111
  // 'completed'. The list is NOT trimmable to fit: modules/grading/grader-score.js runs
112
112
  // the server-side permission-path pre-pass against it (SR-9 / SEC #863), so a short
113
113
  // list would quietly weaken a security check rather than merely lose detail.
114
- /^\/tasks\/\d+\/grade$/.test(req.path)
114
+ /^\/tasks\/\d+\/grade$/.test(req.path) ||
115
+ // task 1004421: a project's logo — up to 256 KB decoded, ~342 KB as base64.
116
+ // modules/provisioning/routes/look.js mounts its own 400 KB parser.
117
+ /^\/provisioning\/instances\/\d+\/logo$/.test(req.path)
115
118
  ) return next();
116
119
  return globalJson(req, res, next);
117
120
  });
@@ -43,6 +43,7 @@ const { buildStampedHtml } = require('../asset-version');
43
43
  const { buildInfo } = require('../build-info');
44
44
  const { buildBongosDownloads } = require('../bongos-downloads');
45
45
  const { clientBranding, branding, applyBrandConditionals } = require('../branding');
46
+ const { nightTintCss } = require('../night-tint');
46
47
  const { prelaunchGate } = require('./prelaunch-gate');
47
48
  const { platformVisibilityGate } = require('./platform-visibility-gate');
48
49
  const { schemaPending } = require('./schema-lag');
@@ -121,6 +122,11 @@ function themeMarkup(theme) {
121
122
  if (decls.length) css.push(`:root{${decls.join('')}}`);
122
123
  const backdrop = cssSafe(theme.backdrop);
123
124
  if (backdrop) css.push(`body{background:${backdrop};}`);
125
+ // The night tint (ADR 0358): a hall wearing a look takes a hint of its accent into
126
+ // its dark grounds. Written as the sheet's own two dark twins, after the sheet, so
127
+ // equal specificity wins on order. clientBranding only sets it with a look, so a
128
+ // hall that chose none keeps the sheet's pure black.
129
+ if (theme.nightTint === true) css.push(nightTintCss());
124
130
  if (css.length) out += `<style>${css.join('')}</style>`;
125
131
  // esc() is lossless in an attribute: the HTML parser decodes the entities back
126
132
  // before anything reads the URL, so the neutral pack's inline `data:image/svg+xml,