@bongos/core 1.24.79 → 1.24.80

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 (366) hide show
  1. package/.bongos-core.json +495 -350
  2. package/.claude/skills/ask-for-help/SKILL.md +1 -1
  3. package/.claude/skills/builder-sequence/SKILL.md +1 -1
  4. package/.claude/skills/design/SKILL.md +1 -1
  5. package/.claude/skills/goal-create/SKILL.md +1 -1
  6. package/.claude/skills/planning-session/SKILL.md +1 -1
  7. package/clients/bongos-client/README.md +1 -1
  8. package/clients/bongos-client/bongos-client.global.js +8 -0
  9. package/clients/bongos-client/index.cjs +8 -0
  10. package/clients/bongos-client/index.d.ts +11 -0
  11. package/clients/bongos-client/index.mjs +8 -0
  12. package/docs/adr/0274-one-kernel-three-role-packs.md +2 -2
  13. package/docs/adr/0399-the-visor-is-glass-over-the-scene-and-owns-no-camera.md +91 -0
  14. package/docs/adr/0400-a-chain-reserves-work-and-each-sub-quest-is-still-claimed-alone.md +109 -0
  15. package/docs/adr/0401-one-options-menu-opens-every-settings-screen-in-a-frame.md +53 -0
  16. package/docs/adr/README.md +3 -0
  17. package/docs/api/openapi.json +195 -3
  18. package/docs/api-reference.md +8 -4
  19. package/docs/architecture.md +7 -3
  20. package/docs/copy-inventory.md +390 -370
  21. package/docs/copy-registry.json +719 -518
  22. package/docs/design/characters/character-sprites-source.webp +0 -0
  23. package/docs/file-map.md +7 -8
  24. package/docs/module-api-changelog.md +2 -0
  25. package/docs/modules-contract.md +5 -0
  26. package/docs/onboarding/slash-commands.md +1 -1
  27. package/docs/packs/{engineer.md → artisan.md} +5 -5
  28. package/docs/packs/artist.md +50 -4
  29. package/docs/page-inventory.json +15 -4
  30. package/docs/page-readings.json +3182 -3336
  31. package/docs/{packs/ideator.md → recipes/ideation-playbook.md} +5 -48
  32. package/migrations/core_296_crafts_artisan_artist.sql +72 -0
  33. package/migrations/core_297_builder_character.sql +30 -0
  34. package/modules/copy-desk/proposals.js +1 -1
  35. package/modules/economy/credits-by-craft.js +29 -15
  36. package/modules/hall-ui/public/ai-studio.html +4 -4
  37. package/modules/hall-ui/public/atlas.html +5 -5
  38. package/modules/hall-ui/public/atlas.js +3 -1
  39. package/modules/hall-ui/public/blockers.html +5 -5
  40. package/modules/hall-ui/public/board-room.html +4 -4
  41. package/modules/hall-ui/public/characters/artisan.png +0 -0
  42. package/modules/hall-ui/public/characters/artist.png +0 -0
  43. package/modules/hall-ui/public/characters/patron.png +0 -0
  44. package/modules/hall-ui/public/city.css +2 -1
  45. package/modules/hall-ui/public/city.html +9 -6
  46. package/modules/hall-ui/public/city.js +7 -1
  47. package/modules/hall-ui/public/collab.html +4 -4
  48. package/modules/hall-ui/public/commands.html +4 -4
  49. package/modules/hall-ui/public/copy-desk.html +4 -4
  50. package/modules/hall-ui/public/craft-mode.js +258 -64
  51. package/modules/hall-ui/public/deploy.html +4 -4
  52. package/modules/hall-ui/public/diagram-viewer.js +2 -1
  53. package/modules/hall-ui/public/dom-utils.js +1 -1
  54. package/modules/hall-ui/public/goal-map.html +9 -6
  55. package/modules/hall-ui/public/goals.html +5 -5
  56. package/modules/hall-ui/public/government.html +4 -4
  57. package/modules/hall-ui/public/hall-render.js +3 -1
  58. package/modules/hall-ui/public/home-city-draw.js +3 -3
  59. package/modules/hall-ui/public/home-city.css +4 -0
  60. package/modules/hall-ui/public/home-city.js +17 -9
  61. package/modules/hall-ui/public/home-gallery.js +8 -1
  62. package/modules/hall-ui/public/home-idea-box.css +2 -2
  63. package/modules/hall-ui/public/home-idea-box.js +3 -3
  64. package/modules/hall-ui/public/idea.html +5 -5
  65. package/modules/hall-ui/public/ideas.html +5 -5
  66. package/modules/hall-ui/public/index.html +11 -7
  67. package/modules/hall-ui/public/market-watch-item.html +4 -4
  68. package/modules/hall-ui/public/market-watch-rules.html +4 -4
  69. package/modules/hall-ui/public/market-watch.html +6 -9
  70. package/modules/hall-ui/public/market-watch.js +22 -45
  71. package/modules/hall-ui/public/modules.html +4 -4
  72. package/modules/hall-ui/public/money.html +4 -4
  73. package/modules/hall-ui/public/my-agents.html +4 -4
  74. package/modules/hall-ui/public/options-menu.js +409 -0
  75. package/modules/hall-ui/public/paintbrush.js +3 -3
  76. package/modules/hall-ui/public/palette.js +11 -13
  77. package/modules/hall-ui/public/primer.html +5 -5
  78. package/modules/hall-ui/public/profile-crafts.js +1 -1
  79. package/modules/hall-ui/public/profile-roles.js +23 -19
  80. package/modules/hall-ui/public/profile.css +1 -1
  81. package/modules/hall-ui/public/profile.html +19 -18
  82. package/modules/hall-ui/public/profile.js +19 -18
  83. package/modules/hall-ui/public/profile.states.json +4 -4
  84. package/modules/hall-ui/public/project-settings.html +4 -4
  85. package/modules/hall-ui/public/roster.html +4 -4
  86. package/modules/hall-ui/public/roster.js +3 -3
  87. package/modules/hall-ui/public/settings.css +24 -4
  88. package/modules/hall-ui/public/settings.html +21 -17
  89. package/modules/hall-ui/public/settings.js +44 -44
  90. package/modules/hall-ui/public/shell-game.css +65 -0
  91. package/modules/hall-ui/public/shell-game.js +283 -3
  92. package/modules/hall-ui/public/shell.js +7 -26
  93. package/modules/hall-ui/public/sky-interaction.js +7 -0
  94. package/modules/hall-ui/public/speciality-tools.js +1 -2
  95. package/modules/hall-ui/public/street-inside.js +3 -4
  96. package/modules/hall-ui/public/street.html +4 -4
  97. package/modules/hall-ui/public/studio.html +5 -5
  98. package/modules/hall-ui/public/style.css +73 -47
  99. package/modules/hall-ui/public/task-detail.js +5 -4
  100. package/modules/hall-ui/public/task.html +5 -5
  101. package/modules/hall-ui/public/thinking.css +10 -0
  102. package/modules/hall-ui/public/thinking.html +9 -6
  103. package/modules/hall-ui/public/tweak-editor.html +5 -5
  104. package/modules/hall-ui/public/tweak-editor.js +5 -1
  105. package/modules/hall-ui/public/update-pill.js +91 -2
  106. package/modules/hall-ui/public/visor.css +80 -0
  107. package/modules/hall-ui/public/visor.js +316 -0
  108. package/modules/hall-ui/public/watch-repo-invite.js +159 -0
  109. package/modules/hall-ui/public/watch.html +6 -5
  110. package/modules/hall-ui/public/watch.js +17 -2
  111. package/modules/hall-ui/public/welcome-boot.js +15 -6
  112. package/modules/hall-ui/public/work.html +5 -5
  113. package/modules/hall-ui/records/builders-roster.md +2 -2
  114. package/modules/hall-ui/records/craft-mode.md +13 -11
  115. package/modules/hall-ui/records/game-mode.md +2 -2
  116. package/modules/hall-ui/records/governor-street.md +1 -1
  117. package/modules/hall-ui/records/home-city.md +3 -1
  118. package/modules/hall-ui/records/market-watch-settings.md +2 -0
  119. package/modules/hall-ui/records/options-menu.md +15 -0
  120. package/modules/hall-ui/records/profile-roles.md +11 -11
  121. package/modules/hall-ui/records/sky.md +3 -3
  122. package/modules/hall-ui/records/start-screen.md +21 -0
  123. package/modules/hall-ui/records/tweak-editor.md +1 -1
  124. package/modules/hall-ui/records/update-pill.md +12 -0
  125. package/modules/hall-ui/records/visor.md +23 -0
  126. package/modules/hall-ui/records/welcome-tower.md +5 -5
  127. package/modules/ideas/ideator-stats.js +3 -1
  128. package/modules/ideas/inbox.js +2 -2
  129. package/modules/lifecycle/activity.js +1 -1
  130. package/modules/lifecycle/cascade.js +4 -4
  131. package/modules/lifecycle/goal-spec-task.js +1 -1
  132. package/modules/lifecycle/help-requests.js +19 -15
  133. package/modules/lifecycle/kickoff-checklist.js +3 -3
  134. package/modules/lifecycle/kickoff-seed.js +1 -1
  135. package/modules/lifecycle/migrations/lifecycle_021_crafts_artisan_artist.sql +29 -0
  136. package/modules/lifecycle/mingle.js +4 -4
  137. package/modules/lifecycle/page-tweak-write.js +1 -1
  138. package/modules/lifecycle/routes/task-write-routes.js +3 -1
  139. package/modules/lifecycle/routes/tasks.js +3 -3
  140. package/modules/lifecycle/ship-card.js +2 -3
  141. package/modules/lifecycle/task-classifier.js +39 -29
  142. package/modules/lifecycle/tests/cascade.mjs +7 -7
  143. package/modules/onboarding/founding-mode.js +3 -0
  144. package/modules/onboarding/genesis-home.js +21 -1
  145. package/modules/onboarding/routes/onboarding.js +2 -0
  146. package/modules/page-notes/notes.js +3 -2
  147. package/modules/page-notes/split.js +1 -1
  148. package/modules/platform-identity/craft-rollup.js +35 -7
  149. package/modules/platform-identity/migrations/platform_identity_036_crafts_artisan_artist.sql +45 -0
  150. package/modules/platform-identity/platform-identity.js +2 -2
  151. package/modules/platform-identity/routes/community-leaderboard.js +2 -1
  152. package/modules/platform-identity/routes/community-search.js +2 -1
  153. package/modules/platform-identity/routes/sso.js +2 -1
  154. package/modules/platform-identity/tests/platform-identity.mjs +6 -6
  155. package/modules/public-landing/public/community.html +3 -5
  156. package/modules/public-landing/public/index.html +1 -1
  157. package/modules/public-landing/public/projects.html +1 -1
  158. package/modules/specialities/migrations/specialities_004_crafts_artisan_artist.sql +30 -0
  159. package/modules/specialities/routes/specialities.js +2 -2
  160. package/modules/status-ui/public/status.js +1 -1
  161. package/modules/ui-design/kit/fixtures/community-leaderboard.json +10 -10
  162. package/modules/ui-design/kit/fixtures/community-search.json +2 -2
  163. package/modules/ui-design/kit/fixtures/scouting-recruiter.json +1 -1
  164. package/package-lock.json +2 -2
  165. package/package.json +1 -1
  166. package/release-notes.json +70 -0
  167. package/scripts/bongos/autobongos-loop.js +1 -1
  168. package/scripts/bongos/discipline-modes.json +7 -12
  169. package/scripts/bongos/fitness.js +1 -1
  170. package/scripts/bongos/gen-hall-sitemap.js +7 -7
  171. package/scripts/bongos/grade-smoke.js +2 -2
  172. package/scripts/bongos/init.js +2 -2
  173. package/scripts/bongos/newcomer-floor.config.json +1 -2
  174. package/scripts/bongos/newcomer-floor.js +4 -3
  175. package/scripts/bongos/newcomer-floor.templates.json +10 -12
  176. package/scripts/bongos/newcomer-floor.test.js +47 -38
  177. package/scripts/bongos/role-pack-guard.js +5 -4
  178. package/scripts/bongos/seed-tasks.example.json +3 -3
  179. package/scripts/bongos/setup.js +20 -107
  180. package/scripts/bongos/start.js +2 -2
  181. package/scripts/bongos/task.js +5 -4
  182. package/scripts/bongos/triage.js +1 -1
  183. package/scripts/bongos/worktree.js +16 -4
  184. package/scripts/setup-builder.js +6 -46
  185. package/src/bongos/auth-admission.js +8 -8
  186. package/src/bongos/craft-mode.js +2 -0
  187. package/src/bongos/db.js +39 -1
  188. package/src/bongos/github-repos.js +78 -0
  189. package/src/bongos/module-scope-map.js +22 -0
  190. package/src/bongos/role-stats.js +27 -11
  191. package/src/bongos/routes/github.js +100 -2
  192. package/src/bongos/routes/me.js +33 -0
  193. package/src/bongos/serve-internal.js +26 -9
  194. package/src/crafts.js +66 -0
  195. package/src/module-api.js +6 -1
  196. package/src/module-loader/manifest-schema.js +16 -0
  197. package/src/modules.js +2 -2
  198. package/src/role-responsibilities.js +6 -5
  199. package/tests/acclaim_feed_evidence_db.mjs +1 -1
  200. package/tests/activity_rollup_as_of.mjs +1 -1
  201. package/tests/api_alias_caller_ratchet.mjs +3 -3
  202. package/tests/application_lifecycle.mjs +1 -1
  203. package/tests/artist_review.mjs +1 -1
  204. package/tests/artist_review_goal.mjs +1 -1
  205. package/tests/blocker_hall_live_proof.mjs +1 -1
  206. package/tests/blocker_tasks_named_db.mjs +2 -2
  207. package/tests/canonical_profile_url.mjs +1 -1
  208. package/tests/cap_newcomer_tasks.mjs +13 -13
  209. package/tests/character_creation.mjs +294 -0
  210. package/tests/claim_gate_open_blocker.mjs +2 -2
  211. package/tests/claim_gate_rebase.mjs +6 -6
  212. package/tests/claude_materialize.mjs +1 -1
  213. package/tests/clawback_confirmed_backlog.mjs +3 -3
  214. package/tests/collab_page.mjs +8 -9
  215. package/tests/collab_self_settling.mjs +2 -2
  216. package/tests/community_leaderboard_route.mjs +13 -5
  217. package/tests/community_proof_db.mjs +3 -0
  218. package/tests/community_search_route.mjs +7 -6
  219. package/tests/conductor.mjs +1 -1
  220. package/tests/conductor_main_e2e.mjs +8 -8
  221. package/tests/copy_desk_flags_db.mjs +1 -1
  222. package/tests/copy_desk_page_approve.mjs +2 -2
  223. package/tests/copy_desk_page_claim.mjs +2 -2
  224. package/tests/copy_desk_page_next.mjs +1 -1
  225. package/tests/copy_desk_page_release.mjs +1 -1
  226. package/tests/copy_desk_page_submit.mjs +2 -2
  227. package/tests/core_258_box_session_claims_db.mjs +1 -1
  228. package/tests/craft_mode.mjs +48 -34
  229. package/tests/craft_mode_home.mjs +20 -20
  230. package/tests/craft_mode_looks.mjs +16 -15
  231. package/tests/craft_mode_nav.mjs +31 -29
  232. package/tests/craft_review_mode.mjs +46 -41
  233. package/tests/crafts_vocabulary.mjs +70 -0
  234. package/tests/cross_discipline_priority_direction.mjs +5 -5
  235. package/tests/fitness.mjs +7 -11
  236. package/tests/funded_project_pays_proof_db.mjs +1 -1
  237. package/tests/genesis_home.mjs +21 -1
  238. package/tests/github_chip.mjs +3 -3
  239. package/tests/github_repo_invite.mjs +379 -0
  240. package/tests/goal_criterion_confirm_ui.mjs +1 -1
  241. package/tests/goal_invite_consent.mjs +1 -1
  242. package/tests/goal_map_page.mjs +2 -2
  243. package/tests/goal_sort_staleness.mjs +1 -1
  244. package/tests/goal_spec_task.mjs +1 -1
  245. package/tests/goal_spec_task_db.mjs +3 -3
  246. package/tests/grade_engine.mjs +2 -2
  247. package/tests/grader_no_artifact.mjs +1 -1
  248. package/tests/grader_prompt_fencing.mjs +1 -1
  249. package/tests/grader_prompt_window_bound.mjs +1 -1
  250. package/tests/grader_reader_lens.mjs +2 -2
  251. package/tests/grader_severity_gate.mjs +1 -1
  252. package/tests/grader_unbacked_score.mjs +1 -1
  253. package/tests/hall_approval_queue.mjs +1 -1
  254. package/tests/hall_board_world.mjs +2 -2
  255. package/tests/hall_builders_page_pass.mjs +1 -1
  256. package/tests/hall_city.mjs +1 -1
  257. package/tests/hall_fullscreen.mjs +27 -3
  258. package/tests/hall_kit_charts.mjs +1 -1
  259. package/tests/hall_landing_boot.mjs +1 -1
  260. package/tests/hall_my_agents.mjs +16 -16
  261. package/tests/hall_nav.mjs +22 -15
  262. package/tests/hall_options_menu.mjs +247 -0
  263. package/tests/hall_oversight_world.mjs +1 -1
  264. package/tests/hall_palette.mjs +8 -9
  265. package/tests/hall_profile_world.mjs +3 -3
  266. package/tests/hall_record_world.mjs +2 -2
  267. package/tests/hall_settings_world.mjs +2 -1
  268. package/tests/hall_sitemap.mjs +6 -5
  269. package/tests/hall_start_screen.mjs +244 -0
  270. package/tests/hall_street.mjs +2 -2
  271. package/tests/hall_street_inside.mjs +17 -9
  272. package/tests/hall_studio_world.mjs +3 -3
  273. package/tests/hall_visor.mjs +224 -0
  274. package/tests/help_requests.mjs +47 -15
  275. package/tests/helpers.mjs +4 -4
  276. package/tests/home_city.mjs +6 -4
  277. package/tests/home_city_widget.mjs +33 -14
  278. package/tests/home_gallery_widget.mjs +4 -4
  279. package/tests/home_idea_box.mjs +6 -6
  280. package/tests/hub_community_page.mjs +3 -3
  281. package/tests/hub_craft_rollup.mjs +75 -36
  282. package/tests/hub_recent_ships.mjs +3 -2
  283. package/tests/idea_cascade.mjs +2 -2
  284. package/tests/idea_credit_streams_db.mjs +1 -1
  285. package/tests/idea_detail_page.mjs +1 -1
  286. package/tests/idea_ideate_full.mjs +5 -2
  287. package/tests/idea_spark_hall.mjs +1 -1
  288. package/tests/init.mjs +3 -3
  289. package/tests/instance_manifest.mjs +2 -2
  290. package/tests/kickoff_checklist.mjs +1 -1
  291. package/tests/leaderboard_roles.mjs +34 -25
  292. package/tests/lifecycle_claim_ladder_refs.mjs +55 -0
  293. package/tests/lightbulb.mjs +14 -10
  294. package/tests/main_discipline.mjs +8 -8
  295. package/tests/markdown_line_endings.mjs +1 -1
  296. package/tests/market_watch_pages.mjs +52 -74
  297. package/tests/me_streak_leaderboard_queries.mjs +3 -3
  298. package/tests/migration_allocation.mjs +1 -1
  299. package/tests/mingle.mjs +36 -21
  300. package/tests/module-scope-map.mjs +22 -2
  301. package/tests/module_api.mjs +1 -0
  302. package/tests/module_contributions.mjs +13 -13
  303. package/tests/module_manifest.mjs +29 -0
  304. package/tests/module_skills_hook.mjs +2 -2
  305. package/tests/modules.mjs +14 -7
  306. package/tests/nav_permission_atoms.mjs +2 -1
  307. package/tests/needs_rebase.mjs +1 -1
  308. package/tests/newcomer_restock.mjs +13 -13
  309. package/tests/page_notes_panel.mjs +22 -22
  310. package/tests/page_tweak_no_block.mjs +1 -1
  311. package/tests/paintbrush.mjs +7 -7
  312. package/tests/privacy_matrix.mjs +1 -0
  313. package/tests/profile_activity.mjs +20 -23
  314. package/tests/profile_earnings.mjs +1 -1
  315. package/tests/profile_nudge_prefs.mjs +4 -4
  316. package/tests/profile_role_headline.mjs +73 -46
  317. package/tests/profile_role_stats.mjs +55 -45
  318. package/tests/profile_rollup_consent.mjs +19 -6
  319. package/tests/profile_route.mjs +12 -12
  320. package/tests/profile_ui_cross_project.mjs +4 -4
  321. package/tests/project_invite_suggestions.mjs +2 -2
  322. package/tests/promote_goal_id.mjs +1 -1
  323. package/tests/publish_manifest.mjs +1 -1
  324. package/tests/rank_floor_wiring.mjs +1 -1
  325. package/tests/regrade_actual_minutes.mjs +1 -1
  326. package/tests/removal_proposal.mjs +2 -2
  327. package/tests/reward_gate.mjs +4 -4
  328. package/tests/reward_mode_cost_plus_only.mjs +1 -1
  329. package/tests/role_responsibilities.mjs +18 -12
  330. package/tests/role_stats_db.mjs +17 -16
  331. package/tests/scouting_rollup_fanout.mjs +5 -5
  332. package/tests/seed_tasks.mjs +5 -2
  333. package/tests/session_earnings_mirror_db.mjs +1 -1
  334. package/tests/session_reward_on_land_db.mjs +1 -1
  335. package/tests/session_reward_reconcile_db.mjs +2 -2
  336. package/tests/sessions_kit_pager.mjs +1 -1
  337. package/tests/settings_art_key_absent.mjs +2 -2
  338. package/tests/settings_main_role.mjs +21 -20
  339. package/tests/setup_flow.mjs +3 -3
  340. package/tests/setup_smoke.mjs +20 -0
  341. package/tests/ship_card.mjs +6 -6
  342. package/tests/specialities.mjs +16 -4
  343. package/tests/speciality_db_shape.mjs +6 -6
  344. package/tests/speciality_injection.mjs +17 -17
  345. package/tests/speciality_offer_ui.mjs +5 -5
  346. package/tests/speciality_offered.mjs +6 -5
  347. package/tests/speciality_session_skills.mjs +7 -7
  348. package/tests/speciality_skills.mjs +11 -11
  349. package/tests/speciality_walkthrough_ui.mjs +2 -2
  350. package/tests/sso_checkin_identity_spoof.mjs +2 -1
  351. package/tests/task_classifier_vocab.mjs +10 -0
  352. package/tests/task_cli.mjs +11 -3
  353. package/tests/task_criteria_unlink_db.mjs +2 -2
  354. package/tests/task_discipline_required.mjs +38 -16
  355. package/tests/task_goal_required.mjs +4 -4
  356. package/tests/task_needs_db.mjs +1 -1
  357. package/tests/tweak_replaces_artist_review.mjs +1 -1
  358. package/tests/update_pill.mjs +153 -2
  359. package/tests/upkeep_home.mjs +1 -1
  360. package/tests/v3_c1_options_proof.mjs +145 -0
  361. package/tests/visual_review.mjs +3 -3
  362. package/tests/watch_applications_queue.mjs +8 -5
  363. package/tests/watch_sealed_floor.mjs +3 -1
  364. package/tests/welcome_boot.mjs +12 -9
  365. package/tests/welcome_boot_browser.mjs +13 -13
  366. package/tests/worktree_helper.mjs +16 -1
@@ -21,9 +21,10 @@ const model = gen.build();
21
21
  test('the parse found the hall, so nothing below passes against an empty map', () => {
22
22
  const files = fs.readdirSync(path.join(ROOT, 'modules/hall-ui/public')).filter((f) => f.endsWith('.html'));
23
23
  assert.equal(model.pages.length, files.length, 'one row per page file');
24
- // 24 since task 1005051 folded the Roadmap into Goals.
25
- assert.ok(model.nav.length >= 24, `expected the shell's nav, found ${model.nav.length} items`);
26
- for (const c of ['building', 'artistry', 'ideating', 'governing']) {
24
+ // 20 since task 1005144 moved four settings pages into the options menu.
25
+ assert.ok(model.nav.length >= 20, `expected the shell's nav, found ${model.nav.length} items`);
26
+ // task 1005141: three crafts — Artisan, Artist (Artistry + Ideating), Patron
27
+ for (const c of ['artisan', 'artist', 'patron']) {
27
28
  assert.ok(model.crafts.tabs[c].length > model.crafts.always.length, `${c} lists tabs of its own`);
28
29
  }
29
30
  assert.ok(model.chrome.includes('shell.js') && model.chrome.includes('craft-mode.js'), 'the chrome is the shell');
@@ -87,10 +88,10 @@ test('the Primer links to Repo Atlas and Commands, and in the hall they open tho
87
88
  assert.equal(gen.classify('/no-such-page', urls), false);
88
89
  });
89
90
 
90
- test('the street is Governing\'s home: no nav item, reached by clicking from the city', () => {
91
+ test('the street is Patron\'s home: no nav item, reached by clicking from the city', () => {
91
92
  const street = model.pages.find((p) => p.url === '/street');
92
93
  assert.ok(street, 'the street is a hall page');
93
94
  assert.deepEqual(street.nav, []);
94
- assert.ok(street.reachIn.includes('governing'));
95
+ assert.ok(street.reachIn.includes('patron'));
95
96
  assert.ok(street.in.includes('/city'), 'the city links to the street');
96
97
  });
@@ -0,0 +1,244 @@
1
+ // tests/hall_start_screen.mjs — the game's start screen and load screen (task 1005143, BONGOS-V3 R03).
2
+ //
3
+ // WHY. Done-when: a sign-in passes through both before the world. That is a chain of hand-offs a
4
+ // reader would only notice breaking as "the hall looks like it used to": the welcome hands a fresh
5
+ // sign-in to shell-game.js, Start turns game mode on, the load screen builds the city week by week
6
+ // from the one bounded city read, and the craft picker waits until the world is showing. So this
7
+ // RUNS the real welcome-boot.js and shell-game.js: the hand-off and the pure parts in a vm, and
8
+ // the two screens in a real browser over Home's own markup (skipped where there is no browser).
9
+ //
10
+ // Run: node tests/hall_start_screen.mjs
11
+ import assert from 'node:assert/strict';
12
+ import { test, after } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import vm from 'node:vm';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
22
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
23
+ const CITY = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'home__city.json'), 'utf8');
24
+
25
+ // ---------- in a vm: the pure parts and the welcome's hand-off ----------
26
+ function sandbox({ store = {}, game = true } = {}) {
27
+ const started = [], asked = [];
28
+ const window = {
29
+ location: { search: '', pathname: '/', hash: '' },
30
+ __BRANDING__: { identity: { worldName: 'Harbor' } },
31
+ localStorage: {
32
+ getItem: (k) => (k in store ? store[k] : null),
33
+ setItem: (k, v) => { store[k] = String(v); },
34
+ removeItem: (k) => { delete store[k]; },
35
+ },
36
+ sessionStorage: { getItem: () => null, setItem() {} },
37
+ OTBCraftMode: { markAsked: () => asked.push('marked'), onAccess: (a, b) => asked.push(['picker', a.authed, b.id]) },
38
+ };
39
+ const document = { documentElement: { dataset: {}, classList: { add() {}, remove() {} } }, addEventListener() {} };
40
+ const box = { window, document, URLSearchParams, setTimeout, clearTimeout, console };
41
+ vm.createContext(box);
42
+ if (game) {
43
+ vm.runInContext(read('shell-game.js'), box, { filename: 'shell-game.js' });
44
+ window.OTBShellGame.start = (o) => { started.push(o); return true; };
45
+ }
46
+ vm.runInContext(read('welcome-boot.js'), box, { filename: 'welcome-boot.js' });
47
+ return { window, store, started, asked };
48
+ }
49
+
50
+ test('the menu is Start, Options, Achievements, Extras, Quit, in that order', () => {
51
+ const { window } = sandbox();
52
+ const MENU = JSON.parse(JSON.stringify(window.OTBShellGame.MENU)); // out of the vm's realm
53
+ assert.deepEqual(MENU.map((m) => m.label), ['Start', 'Options', 'Achievements', 'Extras', 'Quit']);
54
+ const links = MENU.filter((m) => m.href).map((m) => [m.id, m.href]);
55
+ assert.deepEqual(links, [['options', '/settings'], ['achievements', '/profile']]);
56
+ for (const [, href] of links) assert.ok(fs.existsSync(path.join(HALL, href.slice(1) + '.html')), href + ' is a hall page');
57
+ });
58
+
59
+ test('the load screen walks the weeks oldest first and lands exactly on now', () => {
60
+ const { window } = sandbox();
61
+ const G = window.OTBShellGame;
62
+ const stops = Array.from(G.historySteps(10, 30));
63
+ assert.equal(stops.length, 31);
64
+ assert.equal(stops[0], 0);
65
+ assert.equal(stops[30], 9, 'the last stop is the latest week, not a rounding short of it');
66
+ assert.ok(stops.every((v, i) => i === 0 || v >= stops[i - 1]), 'never backwards');
67
+ assert.deepEqual(Array.from(G.historySteps(1, 30)).filter((v) => v !== 0), [], 'a one-week project stands still');
68
+ assert.equal(G.whenLabel(10, 0), 'The city, 9 weeks ago');
69
+ assert.equal(G.whenLabel(10, 8), 'The city, 1 week ago');
70
+ assert.equal(G.whenLabel(10, 9), 'The city, now');
71
+ });
72
+
73
+ test('the tips rotate, and every one is a sentence', () => {
74
+ const { window } = sandbox();
75
+ const G = window.OTBShellGame;
76
+ const n = G.TIPS.length;
77
+ assert.ok(n >= 3);
78
+ assert.equal(G.tipAt(0), G.TIPS[0]);
79
+ assert.equal(G.tipAt(n), G.TIPS[0], 'wraps');
80
+ assert.equal(G.tipAt(n + 1), G.TIPS[1]);
81
+ assert.equal(G.tipAt('garbage'), G.TIPS[0]);
82
+ for (const t of G.TIPS) assert.match(t, /^[A-Z].*\.$/);
83
+ });
84
+
85
+ test('a fresh sign-in is handed to the start screen with the still, and the craft picker waits for the world', () => {
86
+ const s = sandbox({ store: { 'hall-welcome-signin': '1' } });
87
+ const access = { authed: true }, builder = { id: 7, display_name: 'Ada' };
88
+ assert.equal(s.window.OTBWelcome.onAccess(access, builder), true, 'the shell is told a screen is up, so it asks no picker');
89
+ assert.equal(s.started.length, 1);
90
+ assert.equal(s.started[0].name, 'Harbor');
91
+ assert.match(s.started[0].art, /^\/builders\/welcome-(day|night)\.webp$/);
92
+ assert.equal(s.store['hall-welcome-signin'], undefined, 'the flag is spent');
93
+ assert.deepEqual(s.asked, [], 'nothing is asked while the start screen is up');
94
+ s.started[0].done();
95
+ assert.deepEqual(s.asked, [['picker', true, 7]], 'the picker is asked once the world is showing');
96
+ });
97
+
98
+ test('a visit that is not a fresh sign-in keeps the welcome, and a hall without shell-game.js greets a sign-in as before', () => {
99
+ const daily = sandbox();
100
+ assert.equal(daily.window.OTBWelcome.onAccess({ authed: true }, { id: 7 }), true);
101
+ assert.deepEqual([daily.started.length, daily.asked], [0, ['marked']], 'the day\'s first visit is the welcome\'s');
102
+ const bare = sandbox({ store: { 'hall-welcome-signin': '1' }, game: false });
103
+ assert.equal(bare.window.OTBWelcome.onAccess({ authed: true }, { id: 7 }), true);
104
+ assert.deepEqual(bare.asked, ['marked']);
105
+ });
106
+
107
+ test('Home loads the sheet and the script, and the sheet types in no size, corner or breakpoint', () => {
108
+ const html = read('index.html');
109
+ assert.match(html, /<link rel="stylesheet" href="\/builders\/shell-game\.css[^"]*">/);
110
+ assert.ok(html.indexOf('welcome-boot.js') < html.indexOf('shell.js?'), 'the welcome, which hands over, loads before the shell');
111
+ const css = read('shell-game.css').replace(/\/\*[\s\S]*?\*\//g, '');
112
+ assert.doesNotMatch(css, /font-size:\s*[\d.]/);
113
+ assert.doesNotMatch(css, /border-radius:\s*[\d.]/);
114
+ assert.doesNotMatch(css, /@media/);
115
+ assert.doesNotMatch(css, /#[0-9a-fA-F]{3,8}\b/, 'tokens, or black and white: no private palette');
116
+ });
117
+
118
+ // ---------- in a browser: the two screens over Home's own markup ----------
119
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
120
+ let browser = null;
121
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
122
+ after(() => browser && browser.close());
123
+ const skip = !browser && 'no system Edge or Chrome';
124
+
125
+ const ORIGIN = 'http://hall.test';
126
+ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.webp': 'image/webp' };
127
+ const INDEX = read('index.html').replace(/<script\b[^>]*>[\s\S]*?<\/script>/g, '').replace(/\{\{[^}]*\}\}/g, '');
128
+ const STUB = '<script>window.__BRANDING__ = { identity: { worldName: "Harbor" } }; window.BongosClient = { createClient: () => ({ request: async () => ({ ok: false, status: 404, data: null }) }) };</script>';
129
+ const SCRIPTS = ['dom-utils.js', 'welcome-boot.js', 'shell-game.js', 'shell-trail.js', 'craft-mode.js', 'shell.js', 'city-draw.js', 'home-city-draw.js']
130
+ .map((f) => `<script src="/builders/${f}"></script>`).join('');
131
+ const PAGE = INDEX.replace('</body>', STUB + SCRIPTS + '</body>');
132
+
133
+ async function hall({ city = true, reducedMotion = 'no-preference' } = {}) {
134
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 }, reducedMotion });
135
+ await ctx.route(ORIGIN + '/**', (route) => {
136
+ const p = new URL(route.request().url()).pathname;
137
+ if (p === '/' || p === '/index.html') return route.fulfill({ contentType: 'text/html', body: PAGE });
138
+ if (p === '/api/bongos/home/city') return city ? route.fulfill({ contentType: 'application/json', body: CITY }) : route.fulfill({ status: 500, body: '' });
139
+ const ext = path.extname(p);
140
+ const f = path.join(HALL, p.replace(/^\/builders\//, ''));
141
+ if (p.startsWith('/builders/') && TYPES[ext] && fs.existsSync(f)) return route.fulfill({ contentType: TYPES[ext], body: fs.readFileSync(f) });
142
+ return route.fulfill({ status: 404, body: '' });
143
+ });
144
+ return ctx;
145
+ }
146
+ // A page that has just come back from signing in: the flag the sign-in click left, then /me.
147
+ async function signedIn(ctx) {
148
+ const page = await ctx.newPage();
149
+ const errors = [];
150
+ page.on('pageerror', (e) => errors.push(String(e.message)));
151
+ await page.addInitScript(() => { try { localStorage.setItem('hall-welcome-signin', '1'); } catch (_) { /* no storage */ } });
152
+ await page.goto(ORIGIN + '/');
153
+ await page.waitForFunction(() => !!window.OTBShell && !!window.OTBWelcome && !!window.OTBShellGame);
154
+ await page.evaluate(() => {
155
+ const me = { authed: true, rank: 'metic', builder: { id: 7, display_name: 'Ada', github_login: 'ada' } };
156
+ window.OTBShell.applyAccess(me);
157
+ window.OTBShell.applyAccess(me);
158
+ });
159
+ return { page, errors };
160
+ }
161
+ const state = (page) => page.evaluate(() => ({
162
+ start: !!document.getElementById('game-start'),
163
+ welcome: !!document.getElementById('welcome-boot'),
164
+ picker: !!document.getElementById('craft-picker'),
165
+ game: document.documentElement.dataset.game || null,
166
+ full: document.documentElement.dataset.fullscreen || null,
167
+ }));
168
+
169
+ test('a sign-in passes through the start screen and the load screen before the world', { skip }, async () => {
170
+ const ctx = await hall();
171
+ const { page, errors } = await signedIn(ctx);
172
+ await page.waitForSelector('#game-start');
173
+ assert.deepEqual(await state(page), { start: true, welcome: false, picker: false, game: null, full: null },
174
+ 'the start screen, not the welcome and not the picker; game mode is not on until Start');
175
+ const menu = await page.evaluate(() => ({
176
+ title: document.getElementById('gs-title').textContent,
177
+ items: Array.from(document.querySelectorAll('.gs__items:not(.gs__items--extras) .gs__item')).map((e) => e.textContent),
178
+ focus: document.activeElement.textContent,
179
+ small: Array.from(document.querySelectorAll('.gs__items:not(.gs__items--extras) .gs__item')).filter((e) => e.getBoundingClientRect().height < 46).length,
180
+ }));
181
+ assert.deepEqual(menu, { title: 'Harbor', items: ['Start', 'Options', 'Achievements', 'Extras', 'Quit'], focus: 'Start', small: 0 });
182
+
183
+ // The arrows walk the menu; Escape stays with the screen and does not flip the shell underneath.
184
+ await page.keyboard.press('ArrowDown');
185
+ assert.equal(await page.evaluate(() => document.activeElement.textContent), 'Options');
186
+ await page.keyboard.press('ArrowUp');
187
+ await page.keyboard.press('Escape');
188
+ await page.evaluate(() => new Promise((r) => setTimeout(r, 50)));
189
+ assert.equal((await state(page)).start, true, 'Escape does not dismiss the start screen');
190
+
191
+ // Extras opens its links; Escape folds them away again.
192
+ await page.click('[data-gs="extras"]');
193
+ assert.equal(await page.isVisible('#gs-extras a'), true);
194
+ await page.keyboard.press('Escape');
195
+ assert.equal(await page.isVisible('#gs-extras a'), false);
196
+
197
+ // Start: game mode on, the load screen with a tip, and the city building up to now.
198
+ await page.click('[data-gs="start"]');
199
+ await page.waitForSelector('#gs-load:not([hidden])');
200
+ assert.equal(await page.isVisible('.gs__menu'), false);
201
+ assert.match(await page.textContent('#gs-tip'), /\.$/);
202
+ await page.waitForFunction(() => /weeks? ago$/.test(document.getElementById('gs-when').textContent));
203
+ assert.ok(await page.evaluate(() => document.querySelectorAll('#gs-city *').length > 20), 'the city is drawn, not a placeholder');
204
+ await page.waitForFunction(() => { const w = document.getElementById('gs-when'); return !w || w.textContent === 'The city, now'; });
205
+ await page.waitForSelector('#game-start', { state: 'detached' });
206
+ await page.waitForSelector('#craft-picker');
207
+ assert.deepEqual(await state(page), { start: false, welcome: false, picker: true, game: 'on', full: 'on' },
208
+ 'the world: game mode on, the bars gone, and only now the craft picker');
209
+ assert.equal(await page.evaluate(() => localStorage.getItem('hall-game')), 'on');
210
+ assert.deepEqual(errors, []);
211
+ await ctx.close();
212
+ });
213
+
214
+ test('Quit closes the tab, and says so when the browser keeps it open', { skip }, async () => {
215
+ const ctx = await hall();
216
+ const { page } = await signedIn(ctx);
217
+ await page.waitForSelector('#game-start');
218
+ await page.evaluate(() => { window.__closed = 0; window.close = () => { window.__closed += 1; }; });
219
+ await page.click('[data-gs="quit"]');
220
+ await page.waitForSelector('#gs-note:not([hidden])');
221
+ assert.equal(await page.evaluate(() => window.__closed), 1);
222
+ assert.match(await page.textContent('#gs-note'), /Close it to quit/);
223
+ await ctx.close();
224
+ });
225
+
226
+ test('with no city to draw, or with reduced motion, the still and the tip stand and the world still opens', { skip }, async () => {
227
+ for (const opts of [{ city: false }, { reducedMotion: 'reduce' }]) {
228
+ const ctx = await hall(opts);
229
+ const { page, errors } = await signedIn(ctx);
230
+ await page.waitForSelector('#game-start');
231
+ await page.click('[data-gs="start"]');
232
+ await page.waitForSelector('#gs-load:not([hidden])');
233
+ assert.match(await page.textContent('#gs-tip'), /\.$/);
234
+ if (opts.reducedMotion) {
235
+ await page.waitForFunction(() => { const w = document.getElementById('gs-when'); return !w || !w.hidden; });
236
+ assert.equal(await page.evaluate(() => { const w = document.getElementById('gs-when'); return w ? w.textContent : 'The city, now'; }), 'The city, now',
237
+ 'no week-by-week run: the finished city at once');
238
+ }
239
+ await page.waitForSelector('#game-start', { state: 'detached' });
240
+ assert.equal((await state(page)).game, 'on');
241
+ assert.deepEqual(errors, []);
242
+ await ctx.close();
243
+ }
244
+ });
@@ -122,11 +122,11 @@ test('a task not started is the prototype\'s workItem "ready" slab, and the more
122
122
  const p = new Proto({ mode: 'dark' });
123
123
  const T = p.theme('dark');
124
124
  const a = []; const b = [];
125
- p.workItem(a, T, 'engineer', 'ready', 660, 19, 0);
125
+ p.workItem(a, T, 'artisan', 'ready', 660, 19, 0);
126
126
  S.work(b, S.theme('dark'), 'slab', 660, 0, 19, 6, false);
127
127
  assert.deepEqual(b, cityTokens(a), 'the slab, at a base of 0 as workItem draws it');
128
128
  const c = []; const d = [];
129
- p.workItem(c, T, 'engineer', 'backlog', 660, 40, 0);
129
+ p.workItem(c, T, 'artisan', 'backlog', 660, 40, 0);
130
130
  S.work(d, S.theme('dark'), 'stakes', 660, 0, 40, 0, false);
131
131
  assert.deepEqual(d, c, 'the stakes');
132
132
  });
@@ -131,7 +131,15 @@ test('every district is the prototype\'s road of work, both modes', () => {
131
131
  const p = proto(mode, { view: key, ...st });
132
132
  const want = p.districtView(p.theme(mode), key);
133
133
  const got = I.districtView(S.theme(mode), key, p.districtInfo(key));
134
- for (const k of ['kicker', 'name', 'line', 'road', 'ground', 'tx', 'ty', 's', 'shapes', 'legend']) assert.deepEqual(j(got[k]), j(want[k]), `${mode} ${key} ${k}`);
134
+ for (const k of ['kicker', 'name', 'line', 'road', 'ground', 'tx', 'ty', 's', 'shapes']) assert.deepEqual(j(got[k]), j(want[k]), `${mode} ${key} ${k}`);
135
+ // The legend's craft line is the one place the hall leaves the prototype's words
136
+ // (task 1005141): the engineer is the artisan, and the ideator — folded into the
137
+ // artist — no longer has a pavilion of its own. The status line is unchanged.
138
+ assert.deepEqual(j(got.legend), [
139
+ { key: 'a', text: 'Shape is the craft: tower for artisan, studio roof for artist, glass front for UI.' },
140
+ j(want.legend[1]),
141
+ ], `${mode} ${key} legend`);
142
+ assert.equal(want.legend[0].key, 'a', 'the prototype still has the craft line first');
135
143
  assert.deepEqual(got.items.map((i) => [i.x, i.y, i.w, i.name, i.meta, i.stage, i.stageColor, i.stageEdge]), want.items.map((i) => [i.x, i.y, i.w, i.name, i.meta, i.stage, i.stageColor, i.stageEdge]), `${mode} ${key} items`);
136
144
  }
137
145
  }
@@ -142,7 +150,7 @@ test('workItem and crown are verbatim: every craft at every stage, every crown',
142
150
  for (const mode of ['dark', 'light']) {
143
151
  const p = proto(mode);
144
152
  const T = p.theme(mode);
145
- for (const craft of ['engineer', 'artist', 'ui', 'ideator']) {
153
+ for (const craft of ['artisan', 'artist', 'ui']) {
146
154
  for (const stage of ['backlog', 'ready', 'being built', 'shipped', 'being graded', 'awaiting a tester']) {
147
155
  const a = []; const b = [];
148
156
  p.workItem(a, T, craft, stage, 86, 40, 92);
@@ -418,16 +426,16 @@ test('the lift runs to the chosen floor, and the chosen block flips on every pic
418
426
 
419
427
  test('a district is its own goal\'s tasks: shape from the craft, stage from the status, label the kind', () => {
420
428
  const t = (id, status, discipline, kind = 'feature', goal_id = '7', extra = {}) => ({ id: String(id), title: `task ${id}`, status, discipline, kind, goal_id, est_minutes: 120, ...extra });
421
- const tasks = [t(1, 'shipped', 'engineer'), t(2, 'ready', 'artist'), t(3, 'active', 'ui'), t(4, 'confirmed', 'ideator', 'spike'), t(5, 'backlog', null, 'bug'), t(6, 'blocked', 'engineer'), t(7, 'completed', 'artist'), t(8, 'cut', 'engineer'), t(9, 'shipped', 'ui', 'feature', '8')];
429
+ const tasks = [t(1, 'shipped', 'artisan'), t(2, 'ready', 'artist'), t(3, 'active', 'ui'), t(4, 'confirmed', 'artist', 'spike'), t(5, 'backlog', null, 'bug'), t(6, 'blocked', 'artisan'), t(7, 'completed', 'artist'), t(8, 'cut', 'artisan'), t(9, 'shipped', 'ui', 'feature', '8')];
422
430
  const d = I.districtInfo('7', goal(7), tasks);
423
431
  assert.deepEqual(d.items.map((i) => [i[0], i[1], i[2], i[3], i[5]]), [
424
- ['task 1', 'feature', 'engineer', 'shipped', 'shipped'],
425
- ['task 4', 'spike', 'ideator', 'graded · landing', 'graded · landing'],
432
+ ['task 1', 'feature', 'artisan', 'shipped', 'shipped'],
433
+ ['task 4', 'spike', 'artist', 'graded · landing', 'graded · landing'],
426
434
  ['task 7', 'feature', 'artist', 'being graded', 'being graded'],
427
435
  ['task 3', 'feature', 'ui', 'being built', 'being built'],
428
436
  ['task 2', 'feature', 'artist', 'ready', 'ready'],
429
- ['task 6', 'feature', 'engineer', 'backlog', 'blocked'],
430
- ['task 5', 'bug', 'engineer', 'backlog', 'backlog'],
437
+ ['task 6', 'feature', 'artisan', 'backlog', 'blocked'],
438
+ ['task 5', 'bug', 'artisan', 'backlog', 'backlog'],
431
439
  ], 'in the order work moves; a cut task and another goal\'s are not drawn; no craft is a tower');
432
440
  assert.equal(d.items[0][4], 56, 'height from the estimate, never a random');
433
441
  assert.equal(d.items[4][4], 0, 'a slab or a stake has no height');
@@ -439,7 +447,7 @@ test('a district is its own goal\'s tasks: shape from the craft, stage from the
439
447
  });
440
448
 
441
449
  test('past ten pieces of work the road keeps the ones in flight and counts the rest', () => {
442
- const tasks = Array.from({ length: 30 }, (_, i) => ({ id: String(i + 1), title: `t${i + 1}`, status: i < 3 ? 'active' : i < 25 ? 'shipped' : 'backlog', discipline: 'engineer', kind: 'feature', goal_id: '9', shipped_at: `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T00:00:00Z` }));
450
+ const tasks = Array.from({ length: 30 }, (_, i) => ({ id: String(i + 1), title: `t${i + 1}`, status: i < 3 ? 'active' : i < 25 ? 'shipped' : 'backlog', discipline: 'artisan', kind: 'feature', goal_id: '9', shipped_at: `2026-09-${String(1 + (i % 28)).padStart(2, '0')}T00:00:00Z` }));
443
451
  const d = I.districtInfo('9', goal(9), tasks);
444
452
  assert.equal(d.items.length, I.MAX_ITEMS);
445
453
  assert.equal(d.more, 20);
@@ -636,7 +644,7 @@ test('a matter is a desk row: its name opens it in place; back to the street ret
636
644
  });
637
645
 
638
646
  test('a district address reads its goal\'s tasks once and draws them as buildings', async () => {
639
- const tasks = [{ id: '1', title: 'checkout', status: 'shipped', discipline: 'engineer', kind: 'feature', goal_id: '7' }, { id: '2', title: 'tax forms', status: 'active', discipline: 'ui', kind: 'feature', goal_id: '7' }, { id: '3', title: 'other goal', status: 'ready', goal_id: '8' }];
647
+ const tasks = [{ id: '1', title: 'checkout', status: 'shipped', discipline: 'artisan', kind: 'feature', goal_id: '7' }, { id: '2', title: 'tax forms', status: 'active', discipline: 'ui', kind: 'feature', goal_id: '7' }, { id: '3', title: 'other goal', status: 'ready', goal_id: '8' }];
640
648
  const p = await bootPage({ hash: '#district=7', feeds: FEEDS([], { '/api/bongos/tasks?goal_id=7': { tasks } }) });
641
649
  await new Promise((r) => setTimeout(r, 0));
642
650
  assert.equal(p.calls.filter((c) => c === '/api/bongos/tasks?goal_id=7').length, 1);
@@ -277,7 +277,7 @@ async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags =
277
277
  // the same reason categoryChip is: an undefined helper throws mid-render.
278
278
  roleAxisLabel: () => 'Role',
279
279
  roleLabel: (d) => (!d ? '' : d === 'unclassified' ? 'No role yet' : d === 'ui' ? 'UI' : d.charAt(0).toUpperCase() + d.slice(1)),
280
- roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
280
+ roleOptions: () => [{ value: '', label: '— any —' }, { value: 'artisan', label: 'Artisan' }, { value: 'artist', label: 'Artist' }],
281
281
  roleIsUnset: (d) => !d || d === 'unclassified',
282
282
  ROLE_UNSET: 'unclassified',
283
283
  escapeHtml: (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c])),
@@ -592,9 +592,9 @@ function bootShell() {
592
592
  test('the Studio nav entry is warmed only for an artist-craft builder', () => {
593
593
  const { applyAccess, navStudio } = bootShell();
594
594
 
595
- applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Artist A', preferred_disciplines: ['artist', 'ideator'] } });
595
+ applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Artist A', preferred_disciplines: ['artist'] } });
596
596
  assert.ok(navStudio()._classes.has('nav-item--craft'), 'an artist-craft builder gets the warmed entry');
597
597
 
598
- applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Eng E', preferred_disciplines: ['engineer'] } });
598
+ applyAccess({ authed: true, rank: 'archon', builder: { display_name: 'Eng E', preferred_disciplines: ['artisan'] } });
599
599
  assert.ok(!navStudio()._classes.has('nav-item--craft'), 'and a non-artist builder does not (the class is toggled off)');
600
600
  });
@@ -0,0 +1,224 @@
1
+ // tests/hall_visor.mjs — the first-person visor (task 1005148, BONGOS-V3 R08; ADR 0399).
2
+ //
3
+ // The checks ADR 0399 §4 names, driven in a real browser over a stand-in scene that
4
+ // behaves like the sky (a canvas that pans on drag, a bubble Escape listener that
5
+ // unpicks and calls preventDefault only when it closed something):
6
+ // - the glass passes pointers through — a drag on the world with the visor up pans it;
7
+ // - an open sheet's Escape leaves the star picked;
8
+ // - in game mode the last Escape reaches the options menu, never the visor.
9
+ // Plus the criterion's own proof half that is the visor's: an Artisan and an Artist
10
+ // are offered DIFFERENT actions on the glass. The pure parts run in a vm.
11
+ import assert from 'node:assert/strict';
12
+ import { test, after } from 'node:test';
13
+ import { createRequire } from 'node:module';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import vm from 'node:vm';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
22
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
23
+ const VISOR = read('visor.js');
24
+
25
+ // ---------- the pure parts, in a vm ----------
26
+ function loadPure() {
27
+ const listeners = {};
28
+ const doc = {
29
+ readyState: 'complete', body: null, documentElement: { dataset: {}, classList: { contains: () => false, toggle() {}, remove() {} } },
30
+ addEventListener(ev, fn) { listeners[ev] = fn; }, querySelector: () => null, createElement: () => ({}),
31
+ };
32
+ const win = { document: doc, location: { pathname: '/', assign() {} }, addEventListener() {} };
33
+ win.window = win;
34
+ vm.runInNewContext(VISOR, win);
35
+ return win.OTBVisor;
36
+ }
37
+
38
+ test('each character is offered its own actions, and the Artisan\'s and the Artist\'s differ', () => {
39
+ const V = loadPure();
40
+ const ids = (c) => JSON.parse(JSON.stringify(V.actionsFor(c).map((a) => a.id))); // out of the vm's realm
41
+ assert.deepEqual(ids('artisan'), ['claim', 'blockers', 'sky', 'thinking']);
42
+ assert.deepEqual(ids('artist'), ['review', 'words', 'idea', 'studio']);
43
+ assert.deepEqual(ids('patron'), ['board', 'city', 'blockers', 'watch']);
44
+ assert.equal(ids('artisan').filter((id) => ids('artist').includes(id)).length, 0, 'the two glasses share no action');
45
+ assert.equal(V.actionsFor('nobody').length, 0, 'an unknown character gets nothing, not a default');
46
+ for (const c of ['artisan', 'artist', 'patron']) {
47
+ const keys = V.actionsFor(c).map((a) => a.key);
48
+ assert.equal(new Set(keys).size, keys.length, `${c}: one key per action`);
49
+ assert.ok(!keys.includes('a'), `${c}: A is the sheet's`);
50
+ for (const k of keys) assert.ok(!['l', '0', '/', '?', 'f', 'k'].includes(k), `${c}: ${k} is not a key a scene already binds`);
51
+ for (const a of V.actionsFor(c)) assert.ok(a.href || a.tool, `${c}: ${a.id} opens a page or a tool the hall already has`);
52
+ }
53
+ });
54
+
55
+ test('a key means an action only when nothing else spent it', () => {
56
+ const V = loadPure();
57
+ const ev = (key, extra = {}) => ({ key, defaultPrevented: false, ctrlKey: false, metaKey: false, altKey: false, ...extra });
58
+ assert.equal(V.keyFor('artisan', ev('c')).id, 'claim');
59
+ assert.equal(V.keyFor('artisan', ev('C')).id, 'claim', 'case does not matter');
60
+ assert.equal(V.keyFor('artisan', ev('a')), 'sheet');
61
+ assert.equal(V.keyFor('artisan', ev('r')), null, 'the Artist\'s key is not the Artisan\'s');
62
+ assert.equal(V.keyFor('artisan', ev('c', { defaultPrevented: true })), null, 'a scene that used the key keeps it');
63
+ for (const m of ['ctrlKey', 'metaKey', 'altKey']) assert.equal(V.keyFor('artisan', ev('c', { [m]: true })), null, `${m} leaves it to the browser`);
64
+ assert.equal(V.keyFor('artisan', ev('Escape')), null, 'the ambient glass never takes Escape (ADR 0399 §2.4)');
65
+ });
66
+
67
+ test('credits, never a payout: the count reads as credits, with thousands grouped', () => {
68
+ const V = loadPure();
69
+ assert.equal(V.creditsText(56021), '56,021 credits');
70
+ assert.equal(V.creditsText(1), '1 credit');
71
+ assert.equal(V.creditsText(0), '0 credits');
72
+ assert.equal(V.creditsText(null), '');
73
+ assert.doesNotMatch(V.creditsText(56021), /[$€£]/, 'a count of credits, not a sum of money');
74
+ });
75
+
76
+ test('source: the visor loads after the shell on every scene page, with its sheet, and nowhere else', () => {
77
+ for (const f of ['index.html', 'city.html', 'goal-map.html', 'thinking.html']) {
78
+ const html = read(f);
79
+ const at = (s) => html.indexOf(s);
80
+ assert.ok(at('/builders/visor.js') > at('/builders/shell.js?') && at('/builders/shell.js?') > at('/builders/shell-game.js'), `${f}: shell-game, shell, then the visor`);
81
+ assert.match(html, /<link rel="stylesheet" href="\/builders\/visor\.css/, `${f}: links visor.css`);
82
+ }
83
+ for (const f of ['sky-interaction.js', 'city.js', 'home-city.js']) assert.match(read(f), /window\.__visorScene = /, `${f} registers its scene`);
84
+ assert.doesNotMatch(VISOR, /cam\.(x|y|k)\s*=/, 'the visor writes no camera (ADR 0399 §2.2)');
85
+ assert.doesNotMatch(VISOR, /setAttribute\('hidden'|\.hidden = true;[^\n]*nav/, 'it hides nothing but itself');
86
+ assert.match(read('visor.css'), /html\[data-game="on"\] \.visor/, 'game mode only');
87
+ assert.match(read('visor.css'), /pointer-events: none/, 'the glass lets the pointer through');
88
+ });
89
+
90
+ // ---------- in a browser: the glass over a stand-in scene ----------
91
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
92
+ let browser = null;
93
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
94
+ after(() => browser && browser.close());
95
+ const skip = !browser && 'no system Edge or Chrome';
96
+
97
+ const ORIGIN = 'http://hall.test';
98
+ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png' };
99
+ // The stand-in scene: a canvas filling the window that pans on drag, and the sky's Escape rule.
100
+ const SCENE = `<script>
101
+ window.cam = { x: 0, y: 0 }; window.picked = true; window.optionsOpened = 0;
102
+ window.OTBOptions = { escape() { window.optionsOpened++; } };
103
+ const cv = document.getElementById('world'); let drag = null;
104
+ cv.addEventListener('pointerdown', (e) => { drag = { x: e.clientX, y: e.clientY, cx: cam.x, cy: cam.y }; });
105
+ cv.addEventListener('pointermove', (e) => { if (drag) { cam.x = drag.cx - (e.clientX - drag.x); cam.y = drag.cy - (e.clientY - drag.y); } });
106
+ cv.addEventListener('pointerup', () => { drag = null; });
107
+ addEventListener('keydown', (e) => { if (e.key === 'Escape' && window.picked) { e.preventDefault(); window.picked = false; } });
108
+ window.OTBCraftMode = { held: 'artisan', character() { return this.held; }, current() { return null; },
109
+ me() { return { builder: { total_credits: 56021 } }; }, spriteSrc(id) { return '/builders/characters/' + id + '.png'; }, openTool(id) { window.toolOpened = id; } };
110
+ window.__visorScene = { name: 'test', place: 'Test city', actions: [{ label: 'Back to centre', key: '0', run() { window.centred = true; } }], keys: [{ label: 'Look around', key: 'drag' }] };
111
+ </script>`;
112
+ const PAGE = (game) => `<!doctype html><html${game ? ' data-game="on"' : ''}><head><meta charset="utf-8">
113
+ <link rel="stylesheet" href="/builders/visor.css"><style>:root{--ink:#111;--ink-soft:#555;--ink-faint:#777;--rule:#ccc;--rule-soft:#ddd;--bg-card:#fff;--bg-deep:#eee;--accent:#fb9569;--accent-on:#111;--accent-deep:#a3481a;--focus:#a3481a}
114
+ body{margin:0}#world{position:fixed;inset:0;width:100vw;height:100vh;display:block}</style></head>
115
+ <body><canvas id="world"></canvas>${SCENE}<script src="/builders/shell-game.js"></script><script src="/builders/visor.js"></script></body></html>`;
116
+
117
+ async function open({ game = true } = {}) {
118
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
119
+ await ctx.route(ORIGIN + '/**', (route) => {
120
+ const p = new URL(route.request().url()).pathname;
121
+ if (p === '/' ) return route.fulfill({ contentType: 'text/html', body: PAGE(game) });
122
+ const f = path.join(HALL, p.replace(/^\/builders\//, ''));
123
+ if (p.startsWith('/builders/') && TYPES[path.extname(p)] && fs.existsSync(f)) return route.fulfill({ contentType: TYPES[path.extname(p)], body: fs.readFileSync(f) });
124
+ return route.fulfill({ status: 404, body: '' });
125
+ });
126
+ const page = await ctx.newPage();
127
+ const errors = [];
128
+ page.on('pageerror', (e) => errors.push(String(e.message)));
129
+ await page.addInitScript((g) => { try { localStorage.setItem('hall-game', g ? 'on' : 'off'); window.OTB = { escapeHtml: (s) => String(s) }; } catch (_) { /* none */ } }, game);
130
+ await page.goto(ORIGIN + '/');
131
+ await page.waitForFunction(() => !!window.OTBVisor);
132
+ return { ctx, page, errors };
133
+ }
134
+
135
+ test('outside game mode the hall is as it was: no glass', { skip }, async () => {
136
+ const { ctx, page, errors } = await open({ game: false });
137
+ assert.equal(await page.evaluate(() => getComputedStyle(document.getElementById('visor')).display), 'none');
138
+ assert.deepEqual(errors, []);
139
+ await ctx.close();
140
+ });
141
+
142
+ test('in game mode the glass shows the place, the main action, the sprite and the credits', { skip }, async () => {
143
+ const { ctx, page, errors } = await open();
144
+ const g = await page.evaluate(() => ({
145
+ shown: getComputedStyle(document.getElementById('visor')).display,
146
+ place: document.querySelector('.visor__place').textContent,
147
+ main: document.querySelector('.visor__pill--main').textContent,
148
+ who: document.querySelector('.visor__name').textContent,
149
+ credits: document.querySelector('.visor__credits').textContent,
150
+ sprite: document.querySelector('.visor__sprite').getAttribute('src'),
151
+ }));
152
+ assert.equal(g.shown, 'block');
153
+ assert.equal(g.place, 'Test city');
154
+ assert.match(g.main, /^Claim a task/);
155
+ assert.equal(g.who, 'Artisan');
156
+ assert.equal(g.credits, '56,021 credits');
157
+ assert.equal(g.sprite, '/builders/characters/artisan.png');
158
+ assert.deepEqual(errors, []);
159
+ await ctx.close();
160
+ });
161
+
162
+ test('the glass passes the pointer through: a drag over the frame pans the world', { skip }, async () => {
163
+ const { ctx, page } = await open();
164
+ // The left side line runs down x≈1.8% of the width; drag straight across it.
165
+ await page.mouse.move(20, 400); await page.mouse.down(); await page.mouse.move(220, 450, { steps: 5 }); await page.mouse.up();
166
+ const cam = await page.evaluate(() => window.cam);
167
+ assert.deepEqual(cam, { x: -200, y: -50 });
168
+ assert.equal(await page.evaluate(() => document.elementFromPoint(640, 400).id), 'world', 'the centre cross is not a target');
169
+ await ctx.close();
170
+ });
171
+
172
+ test('Escape closes one layer, top first: the sheet, then the scene\'s pick, then the options menu', { skip }, async () => {
173
+ const { ctx, page } = await open();
174
+ await page.keyboard.press('a');
175
+ assert.equal(await page.evaluate(() => OTBVisor.isOpen()), true, 'A opens the sheet');
176
+ await page.keyboard.press('Escape');
177
+ let s = await page.evaluate(() => ({ open: OTBVisor.isOpen(), picked: window.picked, menu: window.optionsOpened }));
178
+ assert.deepEqual(s, { open: false, picked: true, menu: 0 }, 'the sheet took the press and stopped it: the star stays picked');
179
+ await page.keyboard.press('Escape');
180
+ s = await page.evaluate(() => ({ picked: window.picked, menu: window.optionsOpened }));
181
+ assert.deepEqual(s, { picked: false, menu: 0 }, 'the scene took the next one');
182
+ await page.keyboard.press('Escape');
183
+ await page.waitForTimeout(50);
184
+ assert.equal(await page.evaluate(() => window.optionsOpened), 1, 'nothing left open: the options menu has it, not the glass');
185
+ await ctx.close();
186
+ });
187
+
188
+ test('the sheet lists the character\'s actions, then this place, and runs a scene action', { skip }, async () => {
189
+ const { ctx, page } = await open();
190
+ await page.click('[data-visor-sheet]');
191
+ const rows = await page.$$eval('.visor__sheet .visor__row', (r) => r.map((x) => [...x.children].map((c) => c.textContent).join(' ')));
192
+ assert.deepEqual(rows, ['Claim a task C', 'Blockers B', 'Goal sky G', 'Your thinking T', 'Back to centre 0', 'Look around drag', 'Options Esc']);
193
+ await page.click('[data-visor-scene="0"]');
194
+ assert.deepEqual(await page.evaluate(() => ({ centred: window.centred, open: OTBVisor.isOpen() })), { centred: true, open: false });
195
+ await ctx.close();
196
+ });
197
+
198
+ test('an Artist\'s glass offers other actions, and its keys open the Artist\'s tools', { skip }, async () => {
199
+ const { ctx, page } = await open();
200
+ await page.evaluate(() => { OTBCraftMode.held = 'artist'; OTBVisor.render(); });
201
+ assert.match(await page.textContent('.visor__pill--main'), /^Review this page/);
202
+ await page.keyboard.press('e');
203
+ assert.equal(await page.evaluate(() => window.toolOpened), 'paintbrush');
204
+ await ctx.close();
205
+ });
206
+
207
+ test('the keys wait while someone types, or while a dialog is up', { skip }, async () => {
208
+ const { ctx, page } = await open();
209
+ await page.evaluate(() => { const i = document.createElement('input'); i.id = 'field'; document.body.appendChild(i); });
210
+ await page.focus('#field');
211
+ await page.keyboard.press('a');
212
+ assert.equal(await page.evaluate(() => OTBVisor.isOpen()), false, 'typing A in a field is typing');
213
+ await page.evaluate(() => { document.activeElement.blur(); const d = document.createElement('div'); d.setAttribute('aria-modal', 'true'); document.body.appendChild(d); });
214
+ await page.keyboard.press('a');
215
+ assert.equal(await page.evaluate(() => OTBVisor.isOpen()), false, 'a modal dialog has the keyboard');
216
+ await ctx.close();
217
+ });
218
+
219
+ test('no character, or no scene: no glass', { skip }, async () => {
220
+ const { ctx, page } = await open();
221
+ await page.evaluate(() => { OTBCraftMode.held = null; OTBVisor.render(); });
222
+ assert.equal(await page.evaluate(() => document.getElementById('visor').hidden), true);
223
+ await ctx.close();
224
+ });