@bongos/core 1.18.0 → 1.18.18

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 (553) hide show
  1. package/.bongos-core.json +1740 -640
  2. package/.claude/skills/builder-box/SKILL.md +6 -0
  3. package/.claude/skills/builder-sequence/SKILL.md +97 -0
  4. package/.claude/skills/builder-ship/SKILL.md +8 -2
  5. package/.claude/skills/builder-start/SKILL.md +11 -0
  6. package/.claude/skills/builder-sync/SKILL.md +41 -0
  7. package/.claude/skills/fix-task/SKILL.md +64 -0
  8. package/.claude/skills/goal-create/SKILL.md +158 -0
  9. package/.claude/skills/goal-review/SKILL.md +3 -1
  10. package/.claude/skills/idea-triage/SKILL.md +14 -8
  11. package/.claude/skills/merge-mode/SKILL.md +2 -2
  12. package/.claude/skills/new-project/SKILL.md +1 -0
  13. package/.claude/skills/planning-session/SKILL.md +45 -18
  14. package/.env.local.example +4 -3
  15. package/README.md +5 -3
  16. package/bin/bongos.js +1 -0
  17. package/clients/bongos-client/README.md +2 -2
  18. package/clients/bongos-client/bongos-client.global.js +82 -18
  19. package/clients/bongos-client/index.cjs +82 -18
  20. package/clients/bongos-client/index.d.ts +131 -29
  21. package/clients/bongos-client/index.mjs +82 -18
  22. package/config/branding.neutral.json +11 -2
  23. package/config/governance.neutral.json +21 -0
  24. package/docs/adr/0043-git-ssh-trust-boundary-and-rank-floor-on-permission-paths.md +24 -0
  25. package/docs/adr/0090-metic-task-authoring.md +1 -1
  26. package/docs/adr/0142-project-context-is-prose-only.md +41 -0
  27. package/docs/adr/0143-onboarding-repo-picker-oauth-scope.md +35 -0
  28. package/docs/adr/0144-devbox-rehome-onto-cloudbongos-plane.md +41 -0
  29. package/docs/adr/0144-federated-single-logout-backchannel.md +42 -0
  30. package/docs/adr/0145-devbox-app-branding-driven-module.md +44 -0
  31. package/docs/adr/0145-free-hosted-project-tier-isolation-and-domain-separation.md +62 -0
  32. package/docs/adr/0146-configurable-reward-mode-cost-plus-only.md +53 -0
  33. package/docs/adr/0147-standalone-live-docs-regen.md +39 -0
  34. package/docs/adr/0148-task-scoped-box-source-access.md +48 -0
  35. package/docs/adr/0149-bongos-dev-local-launcher.md +57 -0
  36. package/docs/adr/0150-box-first-boot-bringup-vendored-instances.md +68 -0
  37. package/docs/adr/0151-governance-permissions-as-atom-ranks-as-roles.md +229 -0
  38. package/docs/adr/0152-landing-gate-served-by-the-core-app.md +92 -0
  39. package/docs/adr/0152-metic-task-abandonment.md +67 -0
  40. package/docs/adr/0153-sequential-chain-runner.md +51 -0
  41. package/docs/adr/0154-goal-owner-criterion-authoring.md +86 -0
  42. package/docs/adr/README.md +15 -2
  43. package/docs/api/openapi.json +2648 -483
  44. package/docs/api-reference.md +73 -13
  45. package/docs/architecture.md +4 -2
  46. package/docs/branding-contract.md +3 -1
  47. package/docs/canonical-permissions.md +21 -8
  48. package/docs/file-map.md +15 -7
  49. package/docs/onboarding/diagrams/01-task-lifecycle.mmd +2 -2
  50. package/docs/onboarding/diagrams/02-rank-ladder.mmd +3 -1
  51. package/docs/onboarding/diagrams/05-versioning.mmd +1 -3
  52. package/docs/onboarding/diagrams/assertions.json +4 -7
  53. package/docs/onboarding/drachmae-and-karma.md +3 -3
  54. package/docs/onboarding/primer.md +17 -16
  55. package/docs/onboarding/slash-commands.md +124 -0
  56. package/docs/recipes/builder-box-lifecycle.md +23 -0
  57. package/docs/recipes/devbox-app-release.md +14 -4
  58. package/docs/recipes/instance-first-run.md +16 -0
  59. package/docs/recipes/local-dev.md +81 -0
  60. package/docs/recipes/ops-gotchas.md +19 -1
  61. package/docs/recipes/overnight-builder.md +1 -1
  62. package/docs/recipes/packaging-the-core.md +13 -0
  63. package/docs/recipes/parallel-goal-waves.md +174 -0
  64. package/docs/recipes/ship-and-regrade.md +2 -2
  65. package/docs/recipes/standalone-live-docs.md +48 -0
  66. package/docs/recipes/upgrading-the-core.md +5 -2
  67. package/docs/recipes/work-on-a-task-from-your-dev-box.md +108 -0
  68. package/migrations/003_pms.sql +6 -7
  69. package/migrations/006_pms_v2.sql +3 -31
  70. package/migrations/039_newcomer_friendly_column.sql +5 -0
  71. package/migrations/102_remove_box_first_provision_approval.sql +4 -0
  72. package/migrations/core_192_github_oauth_tokens.sql +33 -0
  73. package/migrations/core_193_task_needs_rebase.sql +28 -0
  74. package/migrations/core_194_confirmed_backlog_clawback.sql +63 -0
  75. package/migrations/core_195_idea_triage_provenance.sql +57 -0
  76. package/migrations/core_196_session_reward_topup.sql +69 -0
  77. package/modules/autonomy/pollers/routine-timers.js +9 -8
  78. package/modules/builder-settings/wandering-prefs.js +61 -18
  79. package/modules/dev-box/app/README.md +73 -0
  80. package/modules/dev-box/app/assets/tray/mac-alertTemplate.png +0 -0
  81. package/modules/dev-box/app/assets/tray/mac-alertTemplate@2x.png +0 -0
  82. package/modules/dev-box/app/assets/tray/mac-busyTemplate.png +0 -0
  83. package/modules/dev-box/app/assets/tray/mac-busyTemplate@2x.png +0 -0
  84. package/modules/dev-box/app/assets/tray/mac-offTemplate.png +0 -0
  85. package/modules/dev-box/app/assets/tray/mac-offTemplate@2x.png +0 -0
  86. package/modules/dev-box/app/assets/tray/mac-onTemplate.png +0 -0
  87. package/modules/dev-box/app/assets/tray/mac-onTemplate@2x.png +0 -0
  88. package/modules/dev-box/app/assets/tray/win-alert.png +0 -0
  89. package/modules/dev-box/app/assets/tray/win-busy.png +0 -0
  90. package/modules/dev-box/app/assets/tray/win-off.png +0 -0
  91. package/modules/dev-box/app/assets/tray/win-on.png +0 -0
  92. package/modules/dev-box/app/build-resources/dmg-background.png +0 -0
  93. package/modules/dev-box/app/build-resources/dmg-background@2x.png +0 -0
  94. package/modules/dev-box/app/build-resources/icon.png +0 -0
  95. package/modules/dev-box/app/electron-builder.tmpl.yml +95 -0
  96. package/modules/dev-box/app/electron-builder.yml +95 -0
  97. package/modules/dev-box/app/package-lock.json +5291 -0
  98. package/modules/dev-box/app/package.json +30 -0
  99. package/modules/dev-box/app/renderer/app.js +319 -0
  100. package/modules/dev-box/app/renderer/index.html +45 -0
  101. package/modules/dev-box/app/renderer/styles.css +246 -0
  102. package/modules/dev-box/app/renderer/theme.generated.css +16 -0
  103. package/modules/dev-box/app/scripts/apply-dmg-backdrop.sh +82 -0
  104. package/modules/dev-box/app/scripts/dmgbuild-settings.py +39 -0
  105. package/modules/dev-box/app/scripts/gen-brand.js +138 -0
  106. package/modules/dev-box/app/scripts/gen-dmg-backdrop.py +323 -0
  107. package/modules/dev-box/app/scripts/gen-icons.js +286 -0
  108. package/modules/dev-box/app/scripts/vendor-client.js +23 -0
  109. package/modules/dev-box/app/src/box-controller.js +287 -0
  110. package/modules/dev-box/app/src/brand.generated.json +13 -0
  111. package/modules/dev-box/app/src/brand.js +16 -0
  112. package/modules/dev-box/app/src/config.js +19 -0
  113. package/modules/dev-box/app/src/gds-client.js +90 -0
  114. package/modules/dev-box/app/src/main.js +610 -0
  115. package/modules/dev-box/app/src/pairing.js +57 -0
  116. package/modules/dev-box/app/src/preload.js +29 -0
  117. package/modules/dev-box/app/src/session-store.js +58 -0
  118. package/modules/dev-box/app/src/ssh-setup.js +569 -0
  119. package/modules/dev-box/app/src/task-picker.js +70 -0
  120. package/modules/dev-box/app/src/tray.js +99 -0
  121. package/modules/dev-box/app/src/updater.js +55 -0
  122. package/modules/dev-box/app/src/vendor/bongos-client.cjs +698 -0
  123. package/modules/dev-box/app/test/unit.mjs +526 -0
  124. package/modules/dev-box/box-access.js +121 -4
  125. package/modules/dev-box/boxes.js +20 -0
  126. package/modules/dev-box/module.json +1 -1
  127. package/modules/dev-box/routes/box.js +87 -3
  128. package/modules/economy/credits.js +116 -25
  129. package/modules/economy/reward.js +8 -0
  130. package/modules/economy/streak.js +2 -1
  131. package/modules/governance/CLAUDE.md +15 -0
  132. package/modules/governance/catalog.js +419 -0
  133. package/modules/governance/config.js +174 -0
  134. package/modules/governance/db.js +125 -0
  135. package/modules/governance/migrations/governance_001_tables.sql +81 -0
  136. package/modules/governance/migrations/governance_002_seed_and_backfill.sql +103 -0
  137. package/modules/governance/migrations/governance_003_task_abandon_metic.sql +28 -0
  138. package/modules/governance/migrations/governance_004_criterion_create.sql +39 -0
  139. package/modules/governance/migrations/governance_005_resync_builder_rank_roles.sql +56 -0
  140. package/modules/governance/module.json +10 -0
  141. package/modules/governance/protected-surfaces.js +88 -0
  142. package/modules/governance/protected-surfaces.json +80 -0
  143. package/modules/governance/rank-roles.js +83 -0
  144. package/modules/governance/resolver.js +221 -0
  145. package/modules/governance/routes/governance.js +89 -0
  146. package/modules/governance/routes/role-sync.js +49 -0
  147. package/modules/governance/session-scopes.js +127 -0
  148. package/modules/governance/session-scopes.json +38 -0
  149. package/modules/hall-ui/public/atlas.css +117 -0
  150. package/modules/hall-ui/public/atlas.html +3 -114
  151. package/modules/hall-ui/public/brand-apply.js +34 -6
  152. package/modules/hall-ui/public/builders.js +187 -219
  153. package/modules/hall-ui/public/diagrams.html +2 -0
  154. package/modules/hall-ui/public/dom-utils.js +27 -0
  155. package/modules/hall-ui/public/drachmae.html +2 -0
  156. package/modules/hall-ui/public/drachmae.js +54 -1
  157. package/modules/hall-ui/public/gate.css +98 -0
  158. package/modules/hall-ui/public/gate.html +3 -95
  159. package/modules/hall-ui/public/goal-inbox.js +4 -1
  160. package/modules/hall-ui/public/goals-page.js +626 -114
  161. package/modules/hall-ui/public/goals.css +79 -1
  162. package/modules/hall-ui/public/goals.html +38 -11
  163. package/modules/hall-ui/public/hall-widgets.js +62 -52
  164. package/modules/hall-ui/public/harbor.css +123 -0
  165. package/modules/hall-ui/public/harbor.html +3 -120
  166. package/modules/hall-ui/public/index.html +11 -4
  167. package/modules/hall-ui/public/instances-panel.js +4 -1
  168. package/modules/hall-ui/public/modules.css +63 -0
  169. package/modules/hall-ui/public/modules.html +3 -60
  170. package/modules/hall-ui/public/my-projects.html +78 -0
  171. package/modules/hall-ui/public/my-projects.js +142 -0
  172. package/modules/hall-ui/public/new-project-wizard.js +7 -3
  173. package/modules/hall-ui/public/not-ready.html +2 -0
  174. package/modules/hall-ui/public/pair.css +73 -0
  175. package/modules/hall-ui/public/pair.html +3 -70
  176. package/modules/hall-ui/public/palette.js +302 -0
  177. package/modules/hall-ui/public/primer.css +13 -0
  178. package/modules/hall-ui/public/primer.html +2 -0
  179. package/modules/hall-ui/public/profile.html +2 -0
  180. package/modules/hall-ui/public/ranks.html +2 -0
  181. package/modules/hall-ui/public/roadmap.css +179 -0
  182. package/modules/hall-ui/public/roadmap.html +9 -172
  183. package/modules/hall-ui/public/roadmap.js +329 -316
  184. package/modules/hall-ui/public/scouting.html +45 -0
  185. package/modules/hall-ui/public/scouting.js +73 -0
  186. package/modules/hall-ui/public/sessions.css +16 -0
  187. package/modules/hall-ui/public/sessions.html +3 -13
  188. package/modules/hall-ui/public/settings.html +2 -0
  189. package/modules/hall-ui/public/settings.js +4 -4
  190. package/modules/hall-ui/public/shell.js +143 -18
  191. package/modules/hall-ui/public/style.css +231 -39
  192. package/modules/hall-ui/public/task-detail.js +347 -26
  193. package/modules/hall-ui/public/task.html +6 -1
  194. package/modules/hall-ui/public/watch.html +2 -0
  195. package/modules/hall-ui/public/work.html +8 -6
  196. package/modules/hall-ui/public/work.js +1 -1
  197. package/modules/ideas/inbox.js +61 -11
  198. package/modules/ideas/routes/inbox.js +23 -0
  199. package/modules/lifecycle/claim-eligibility.js +72 -0
  200. package/modules/lifecycle/db.js +519 -139
  201. package/modules/lifecycle/done-when.js +18 -3
  202. package/modules/lifecycle/github-push.js +19 -2
  203. package/modules/lifecycle/goal-advisory.js +114 -0
  204. package/modules/lifecycle/goal-authz.js +62 -0
  205. package/modules/lifecycle/lifecycle.js +9 -0
  206. package/modules/lifecycle/publish-reconciler.js +109 -6
  207. package/modules/lifecycle/routes/claims.js +68 -23
  208. package/modules/lifecycle/routes/done-when.js +110 -15
  209. package/modules/lifecycle/routes/goals.js +8 -38
  210. package/modules/lifecycle/routes/tasks.js +38 -6
  211. package/modules/lifecycle/ship-card.js +6 -0
  212. package/modules/onboarding/onboarding-state.js +1 -1
  213. package/modules/platform-identity/CLAUDE.md +15 -0
  214. package/modules/platform-identity/hub-device.js +147 -0
  215. package/modules/platform-identity/hub-keys.js +151 -0
  216. package/modules/platform-identity/migrations/platform_identity_001_tables.sql +114 -0
  217. package/modules/platform-identity/migrations/platform_identity_002_client_origin_index.sql +18 -0
  218. package/modules/platform-identity/migrations/platform_identity_003_project_memberships.sql +42 -0
  219. package/modules/platform-identity/migrations/platform_identity_004_projects.sql +44 -0
  220. package/modules/platform-identity/migrations/platform_identity_005_builder_activity.sql +42 -0
  221. package/modules/platform-identity/module.json +18 -0
  222. package/modules/platform-identity/platform-identity.js +531 -0
  223. package/modules/platform-identity/pollers/code-sweep.js +43 -0
  224. package/modules/platform-identity/routes/my-projects.js +140 -0
  225. package/modules/platform-identity/routes/projects.js +107 -0
  226. package/modules/platform-identity/routes/scouting.js +35 -0
  227. package/modules/platform-identity/routes/sso.js +402 -0
  228. package/modules/platform-identity/tests/platform-identity.mjs +388 -0
  229. package/modules/platform-identity/ui/.gitkeep +0 -0
  230. package/modules/security/routes/reports.js +10 -0
  231. package/modules/sessions/db.js +166 -21
  232. package/modules/sessions/module.json +1 -1
  233. package/modules/sessions/pollers/session-reward-reconcile.js +78 -0
  234. package/modules/sessions/routes/sessions.js +33 -0
  235. package/modules/status-ui/public/explore.html +54 -0
  236. package/modules/status-ui/public/explore.js +67 -0
  237. package/package-lock.json +2 -2
  238. package/package.json +1 -1
  239. package/public-landing/assets/anim/idle/frame_00.webp +0 -0
  240. package/public-landing/assets/anim/idle/frame_01.webp +0 -0
  241. package/public-landing/assets/anim/idle/frame_02.webp +0 -0
  242. package/public-landing/assets/anim/idle/frame_03.webp +0 -0
  243. package/public-landing/assets/anim/idle/frame_04.webp +0 -0
  244. package/public-landing/assets/anim/idle/frame_05.webp +0 -0
  245. package/public-landing/assets/anim/idle/frame_06.webp +0 -0
  246. package/public-landing/assets/anim/idle/frame_07.webp +0 -0
  247. package/public-landing/assets/anim/idle/frame_08.webp +0 -0
  248. package/public-landing/assets/anim/idle/frame_09.webp +0 -0
  249. package/public-landing/assets/anim/idle/frame_10.webp +0 -0
  250. package/public-landing/assets/anim/idle/frame_11.webp +0 -0
  251. package/public-landing/assets/anim/idle/frame_12.webp +0 -0
  252. package/public-landing/assets/anim/idle/frame_13.webp +0 -0
  253. package/public-landing/assets/anim/idle/frame_14.webp +0 -0
  254. package/public-landing/assets/anim/idle/frame_15.webp +0 -0
  255. package/public-landing/assets/anim/idle/frame_16.webp +0 -0
  256. package/public-landing/assets/anim/idle/frame_17.webp +0 -0
  257. package/public-landing/assets/anim/idle/frame_18.webp +0 -0
  258. package/public-landing/assets/anim/idle/frame_19.webp +0 -0
  259. package/public-landing/assets/anim/idle/frame_20.webp +0 -0
  260. package/public-landing/assets/anim/idle/frame_21.webp +0 -0
  261. package/public-landing/assets/anim/idle/frame_22.webp +0 -0
  262. package/public-landing/assets/anim/idle/frame_23.webp +0 -0
  263. package/public-landing/assets/anim/idle/frame_24.webp +0 -0
  264. package/public-landing/assets/anim/idle/frame_25.webp +0 -0
  265. package/public-landing/assets/anim/idle/frame_26.webp +0 -0
  266. package/public-landing/assets/anim/idle/frame_27.webp +0 -0
  267. package/public-landing/assets/anim/idle/frame_28.webp +0 -0
  268. package/public-landing/assets/anim/idle/frame_29.webp +0 -0
  269. package/public-landing/assets/anim/idle/frame_30.webp +0 -0
  270. package/public-landing/assets/anim/idle/frame_31.webp +0 -0
  271. package/public-landing/assets/anim/idle/frame_32.webp +0 -0
  272. package/public-landing/assets/anim/idle/frame_33.webp +0 -0
  273. package/public-landing/assets/anim/idle/frame_34.webp +0 -0
  274. package/public-landing/assets/anim/idle/frame_35.webp +0 -0
  275. package/public-landing/assets/anim/idle/frame_36.webp +0 -0
  276. package/public-landing/assets/anim/idle/frame_37.webp +0 -0
  277. package/public-landing/assets/anim/idle/frame_38.webp +0 -0
  278. package/public-landing/assets/anim/idle/frame_39.webp +0 -0
  279. package/public-landing/assets/anim/idle/frame_40.webp +0 -0
  280. package/public-landing/assets/anim/idle/frame_41.webp +0 -0
  281. package/public-landing/assets/anim/idle/frame_42.webp +0 -0
  282. package/public-landing/assets/anim/idle/frame_43.webp +0 -0
  283. package/public-landing/create.html +252 -62
  284. package/public-landing/gate/README.md +137 -0
  285. package/public-landing/gate/dispmaps/bar.png +0 -0
  286. package/public-landing/gate/dispmaps/flat.png +0 -0
  287. package/public-landing/gate/dispmaps/key.png +0 -0
  288. package/public-landing/gate/index.html +587 -0
  289. package/public-landing/gate/previews/cloud-bongos.jpg +0 -0
  290. package/public-landing/gate/previews/off-the-boats.jpg +0 -0
  291. package/public-landing/gate/space.css +799 -0
  292. package/public-landing/gate/space.js +1826 -0
  293. package/public-landing/gate/sprites/enter.png +0 -0
  294. package/public-landing/gate/sprites/search.png +0 -0
  295. package/public-landing/gate/sprites/terminal-blank.png +0 -0
  296. package/public-landing/gate/sprites/tile.png +0 -0
  297. package/public-landing/world/assets/clouds/cloud-island-large.png +0 -0
  298. package/public-landing/world/assets/clouds/cloud-puff.png +0 -0
  299. package/public-landing/world/assets/creatures/creature-whale-cargo.png +0 -0
  300. package/public-landing/world/assets/creatures/creature-whale-glide.png +0 -0
  301. package/public-landing/world/assets/plates/plate-clouds-near.png +0 -0
  302. package/public-landing/world/assets/plates/scene-establishing.png +0 -0
  303. package/scripts/gds/abandon.js +6 -4
  304. package/scripts/gds/api.js +10 -5
  305. package/scripts/gds/api.ps1 +26 -0
  306. package/scripts/gds/api.sh +6 -0
  307. package/scripts/gds/audit-ships.js +7 -1
  308. package/scripts/gds/backfill-sessions.js +78 -18
  309. package/scripts/gds/backup.js +23 -1
  310. package/scripts/gds/box-connect-lib.js +124 -31
  311. package/scripts/gds/box-sync.js +66 -0
  312. package/scripts/gds/box.js +225 -53
  313. package/scripts/gds/cf-tunnel.js +145 -6
  314. package/scripts/gds/claim.js +34 -3
  315. package/scripts/gds/cli-lib.js +161 -21
  316. package/scripts/gds/code.js +6 -2
  317. package/scripts/gds/deploy-divergence.js +209 -0
  318. package/scripts/gds/dev-lib.js +309 -0
  319. package/scripts/gds/dev.js +322 -0
  320. package/scripts/gds/doctor.js +90 -61
  321. package/scripts/gds/fetch-art-key.js +1 -1
  322. package/scripts/gds/fetch-sound-prefs.js +2 -2
  323. package/scripts/gds/fitness.js +228 -12
  324. package/scripts/gds/gds-db-backup.service +16 -1
  325. package/scripts/gds/gds-db-offsite.service +9 -1
  326. package/scripts/gds/gen-api-client.js +1 -1
  327. package/scripts/gds/gen-api-docs.js +36 -14
  328. package/scripts/gds/gen-diagrams.js +45 -28
  329. package/scripts/gds/gen-file-map.js +2 -1
  330. package/scripts/gds/gen-freshness.js +32 -0
  331. package/scripts/gds/gen-repo-map.js +3 -2
  332. package/scripts/gds/gen-session-index.js +14 -5
  333. package/scripts/gds/hooks/pre-push.js +2 -2
  334. package/scripts/gds/init.js +60 -3
  335. package/scripts/gds/install-git-hooks.js +1 -1
  336. package/scripts/gds/key-card.js +15 -6
  337. package/scripts/gds/key-status.js +1 -1
  338. package/scripts/gds/linkify-refs.js +35 -9
  339. package/scripts/gds/lint-migrations.js +102 -0
  340. package/scripts/gds/login.js +58 -11
  341. package/scripts/gds/mirror-redact.js +5 -0
  342. package/scripts/gds/package-core.js +263 -4
  343. package/scripts/gds/paste-token.js +7 -1
  344. package/scripts/gds/preflight.js +126 -0
  345. package/scripts/gds/provision.js +268 -19
  346. package/scripts/gds/publish-manifest.js +22 -1
  347. package/scripts/gds/reauth.js +7 -3
  348. package/scripts/gds/regen-instance-docs.js +47 -0
  349. package/scripts/gds/run-unit-tests.js +42 -16
  350. package/scripts/gds/seed-bongos-v1-seo-tasks.js +104 -0
  351. package/scripts/gds/seed-bv1-profiles-tasks.js +126 -0
  352. package/scripts/gds/sequence.js +425 -0
  353. package/scripts/gds/session-cost.js +10 -1
  354. package/scripts/gds/setup.js +72 -5
  355. package/scripts/gds/shell.js +5 -3
  356. package/scripts/gds/ship.js +433 -59
  357. package/scripts/gds/smoke-gds.js +139 -8
  358. package/scripts/gds/start.js +171 -22
  359. package/scripts/gds/status.js +1 -1
  360. package/scripts/gds/triage.js +10 -9
  361. package/scripts/gds/upgrade.js +271 -7
  362. package/scripts/migrate.sh +10 -0
  363. package/scripts/status-mirror-sync.js +114 -84
  364. package/src/bongos/api-errors.js +1 -0
  365. package/src/bongos/auth.js +809 -107
  366. package/src/bongos/db-kernel.js +27 -0
  367. package/src/bongos/db.js +105 -0
  368. package/src/bongos/github-repos.js +93 -0
  369. package/src/bongos/module-scope-map.js +27 -1
  370. package/src/bongos/permission-path-check.js +136 -102
  371. package/src/bongos/repo-info.js +47 -11
  372. package/src/bongos/route-rank-check.js +429 -46
  373. package/src/bongos/routes/_helpers.js +12 -1
  374. package/src/bongos/routes/auth.js +312 -13
  375. package/src/bongos/routes/backup.js +58 -2
  376. package/src/bongos/routes/github.js +90 -0
  377. package/src/bongos/routes/instance.js +15 -3
  378. package/src/bongos/routes/me.js +35 -1
  379. package/src/bongos/routes.js +2 -0
  380. package/src/bongos/serve-internal.js +84 -15
  381. package/src/branding.js +49 -2
  382. package/src/instance-config.js +46 -0
  383. package/src/module-api.js +140 -1
  384. package/src/platform-server.js +86 -9
  385. package/tests/abandon_task.mjs +59 -3
  386. package/tests/api_cli_flags.mjs +53 -0
  387. package/tests/auth_page_gate.mjs +13 -0
  388. package/tests/backfill_sessions.mjs +127 -1
  389. package/tests/backup_cli.mjs +54 -0
  390. package/tests/bongos_box_connect.mjs +77 -0
  391. package/tests/box_access.mjs +130 -1
  392. package/tests/box_credential.mjs +125 -0
  393. package/tests/box_host_keys.mjs +24 -0
  394. package/tests/box_infra_resolve.mjs +149 -0
  395. package/tests/box_scope_session.mjs +6 -2
  396. package/tests/box_sync.mjs +55 -0
  397. package/tests/box_task_scope.mjs +99 -0
  398. package/tests/branding.mjs +41 -23
  399. package/tests/cf_ruleset_preflight.mjs +142 -0
  400. package/tests/cf_tunnel.mjs +37 -0
  401. package/tests/claim_gate_rebase.mjs +98 -0
  402. package/tests/clawback_confirmed_backlog.mjs +103 -0
  403. package/tests/cli_lib_formatters.mjs +20 -0
  404. package/tests/confirm_attribution.mjs +30 -46
  405. package/tests/credit_grant.mjs +198 -10
  406. package/tests/criterion_authoring_gate.mjs +272 -0
  407. package/tests/criterion_progress.mjs +33 -1
  408. package/tests/dev_lib.mjs +253 -0
  409. package/tests/devbox_artifact_gate.mjs +75 -0
  410. package/tests/devbox_dmg_backdrop.mjs +137 -0
  411. package/tests/devbox_icons.mjs +304 -0
  412. package/tests/devbox_installer_brand.mjs +232 -0
  413. package/tests/devbox_key_register.mjs +234 -0
  414. package/tests/docs_openapi_route.mjs +110 -0
  415. package/tests/doctor_hooks.mjs +141 -0
  416. package/tests/extract_json.mjs +140 -0
  417. package/tests/fitness.mjs +89 -0
  418. package/tests/gen_freshness.mjs +96 -0
  419. package/tests/github_repos_helpers.mjs +91 -0
  420. package/tests/github_token_store.mjs +121 -0
  421. package/tests/goal_advisory.mjs +251 -0
  422. package/tests/goal_criterion_confirm_ui.mjs +269 -0
  423. package/tests/goal_routes.mjs +59 -4
  424. package/tests/goal_tier.mjs +12 -3
  425. package/tests/governance_catalog.mjs +129 -0
  426. package/tests/governance_claim_eligibility.mjs +262 -0
  427. package/tests/governance_config.mjs +187 -0
  428. package/tests/governance_ownership_scope.mjs +410 -0
  429. package/tests/governance_page_gates.mjs +324 -0
  430. package/tests/governance_parity.mjs +336 -0
  431. package/tests/governance_principals.mjs +232 -0
  432. package/tests/governance_protected_surfaces.mjs +208 -0
  433. package/tests/governance_require_permission.mjs +223 -0
  434. package/tests/governance_resolver.mjs +84 -0
  435. package/tests/governance_role_sync.mjs +231 -0
  436. package/tests/governance_seed.mjs +127 -0
  437. package/tests/governance_session_scope.mjs +434 -0
  438. package/tests/governance_vanilla_boot.mjs +203 -0
  439. package/tests/grade_attribution.mjs +7 -7
  440. package/tests/hall_audit.mjs +223 -0
  441. package/tests/hall_nav.mjs +191 -0
  442. package/tests/hall_palette.mjs +206 -0
  443. package/tests/hall_signout.mjs +124 -0
  444. package/tests/hook_user_agents.mjs +10 -0
  445. package/tests/idea_triage.mjs +162 -0
  446. package/tests/idp_device_exchange.mjs +198 -0
  447. package/tests/idp_federation.mjs +100 -0
  448. package/tests/init.mjs +51 -0
  449. package/tests/instance_config.mjs +65 -0
  450. package/tests/instance_mismatch_guard.mjs +23 -6
  451. package/tests/instance_release_config.mjs +104 -0
  452. package/tests/linkify_refs.mjs +15 -2
  453. package/tests/migrations_forward_safe.mjs +86 -0
  454. package/tests/module_api.mjs +1 -1
  455. package/tests/module_contributions.mjs +55 -0
  456. package/tests/module_loader.mjs +10 -5
  457. package/tests/module_scope_active_claims.mjs +94 -0
  458. package/tests/needs_rebase.mjs +92 -0
  459. package/tests/one_active_claim_per_session.mjs +76 -0
  460. package/tests/override_attribution.mjs +10 -8
  461. package/tests/override_request_ownership.mjs +8 -4
  462. package/tests/package_core.mjs +246 -1
  463. package/tests/parse_cookie_decode.mjs +42 -0
  464. package/tests/pay_on_land.mjs +129 -0
  465. package/tests/platform_boot.mjs +100 -1
  466. package/tests/post_login_landing.mjs +42 -0
  467. package/tests/preflight_checks.mjs +72 -0
  468. package/tests/provision.mjs +289 -7
  469. package/tests/pruned_lockfile_integrity.mjs +79 -0
  470. package/tests/publish_branch_route.mjs +53 -0
  471. package/tests/publish_reconciler.mjs +187 -2
  472. package/tests/repo_info_binding.mjs +248 -0
  473. package/tests/reward_gate.mjs +11 -2
  474. package/tests/reward_mode_cost_plus_only.mjs +116 -0
  475. package/tests/reward_mode_surfacing.mjs +168 -0
  476. package/tests/route_permission_check.mjs +301 -0
  477. package/tests/route_rank_check.mjs +254 -12
  478. package/tests/route_validation.mjs +30 -0
  479. package/tests/sequence.mjs +267 -0
  480. package/tests/session_digest.mjs +44 -0
  481. package/tests/session_index.mjs +18 -6
  482. package/tests/session_records.mjs +39 -1
  483. package/tests/session_reward_on_land.mjs +381 -0
  484. package/tests/session_reward_reconcile.mjs +229 -0
  485. package/tests/ship_api_client_pathspec.mjs +100 -0
  486. package/tests/ship_base_flag.mjs +93 -0
  487. package/tests/ship_ci_deploy.mjs +140 -3
  488. package/tests/ship_upstream_deletion.mjs +57 -0
  489. package/tests/smoke_target.mjs +233 -0
  490. package/tests/start_env_guard.mjs +87 -0
  491. package/tests/start_rebase_warning.mjs +103 -0
  492. package/tests/status_mirror_sync.mjs +122 -0
  493. package/tests/stop_refcheck.mjs +19 -1
  494. package/tests/task_goal_link.mjs +15 -6
  495. package/tests/upgrade.mjs +393 -5
  496. package/tests/web_admission_status.mjs +162 -0
  497. package/tests/write_validation_gaps.mjs +110 -0
  498. package/.gitignore +0 -91
  499. package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +0 -94
  500. package/docs/adr/0140-cloudbongos-standalone-reprovision.md +0 -43
  501. package/scripts/hall-preview/fixtures/access-requests.json +0 -20
  502. package/scripts/hall-preview/fixtures/achievements.json +0 -76
  503. package/scripts/hall-preview/fixtures/analytics__builder.json +0 -251
  504. package/scripts/hall-preview/fixtures/audit-log.json +0 -68
  505. package/scripts/hall-preview/fixtures/blockers__detail.json +0 -12
  506. package/scripts/hall-preview/fixtures/box__me.json +0 -5
  507. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +0 -69
  508. package/scripts/hall-preview/fixtures/builders__active-claims.json +0 -1
  509. package/scripts/hall-preview/fixtures/builders__directory.json +0 -81
  510. package/scripts/hall-preview/fixtures/builders__roster.json +0 -48
  511. package/scripts/hall-preview/fixtures/goals.json +0 -502
  512. package/scripts/hall-preview/fixtures/goals__25.json +0 -96
  513. package/scripts/hall-preview/fixtures/goals__26.json +0 -67
  514. package/scripts/hall-preview/fixtures/goals__3.json +0 -103
  515. package/scripts/hall-preview/fixtures/goals__35.json +0 -49
  516. package/scripts/hall-preview/fixtures/goals__conflicts.json +0 -154
  517. package/scripts/hall-preview/fixtures/goals__detail.json +0 -96
  518. package/scripts/hall-preview/fixtures/goals__inbox.json +0 -5
  519. package/scripts/hall-preview/fixtures/goals__requests.json +0 -3
  520. package/scripts/hall-preview/fixtures/grades__by-builder.json +0 -32
  521. package/scripts/hall-preview/fixtures/inbox.json +0 -330
  522. package/scripts/hall-preview/fixtures/inbox__detail.json +0 -15
  523. package/scripts/hall-preview/fixtures/instance.json +0 -124
  524. package/scripts/hall-preview/fixtures/leaderboard.json +0 -184
  525. package/scripts/hall-preview/fixtures/me.json +0 -476
  526. package/scripts/hall-preview/fixtures/me__art-key__own.json +0 -6
  527. package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +0 -209
  528. package/scripts/hall-preview/fixtures/me__recent-ships.json +0 -304
  529. package/scripts/hall-preview/fixtures/me__render.json +0 -5
  530. package/scripts/hall-preview/fixtures/me__skill-prefs.json +0 -143
  531. package/scripts/hall-preview/fixtures/me__sound-prefs.json +0 -67
  532. package/scripts/hall-preview/fixtures/me__wandering.json +0 -43
  533. package/scripts/hall-preview/fixtures/override-requests.json +0 -15
  534. package/scripts/hall-preview/fixtures/provisioning__fleet.json +0 -1
  535. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +0 -1
  536. package/scripts/hall-preview/fixtures/provisioning__instances.json +0 -1
  537. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +0 -516
  538. package/scripts/hall-preview/fixtures/public__bounty-table.json +0 -37
  539. package/scripts/hall-preview/fixtures/public__cost-summary.json +0 -742
  540. package/scripts/hall-preview/fixtures/public__estimation-drift.json +0 -41
  541. package/scripts/hall-preview/fixtures/public__grades.json +0 -50
  542. package/scripts/hall-preview/fixtures/public__progress.json +0 -124
  543. package/scripts/hall-preview/fixtures/public__recent-shipped.json +0 -426
  544. package/scripts/hall-preview/fixtures/public__repo-health.json +0 -160
  545. package/scripts/hall-preview/fixtures/public__repo-info.json +0 -5
  546. package/scripts/hall-preview/fixtures/security__docs.json +0 -10
  547. package/scripts/hall-preview/fixtures/security__reports.json +0 -31
  548. package/scripts/hall-preview/fixtures/sessions__search.json +0 -9
  549. package/scripts/hall-preview/fixtures/tasks.json +0 -2420
  550. package/scripts/hall-preview/fixtures/tasks__claimable.json +0 -2555
  551. package/scripts/hall-preview/fixtures/tasks__detail.json +0 -111
  552. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +0 -330
  553. package/scripts/hall-preview/fixtures/versions.json +0 -89
@@ -0,0 +1,1826 @@
1
+ /* ═══════════════════════════════════════════════════════════════════════
2
+ Cloud Bongos — "space" scene controller (tasks 1721, 1722)
3
+
4
+ The landing flow, top to bottom:
5
+ ENTRY — every visit lands on the "type bongos" terminal, set in space.
6
+ SPACE — typing bongos reveals the pannable sky of project circles
7
+ (size = builders, drifting around anchors). Hover for details;
8
+ a SHIFT tap searches what's in view; click empty sky to add one.
9
+ DOWNLOAD— clicking the Cloud Bongos orb zooms straight into its download
10
+ page (search + tiles, over the temple world) — no gate between.
11
+ DETAIL — clicking any other orb opens a themed "story coming soon" page.
12
+ ESC — rises from download/detail back to the circles.
13
+
14
+ No framework, no build step. Project data is INLINED (Caddy 404s *.json on
15
+ staging); swap in a registry by changing PROJECTS + loadProjects(). The
16
+ download scene + its drag physics live in the inline script in index.html;
17
+ this file talks to it via cb:show-download / cb:hide-download events.
18
+ ═══════════════════════════════════════════════════════════════════════ */
19
+ (function () {
20
+ 'use strict';
21
+
22
+ /* ─── project data ──────────────────────────────────────────────────────
23
+ type: 'self' → dives into the download page (Cloud Bongos itself)
24
+ 'link' → a real, live instance from the platform's project
25
+ catalog, loaded at boot by loadProjects() from
26
+ /api/bongos/projects/featured; clicking opens the site.
27
+ The Cloud Bongos self-orb is the ONLY seeded entry — every other orb is a
28
+ genuinely-live project fetched from the catalog (task 1002260). No sample
29
+ fiction any more: an empty catalog simply means a sparser sky. */
30
+ var PROJECTS = [
31
+ { id: 'cloud-bongos', name: 'Cloud Bongos', type: 'self', builders: 24,
32
+ accent: '#f97316', preview: 'previews/cloud-bongos.jpg',
33
+ tagline: 'The AI-first build platform',
34
+ description: 'Building software is laying down a beat. AI agents claim tasks and ship the code; you set the direction and the gates.',
35
+ tags: ['platform', 'ai', 'build system', 'bongos'] }
36
+ ];
37
+
38
+ /* ─── tunables ─────────────────────────────────────────────────────────── */
39
+ var DEADZONE = 5; // px of movement before a press becomes a pan
40
+ var ZMIN = 0.22, ZMAX = 2.4; // zoom clamp (ZMIN lowered task 1897 — pull way back for a wide overview + more sky to create in)
41
+ var R_BASE = 30, R_K = 11; // radius = R_BASE + R_K*sqrt(builders)
42
+ var SPIRAL_STEP = 235; // spacing of the golden-angle START scatter
43
+
44
+ /* ─── gravity ("living cluster") — real n-body physics ────────────────────
45
+ The orbs are planets/suns pulling on each other by their SIZE. Verified
46
+ stable + lively + bounded over a 60-min headless sim (scratchpad/sim.js).
47
+ TUNING — livelier → SWIRL↑ or THERMO_LO↑ · tighter cluster → CONTAIN_R↓ ·
48
+ stronger pull → G↑ · softer near-misses (fewer hard slings) → SOFTEN↑ ·
49
+ bouncier contacts → REST↑ (→1 = elastic). Mass = (r/R_REF)^3, so a big orb
50
+ pulls much harder than a small one — that is "gravity based on their size." */
51
+ var GRAV = {
52
+ R_REF: 50, // mass = (radius / R_REF)^3
53
+ G: 5200, // gravitational constant (px^3 / mass·s^2)
54
+ SOFTEN: 70, // softening length (px) — tames close encounters, no singularities
55
+ SWIRL: 0.09, // initial angular velocity of the whole field (rad/s) — low = gentle, un-chaotic start
56
+ SWIRL_JITTER: 0.15, // random spread on the initial velocities (0 = a clean disk)
57
+ REST: 0.92, // restitution when two discs actually touch (rare)
58
+ CONTAIN_R: 520, // soft-well radius (px) — gentle pull-back keeps it on screen
59
+ CONTAIN_K: 6.0, // inward accel per px beyond CONTAIN_R
60
+ MAXV: 260, // hard velocity cap (px/s) — last-resort safety
61
+ THERMO_LO: 0.55, // KE may slosh freely down to this ×target before a gentle nudge up
62
+ THERMO_HI: 1.9, // ...and up to this ×target before a nudge down (pure physics between)
63
+ THERMO_MAX: 0.01, // max per-step velocity nudge when outside the band
64
+ PHYS_HZ: 120 // physics steps per second (fixed timestep; render interpolates)
65
+ };
66
+
67
+ /* ─── space-time sheet — a blueprint grid that warps with gravity ──────────
68
+ A faint cyan grid drawn behind the orbs (own canvas, z1). Every grid vertex
69
+ is pulled toward each orb by a mass-weighted well — mass = (r/R_REF)^3, the
70
+ SAME law the n-body physics uses — so a big orb dents the sheet deeply, a
71
+ small one barely, and the dimples ride the live motion. Kept a whisper:
72
+ the warp is real but the ink is faint, and its alpha ramps with zoom so it
73
+ reads as a blueprint underlay that only truly shows when you lean in.
74
+ TUNING — deeper wells → DEPTH↑ · wider wells → WELL_R↑ · more visible →
75
+ the *_A_HI alphas↑ · denser grid → BASE_WORLD↓ · smoother curves → SEG↑. */
76
+ var GRID = {
77
+ ENABLED: true,
78
+ BASE_WORLD: 60, // world px between minor grid lines (smaller = denser grid)
79
+ SEG: 10, // samples per cell along a line (curve smoothness near a well)
80
+ MAJOR_EVERY: 4, // every Nth line is a brighter "major" rule (blueprint read)
81
+ RGB: '158,202,248', // blueprint cyan-blue ink
82
+ MINOR_A_LO: 0.055, MINOR_A_HI: 0.150, // minor-line alpha at ZMIN → ZMAX
83
+ MAJOR_A_LO: 0.110, MAJOR_A_HI: 0.270, // major-line alpha at ZMIN → ZMAX
84
+ LINE_PX: 1, // stroke width in screen px (a crisp hairline at any zoom)
85
+ // The well: a Gaussian-weighted pull toward each orb. Displacement per orb =
86
+ // (orb − point) · k · exp(−d²/2σ²), with σ = orb.r·WELL_R and k = DEPTH·(r/R_REF)².
87
+ // This VANISHES at the centre and far away and peaks near the rim — a smooth
88
+ // basin (C∞, no hard clamp → no creases, and for k<1 it can never fold over).
89
+ // Depth grows with orb size (∝ r², a legible stand-in for mass), so a big orb
90
+ // dents a wider, deeper region — reads as heavier. (Use it.m in render() for
91
+ // strict mass ∝ size³ gravity, at the cost of tiny orbs barely denting.)
92
+ WELL_R: 1.9, // well scale σ = orb.r * WELL_R (world px) — bigger orb, wider dip
93
+ DEPTH: 0.20, // pull coefficient (k = DEPTH·(r/R_REF)²); higher = deeper dips
94
+ CUT: 4.0, // ignore a well past CUT × σ (Gaussian is negligible there; saves work)
95
+ MARGIN: 1.4, // extra world coverage beyond the viewport, in cells
96
+ // Glow: the grid lines light up where the sheet is most pinched. Each segment
97
+ // is stroked a second time, additively, with an alpha that tracks how far its
98
+ // vertices were displaced — so a deep gravity well haloes the lines around it,
99
+ // and a shallow one barely does. Deliberately subtle (few builders today, so
100
+ // even the big orbs' dips are gentle); raise GLOW_MAX_A / lower GLOW_REF later
101
+ // as orbs grow. Set GLOW_MAX_A: 0 to switch the glow off entirely.
102
+ GLOW_RGB: '198,232,255', // brighter, whiter blueprint cyan for the glow
103
+ GLOW_MAX_A: 0.07, // max ADDED alpha at full pinch (additive) — keep small = subtle
104
+ GLOW_REF: 40, // world-px of displacement mapped to full glow (lower = glows sooner)
105
+ GLOW_POW: 1.5, // >1 concentrates the glow into the most-pinched zones
106
+ GLOW_LEVELS: 8, // alpha quantization buckets (keeps the stroke count low)
107
+ GLOW_WIDTH: 1.4, // glow stroke width (screen px)
108
+ GLOW_BLUR: 2 // shadowBlur bloom (screen px); 0 = crisp (cheaper)
109
+ };
110
+
111
+ /* ─── element refs ─────────────────────────────────────────────────────── */
112
+ var space = document.getElementById('space');
113
+ var canvas = document.getElementById('space-canvas');
114
+ var gridCanvas = document.getElementById('space-grid');
115
+ var gctx = gridCanvas ? gridCanvas.getContext('2d') : null;
116
+ var gridDPR = 1;
117
+ var starfield = document.getElementById('starfield');
118
+ var hovercard = document.getElementById('hovercard');
119
+ var flash = document.getElementById('dive-flash');
120
+ var searchbar = document.getElementById('searchbar');
121
+ var searchInput = document.getElementById('search-input');
122
+ var searchCtxCount = document.getElementById('search-ctx-count');
123
+ var scrim = document.getElementById('create-scrim');
124
+ var createName = document.getElementById('create-name');
125
+ var createGo = document.getElementById('create-go');
126
+ var createCancel = document.getElementById('create-cancel');
127
+ var detail = document.getElementById('detail');
128
+ // entry gate (the relocated type-"bongos" terminal, now over the space sky)
129
+ var entryEl = document.getElementById('entry');
130
+ var eTyped = document.getElementById('typed'), eCaret = document.getElementById('caret'),
131
+ eHint = document.getElementById('hint'), eCli = document.getElementById('cli'),
132
+ eEnter = document.getElementById('enter');
133
+ // dive cinematic (task 1729): clicking the Cloud Bongos orb flies you from
134
+ // space, down through the clouds, into the Cloud Bongos world (download page)
135
+ var descent = document.getElementById('descent');
136
+ var DESCENT_MS = 2750; // deep in the cloud (still zooming) → zoom-through crossfade
137
+ var diveLanded = false, descentTimer = null;
138
+ var prefersReduced = false;
139
+ try { prefersReduced = matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
140
+
141
+ /* ─── host-aware brand (task 1746) ───────────────────────────────────────
142
+ ONE gate bundle serves all three domains; only location.hostname differs.
143
+ claudebongos.com / geminibongos.com are vanity DOORS: they mirror the
144
+ Cloud Bongos entry gate with just the wordmark + terminal word changed
145
+ (you still type "bongos"), and on enter they hand off to cloudbongos.com,
146
+ landing straight on the orbs via ?entered=1 (which cloudbongos.com honors
147
+ to skip its own entry). cloudbongos.com / staging / localhost keep the
148
+ default "cloud" behavior and reveal the orbs in place. Because it is one
149
+ bundle + a runtime switch, any later change to this gate shows up on all
150
+ three automatically — there is no forked copy to keep in sync. */
151
+ var CLOUDBONGOS_ORIGIN = 'https://cloudbongos.com';
152
+ function brandForHost(host) {
153
+ host = (host || '').toLowerCase();
154
+ // Redirect straight to the gate's SERVED path (/gate/), not the apex "/", so
155
+ // the ?entered=1 flag survives: the apex "/" -> "/gate/" 302 drops the query,
156
+ // which made a door visitor land on the entry gate and type "bongos" again
157
+ // (task 1977 sibling fix). /gate/ is served directly, so the flag reaches
158
+ // wantsDirectOrbs() and the orbs open straight away.
159
+ if (/(^|\.)claudebongos\.com$/.test(host)) return { word: 'claude', display: 'Claude', redirect: CLOUDBONGOS_ORIGIN + '/gate/?entered=1' };
160
+ if (/(^|\.)geminibongos\.com$/.test(host)) return { word: 'gemini', display: 'Gemini', redirect: CLOUDBONGOS_ORIGIN + '/gate/?entered=1' };
161
+ return { word: 'cloud', display: 'Cloud', redirect: null };
162
+ }
163
+ var BRAND = brandForHost(location.hostname);
164
+
165
+ // Swap the one brand word into the static markup the door visitor sees: the
166
+ // terminal prefix (`> claude`) and every "Cloud Bongos" wordmark, plus the
167
+ // tab title. The default bundle ships "cloud", so this is a no-op on
168
+ // cloudbongos.com and only rewrites on a door.
169
+ function applyBrand() {
170
+ if (BRAND.word === 'cloud') return;
171
+ if (eCli) { var c = eCli.querySelector('.c'); if (c) c.textContent = BRAND.word; }
172
+ var marks = document.querySelectorAll('.mark, .space-mark, .tv-mark');
173
+ for (var i = 0; i < marks.length; i++) {
174
+ var nodes = marks[i].childNodes;
175
+ for (var j = 0; j < nodes.length; j++) {
176
+ if (nodes[j].nodeType === 3 && /\bCloud\b/.test(nodes[j].nodeValue)) {
177
+ nodes[j].nodeValue = nodes[j].nodeValue.replace(/\bCloud\b/, BRAND.display);
178
+ }
179
+ }
180
+ }
181
+ try { document.title = BRAND.display + ' Bongos — landing gate'; } catch (_) {}
182
+ }
183
+ applyBrand();
184
+
185
+ // Did a door redirect us here asking to open the orbs straight away? Honored
186
+ // ONLY where we are not ourselves a door, so a door still shows its branded
187
+ // entry even if it somehow carries the flag.
188
+ function wantsDirectOrbs() {
189
+ if (BRAND.redirect) return false;
190
+ try { return new URLSearchParams(location.search).get('entered') === '1'; }
191
+ catch (_) { return /[?&]entered=1(?:&|$)/.test(location.search || ''); }
192
+ }
193
+ // view toggle + table directory (task 1731)
194
+ var vtCircles = document.getElementById('vt-circles');
195
+ var vtTable = document.getElementById('vt-table');
196
+ var tvBody = document.getElementById('tv-body');
197
+ var tvSearchInput = document.getElementById('tv-search-input');
198
+ var tvCount = document.getElementById('tv-count');
199
+ var tvCreate = document.getElementById('tv-create');
200
+
201
+ /* ─── view state ───────────────────────────────────────────────────────── */
202
+ var panX = 0, panY = 0, zoom = 1;
203
+ var saved = null; // pan/zoom snapshot captured at dive time
204
+ var W = innerWidth, H = innerHeight;
205
+ var items = []; // one per circle
206
+ var byId = {};
207
+ var searchOpen = false, createOpen = false, diving = false, entering = false;
208
+ var userTouched = false; // has the user panned/zoomed? (stop auto-refit)
209
+ var createAt = { x: 0, y: 0 };
210
+ var browseView = 'space'; // which project view: 'space' (circles) | 'table'
211
+ var tvSort = { key: 'builders', dir: -1 }; // table sort: dir -1 desc, 1 asc
212
+
213
+ function radiusFor(builders) { return R_BASE + R_K * Math.sqrt(Math.max(1, builders)); }
214
+ function massFor(r) { return Math.pow(r / GRAV.R_REF, 3); } // mass grows with size
215
+ function project(id) { return byId[id]; }
216
+ function anyOverlay() { return searchOpen || createOpen || signInOpen; }
217
+ function isSpace() { return document.body.classList.contains('view-space'); }
218
+
219
+ /* ─── build a circle ───────────────────────────────────────────────────── */
220
+ function makeCircle(proj, ax, ay) {
221
+ var r = radiusFor(proj.builders), d = r * 2;
222
+ var el = document.createElement('div');
223
+ el.className = 'circle' + (proj.type === 'self' ? ' is-self' : '');
224
+ el.dataset.id = proj.id;
225
+ el.style.left = ax + 'px';
226
+ el.style.top = ay + 'px';
227
+ el.style.width = d + 'px';
228
+ el.style.height = d + 'px';
229
+ el.style.setProperty('--acc', proj.accent);
230
+ el.style.setProperty('--glow', hexGlow(proj.accent));
231
+
232
+ var disc = document.createElement('div');
233
+ disc.className = 'disc';
234
+ el.appendChild(disc);
235
+
236
+ // the world lives INSIDE the orb — a peek revealed on hover (real instances
237
+ // that carry a preview image; samples stay their accent-coloured sphere).
238
+ if (proj.preview) {
239
+ var world = document.createElement('div');
240
+ world.className = 'disc-world';
241
+ world.style.backgroundImage = "url('" + proj.preview + "')";
242
+ disc.appendChild(world);
243
+ }
244
+
245
+ if (proj.type === 'self') {
246
+ var badge = document.createElement('div');
247
+ badge.className = 'self-badge';
248
+ badge.textContent = 'this platform';
249
+ el.appendChild(badge);
250
+ }
251
+
252
+ var label = document.createElement('div');
253
+ label.className = 'label';
254
+ label.innerHTML = '<span class="name"></span><span class="count"></span>';
255
+ label.querySelector('.name').textContent = proj.name;
256
+ // Catalog projects carry no builder count (the public feed has none) — show
257
+ // a neutral 'live' status rather than invent a number (task 1002260).
258
+ label.querySelector('.count').textContent =
259
+ (typeof proj.builders === 'number' && proj.builders > 0)
260
+ ? (proj.builders + (proj.builders === 1 ? ' builder' : ' builders'))
261
+ : 'live';
262
+ el.appendChild(label);
263
+
264
+ canvas.appendChild(el);
265
+
266
+ el.addEventListener('mouseenter', function () { showHover(proj, el); });
267
+ el.addEventListener('mouseleave', hideHover);
268
+
269
+ return {
270
+ // ax/ay = the DOM anchor (el.style.left/top, set once). x/y = the live
271
+ // physics position; the render offset is (x-ax, y-ay). px/py = previous
272
+ // step position (for frame interpolation). m = gravitational mass.
273
+ proj: proj, el: el, disc: disc, ax: ax, ay: ay, r: r,
274
+ x: ax, y: ay, px: ax, py: ay, vx: 0, vy: 0, m: massFor(r), fx: 0, fy: 0
275
+ };
276
+ }
277
+
278
+ function hexGlow(hex) {
279
+ var h = hex.replace('#', '');
280
+ var n = parseInt(h.length === 3 ? h.replace(/(.)/g, '$1$1') : h, 16);
281
+ var r = (n >> 16) & 255, g = (n >> 8) & 255, b = n & 255;
282
+ return 'rgba(' + r + ',' + g + ',' + b + ',.55)';
283
+ }
284
+
285
+ /* ─── layout: scatter anchors, Cloud Bongos in the middle ────────────────── */
286
+ function layout() {
287
+ var CX = 0, CY = 0, GA = 2.399963, STEP = SPIRAL_STEP;
288
+ var others = PROJECTS.filter(function (p) { return p.type !== 'self'; });
289
+ var self = PROJECTS.filter(function (p) { return p.type === 'self'; });
290
+ var placed = [];
291
+
292
+ self.forEach(function (p) { placed.push({ p: p, x: CX, y: CY }); });
293
+ others.forEach(function (p, i) {
294
+ var k = i + 1;
295
+ var rad = STEP * Math.sqrt(k);
296
+ var ang = k * GA;
297
+ placed.push({ p: p, x: CX + Math.cos(ang) * rad, y: CY + Math.sin(ang) * rad * 0.82 });
298
+ });
299
+
300
+ placed.forEach(function (pl) {
301
+ var it = makeCircle(pl.p, pl.x, pl.y);
302
+ items.push(it);
303
+ byId[pl.p.id] = pl.p;
304
+ });
305
+ initOrbits();
306
+ }
307
+
308
+ /* ─── fit the whole constellation into view ──────────────────────────────── */
309
+ function fitToView() {
310
+ if (!items.length) return;
311
+ W = innerWidth || document.documentElement.clientWidth || 1200;
312
+ H = innerHeight || document.documentElement.clientHeight || 800;
313
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
314
+ items.forEach(function (it) {
315
+ minX = Math.min(minX, it.x - it.r - 30); minY = Math.min(minY, it.y - it.r - 30);
316
+ maxX = Math.max(maxX, it.x + it.r + 30); maxY = Math.max(maxY, it.y + it.r + 70);
317
+ });
318
+ var cw = maxX - minX, ch = maxY - minY;
319
+ var pad = 70;
320
+ var z = Math.min((W - pad * 2) / cw, (H - pad * 2) / ch, 1.3);
321
+ zoom = Math.max(ZMIN, Math.min(ZMAX, z));
322
+ var cx = (minX + maxX) / 2, cy = (minY + maxY) / 2;
323
+ panX = W / 2 - cx * zoom;
324
+ panY = H / 2 - cy * zoom;
325
+ applyCanvas();
326
+ }
327
+
328
+ function applyCanvas() {
329
+ canvas.style.transform = 'translate(' + panX.toFixed(2) + 'px,' + panY.toFixed(2) + 'px) scale(' + zoom.toFixed(4) + ')';
330
+ }
331
+
332
+ function clampPan() {
333
+ var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
334
+ items.forEach(function (it) {
335
+ minX = Math.min(minX, it.x); minY = Math.min(minY, it.y);
336
+ maxX = Math.max(maxX, it.x); maxY = Math.max(maxY, it.y);
337
+ });
338
+ var m = 220;
339
+ var sxMin = panX + minX * zoom, sxMax = panX + maxX * zoom;
340
+ var syMin = panY + minY * zoom, syMax = panY + maxY * zoom;
341
+ if (sxMax < m) panX += m - sxMax; else if (sxMin > W - m) panX -= sxMin - (W - m);
342
+ if (syMax < m) panY += m - syMax; else if (syMin > H - m) panY -= syMin - (H - m);
343
+ }
344
+
345
+ /* ═══ gravity: a living n-body cluster ═════════════════════════════════════
346
+ Every orb attracts every other by its mass (∝ size³), softened so close
347
+ passes sling instead of exploding. A fixed-timestep leapfrog integrator
348
+ (accurate + energy-preserving) advances the sim; the frame loop interpolates
349
+ between steps for smoothness. A soft well + a band thermostat keep the swirl
350
+ bounded and lively forever without freezing the physics in between. Verified
351
+ over 60 sim-minutes: no NaN, stays centered, never interpenetrates. */
352
+ var keTarget = 0;
353
+
354
+ function barycenter() {
355
+ var M = 0, cx = 0, cy = 0;
356
+ for (var i = 0; i < items.length; i++) { var it = items[i]; M += it.m; cx += it.m * it.x; cy += it.m * it.y; }
357
+ return M > 0 ? { x: cx / M, y: cy / M, M: M } : { x: 0, y: 0, M: 0 };
358
+ }
359
+ function kineticEnergy() {
360
+ var ke = 0;
361
+ for (var i = 0; i < items.length; i++) { var it = items[i]; ke += 0.5 * it.m * (it.vx * it.vx + it.vy * it.vy); }
362
+ return ke;
363
+ }
364
+ function killDrift() {
365
+ // remove uniform bulk velocity so the cluster never sails off-screen.
366
+ // Galilean-neutral: the internal swirl is untouched.
367
+ var M = 0, px = 0, py = 0;
368
+ for (var i = 0; i < items.length; i++) { var it = items[i]; M += it.m; px += it.m * it.vx; py += it.m * it.vy; }
369
+ if (M <= 0) return;
370
+ var dvx = px / M, dvy = py / M;
371
+ for (var j = 0; j < items.length; j++) { items[j].vx -= dvx; items[j].vy -= dvy; }
372
+ }
373
+
374
+ // seed a slow overall swirl + remember the lively energy the thermostat targets
375
+ function initOrbits() {
376
+ var c = barycenter();
377
+ var jit = GRAV.SWIRL * 60 * GRAV.SWIRL_JITTER;
378
+ for (var i = 0; i < items.length; i++) {
379
+ var it = items[i], dx = it.x - c.x, dy = it.y - c.y;
380
+ it.vx = -dy * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
381
+ it.vy = dx * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
382
+ }
383
+ killDrift();
384
+ keTarget = kineticEnergy();
385
+ }
386
+
387
+ function computeForces(c) {
388
+ var n = items.length, soft2 = GRAV.SOFTEN * GRAV.SOFTEN, i, j;
389
+ for (i = 0; i < n; i++) { items[i].fx = 0; items[i].fy = 0; }
390
+ for (i = 0; i < n; i++) {
391
+ for (j = i + 1; j < n; j++) {
392
+ var a = items[i], b = items[j];
393
+ var dx = b.x - a.x, dy = b.y - a.y;
394
+ var d2 = dx * dx + dy * dy + soft2;
395
+ var f = GRAV.G / (d2 * Math.sqrt(d2));
396
+ a.fx += f * b.m * dx; a.fy += f * b.m * dy;
397
+ b.fx -= f * a.m * dx; b.fy -= f * a.m * dy;
398
+ }
399
+ }
400
+ // soft containment well — a gentle inward pull only beyond CONTAIN_R
401
+ for (i = 0; i < n; i++) {
402
+ var it = items[i], ex = it.x - c.x, ey = it.y - c.y, d = Math.hypot(ex, ey);
403
+ if (d > GRAV.CONTAIN_R && d > 1e-6) {
404
+ var k = -GRAV.CONTAIN_K * (d - GRAV.CONTAIN_R) / d;
405
+ it.fx += k * ex; it.fy += k * ey;
406
+ }
407
+ }
408
+ }
409
+
410
+ function resolveCollisions() {
411
+ var n = items.length;
412
+ for (var i = 0; i < n; i++) {
413
+ for (var j = i + 1; j < n; j++) {
414
+ var a = items[i], b = items[j];
415
+ var dx = b.x - a.x, dy = b.y - a.y;
416
+ var dist = Math.hypot(dx, dy) || 1e-6, min = a.r + b.r;
417
+ if (dist >= min) continue;
418
+ var nx = dx / dist, ny = dy / dist;
419
+ var im1 = 1 / a.m, im2 = 1 / b.m, ims = im1 + im2;
420
+ var corr = (min - dist) / ims; // positional push-apart by inverse mass
421
+ a.x -= nx * corr * im1; a.y -= ny * corr * im1;
422
+ b.x += nx * corr * im2; b.y += ny * corr * im2;
423
+ var vn = (b.vx - a.vx) * nx + (b.vy - a.vy) * ny; // velocity exchange along the normal
424
+ if (vn < 0) {
425
+ var imp = -(1 + GRAV.REST) * vn / ims;
426
+ a.vx -= imp * im1 * nx; a.vy -= imp * im1 * ny;
427
+ b.vx += imp * im2 * nx; b.vy += imp * im2 * ny;
428
+ }
429
+ }
430
+ }
431
+ }
432
+
433
+ function thermostat() {
434
+ var lo = keTarget * GRAV.THERMO_LO, hi = keTarget * GRAV.THERMO_HI, ke = kineticEnergy();
435
+ if (ke < 1e-6) return;
436
+ var target = ke < lo ? lo : (ke > hi ? hi : 0);
437
+ if (!target) return; // inside the band → pure physics
438
+ var f = Math.sqrt(target / ke);
439
+ f = Math.max(1 - GRAV.THERMO_MAX, Math.min(1 + GRAV.THERMO_MAX, f));
440
+ for (var i = 0; i < items.length; i++) { items[i].vx *= f; items[i].vy *= f; }
441
+ }
442
+
443
+ function clampVelocities() {
444
+ for (var i = 0; i < items.length; i++) {
445
+ var it = items[i], sp = Math.hypot(it.vx, it.vy);
446
+ if (sp > GRAV.MAXV) { var f = GRAV.MAXV / sp; it.vx *= f; it.vy *= f; }
447
+ }
448
+ }
449
+
450
+ function physStep(dt) {
451
+ var i, it, c = barycenter();
452
+ for (i = 0; i < items.length; i++) { it = items[i]; it.px = it.x; it.py = it.y; }
453
+ computeForces(c); // leapfrog kick–drift–kick
454
+ for (i = 0; i < items.length; i++) { it = items[i]; it.vx += 0.5 * it.fx * dt; it.vy += 0.5 * it.fy * dt; }
455
+ for (i = 0; i < items.length; i++) { it = items[i]; it.x += it.vx * dt; it.y += it.vy * dt; }
456
+ computeForces(c);
457
+ for (i = 0; i < items.length; i++) { it = items[i]; it.vx += 0.5 * it.fx * dt; it.vy += 0.5 * it.fy * dt; }
458
+ resolveCollisions();
459
+ thermostat();
460
+ killDrift();
461
+ clampVelocities();
462
+ }
463
+
464
+ /* ═══ space-time sheet: a gravity-warped blueprint grid ════════════════════
465
+ Drawn each frame on its own canvas (behind the orbs) in screen space, using
466
+ the live pan/zoom so it lines up with the orb canvas exactly. Each vertex is
467
+ displaced toward every nearby orb by a softened, mass-weighted well, so the
468
+ lines funnel into each gravity dimple and the whole sheet breathes with the
469
+ n-body motion. See the GRID tuning block up top. */
470
+ var gridWells = [];
471
+
472
+ function sizeGrid() {
473
+ if (!gridCanvas) return;
474
+ gridDPR = window.devicePixelRatio || 1;
475
+ gridCanvas.width = Math.max(1, Math.round(W * gridDPR));
476
+ gridCanvas.height = Math.max(1, Math.round(H * gridDPR));
477
+ gridCanvas.style.width = W + 'px';
478
+ gridCanvas.style.height = H + 'px';
479
+ }
480
+
481
+ // Displace a world point toward each orb by a Gaussian-weighted pull that
482
+ // vanishes at the centre + far away and peaks near the rim — a smooth basin
483
+ // (no clamp → no creases; can't fold). Writes the warped point onto `out`.
484
+ function warpPoint(wx, wy, out) {
485
+ var ox = wx, oy = wy;
486
+ for (var i = 0; i < gridWells.length; i++) {
487
+ var wl = gridWells[i];
488
+ var dx = wl.x - wx, dy = wl.y - wy, d2 = dx * dx + dy * dy;
489
+ if (d2 > wl.cut2) continue;
490
+ var e = wl.k * Math.exp(-0.5 * d2 * wl.isig2); // fraction of the way to pull toward the orb
491
+ ox += dx * e; oy += dy * e;
492
+ }
493
+ out.x = ox; out.y = oy;
494
+ }
495
+
496
+ function drawGrid() {
497
+ if (!gctx || !GRID.ENABLED || !isSpace()) return;
498
+ gctx.setTransform(gridDPR, 0, 0, gridDPR, 0, 0);
499
+ gctx.clearRect(0, 0, W, H);
500
+ if (!gridWells.length) return;
501
+
502
+ var S = GRID.BASE_WORLD, step = S / GRID.SEG, m = GRID.MARGIN * S;
503
+ // visible world rect (+ margin) so warped lines enter from off-screen cleanly
504
+ var x0 = (0 - panX) / zoom - m, x1 = (W - panX) / zoom + m;
505
+ var y0 = (0 - panY) / zoom - m, y1 = (H - panY) / zoom + m;
506
+ var gx0 = Math.floor(x0 / S) * S, gy0 = Math.floor(y0 / S) * S;
507
+
508
+ // alpha ramps with zoom: a whisper zoomed out, legible zoomed in
509
+ var zf = (zoom - ZMIN) / (ZMAX - ZMIN); if (zf < 0) zf = 0; else if (zf > 1) zf = 1;
510
+ var minorA = GRID.MINOR_A_LO + zf * (GRID.MINOR_A_HI - GRID.MINOR_A_LO);
511
+ var majorA = GRID.MAJOR_A_LO + zf * (GRID.MAJOR_A_HI - GRID.MAJOR_A_LO);
512
+ // fade minor lines out as their on-screen cell shrinks, so a zoomed-out grid
513
+ // never turns into noise (only the major rules survive there)
514
+ var cellPx = S * zoom;
515
+ if (cellPx < 30) minorA *= Math.max(0, (cellPx - 14) / 16);
516
+
517
+ gctx.lineWidth = GRID.LINE_PX;
518
+ gctx.lineJoin = 'round'; gctx.lineCap = 'round';
519
+ var p = { x: 0, y: 0 }, x, y, n, i;
520
+ var xs = [], ys = []; // reused per-line screen-point buffers
521
+
522
+ // draw a smooth curve through the n collected points with midpoint-quadratics
523
+ // (Catmull-Rom-style) so the warped lines read as curves, not faceted chains
524
+ function emitSmooth(n) {
525
+ if (n < 2) return;
526
+ gctx.moveTo(xs[0], ys[0]);
527
+ if (n === 2) { gctx.lineTo(xs[1], ys[1]); return; }
528
+ for (i = 1; i < n - 2; i++) {
529
+ gctx.quadraticCurveTo(xs[i], ys[i], (xs[i] + xs[i + 1]) / 2, (ys[i] + ys[i + 1]) / 2);
530
+ }
531
+ gctx.quadraticCurveTo(xs[i], ys[i], xs[i + 1], ys[i + 1]); // last span (i = n-2)
532
+ }
533
+
534
+ // one batched path per tier (minor / major) — 2 strokes total
535
+ function sweep(major, alpha) {
536
+ if (alpha < 0.004) return;
537
+ gctx.beginPath();
538
+ for (x = gx0; x <= x1; x += S) { // vertical lines (constant world x)
539
+ if ((Math.round(x / S) % GRID.MAJOR_EVERY === 0) !== major) continue;
540
+ n = 0;
541
+ for (y = y0; y <= y1; y += step) { warpPoint(x, y, p); xs[n] = panX + p.x * zoom; ys[n] = panY + p.y * zoom; n++; }
542
+ emitSmooth(n);
543
+ }
544
+ for (y = gy0; y <= y1; y += S) { // horizontal lines (constant world y)
545
+ if ((Math.round(y / S) % GRID.MAJOR_EVERY === 0) !== major) continue;
546
+ n = 0;
547
+ for (x = x0; x <= x1; x += step) { warpPoint(x, y, p); xs[n] = panX + p.x * zoom; ys[n] = panY + p.y * zoom; n++; }
548
+ emitSmooth(n);
549
+ }
550
+ gctx.strokeStyle = 'rgba(' + GRID.RGB + ',' + alpha.toFixed(4) + ')';
551
+ gctx.stroke();
552
+ }
553
+
554
+ sweep(false, minorA); // minor rules first, majors drawn over them
555
+ sweep(true, majorA);
556
+
557
+ // ── glow pass: lines brighten where the sheet is most pinched ──
558
+ // A vertex's displacement magnitude IS the local "gravity" — stroke each
559
+ // segment additively with an alpha that tracks it, so deep wells halo the
560
+ // grid around them and shallow ones barely do. Bucketed into GLOW_LEVELS so
561
+ // the whole pass is a handful of strokes, not thousands.
562
+ if (GRID.GLOW_MAX_A > 0.001) drawGlow(S, step, x0, x1, y0, y1, gx0, gy0, zf);
563
+ }
564
+
565
+ // is a grid line (constant world x if vertical, else y) close enough to any
566
+ // well to pick up glow? Cheap perpendicular-distance prune so the glow pass
567
+ // only samples lines that actually pass near an orb.
568
+ function lineNearWell(c, isVert, reachSig) {
569
+ for (var i = 0; i < gridWells.length; i++) {
570
+ var wl = gridWells[i];
571
+ if (Math.abs(c - (isVert ? wl.x : wl.y)) < reachSig * wl.sig) return true;
572
+ }
573
+ return false;
574
+ }
575
+
576
+ var glowBuckets = null; // reused per-frame flat [x0,y0,x1,y1,…] arrays, one per alpha level
577
+
578
+ function drawGlow(S, step, x0, x1, y0, y1, gx0, gy0, zf) {
579
+ var L = GRID.GLOW_LEVELS, ref = GRID.GLOW_REF, pw = GRID.GLOW_POW, reach = 3;
580
+ if (!glowBuckets || glowBuckets.length !== L + 1) { glowBuckets = []; for (var b = 0; b <= L; b++) glowBuckets.push([]); }
581
+ for (b = 0; b <= L; b++) glowBuckets[b].length = 0;
582
+
583
+ var p = { x: 0, y: 0 }, x, y, sx, sy, dx, dy, g, gAvg, bi, prevG, psx, psy, first;
584
+ // vertical lines
585
+ for (x = gx0; x <= x1; x += S) {
586
+ if (!lineNearWell(x, true, reach)) continue;
587
+ prevG = 0; psx = 0; psy = 0; first = true;
588
+ for (y = y0; y <= y1; y += step) {
589
+ warpPoint(x, y, p);
590
+ dx = p.x - x; dy = p.y - y;
591
+ g = Math.pow(Math.min(1, Math.sqrt(dx * dx + dy * dy) / ref), pw);
592
+ sx = panX + p.x * zoom; sy = panY + p.y * zoom;
593
+ if (!first) { gAvg = (g + prevG) * 0.5; bi = Math.round(gAvg * L); if (bi > 0) glowBuckets[bi].push(psx, psy, sx, sy); }
594
+ prevG = g; psx = sx; psy = sy; first = false;
595
+ }
596
+ }
597
+ // horizontal lines
598
+ for (y = gy0; y <= y1; y += S) {
599
+ if (!lineNearWell(y, false, reach)) continue;
600
+ prevG = 0; psx = 0; psy = 0; first = true;
601
+ for (x = x0; x <= x1; x += step) {
602
+ warpPoint(x, y, p);
603
+ dx = p.x - x; dy = p.y - y;
604
+ g = Math.pow(Math.min(1, Math.sqrt(dx * dx + dy * dy) / ref), pw);
605
+ sx = panX + p.x * zoom; sy = panY + p.y * zoom;
606
+ if (!first) { gAvg = (g + prevG) * 0.5; bi = Math.round(gAvg * L); if (bi > 0) glowBuckets[bi].push(psx, psy, sx, sy); }
607
+ prevG = g; psx = sx; psy = sy; first = false;
608
+ }
609
+ }
610
+
611
+ // stroke the buckets additively (dim → bright), so overlaps accumulate glow
612
+ var prevOp = gctx.globalCompositeOperation;
613
+ gctx.globalCompositeOperation = 'lighter';
614
+ gctx.lineWidth = GRID.GLOW_WIDTH;
615
+ if (GRID.GLOW_BLUR > 0) { gctx.shadowColor = 'rgba(' + GRID.GLOW_RGB + ',1)'; gctx.shadowBlur = GRID.GLOW_BLUR; }
616
+ var glowZ = 0.4 + 0.6 * zf; // present but stronger zoomed in
617
+ for (var lv = 1; lv <= L; lv++) {
618
+ var segs = glowBuckets[lv]; if (!segs.length) continue;
619
+ var a = GRID.GLOW_MAX_A * glowZ * (lv / L);
620
+ if (a < 0.003) continue;
621
+ gctx.beginPath();
622
+ for (var i = 0; i < segs.length; i += 4) { gctx.moveTo(segs[i], segs[i + 1]); gctx.lineTo(segs[i + 2], segs[i + 3]); }
623
+ gctx.strokeStyle = 'rgba(' + GRID.GLOW_RGB + ',' + a.toFixed(4) + ')';
624
+ gctx.stroke();
625
+ }
626
+ gctx.shadowBlur = 0;
627
+ gctx.globalCompositeOperation = prevOp;
628
+ }
629
+
630
+ function render(alpha) {
631
+ // snap the moving offset to the device-pixel grid: on a HiDPI screen the
632
+ // motion is still smooth (sub-pixel steps) but glyphs + edges rasterize the
633
+ // same every frame, so labels don't shimmer as an orb drifts.
634
+ var dpr = window.devicePixelRatio || 1;
635
+ gridWells.length = 0;
636
+ for (var k = 0; k < items.length; k++) {
637
+ var it = items[k];
638
+ var ix = it.px + (it.x - it.px) * alpha, iy = it.py + (it.y - it.py) * alpha;
639
+ var tx = Math.round((ix - it.ax) * dpr) / dpr, ty = Math.round((iy - it.ay) * dpr) / dpr;
640
+ it.el.style.transform = 'translate(-50%,-50%) translate(' + tx.toFixed(2) + 'px,' + ty.toFixed(2) + 'px)';
641
+ // a gravity well per orb, sampled at the SAME interpolated centre it renders
642
+ // at, so the dimple sits dead under the orb. sig = well width, k = pull
643
+ // strength (∝ r² — bigger orb, deeper+wider dip), isig2 = 1/sig² (precomputed).
644
+ var sig = it.r * GRID.WELL_R, rr = it.r / GRAV.R_REF;
645
+ gridWells.push({ x: ix, y: iy, sig: sig, k: GRID.DEPTH * rr * rr, isig2: 1 / (sig * sig), cut2: (GRID.CUT * sig) * (GRID.CUT * sig) });
646
+ }
647
+ sampleUnder(); // one throttled element-under-cursor sample shared by the two below
648
+ checkHoverExit(); // drop the hovercard if the orb has drifted off the cursor (task 1895)
649
+ updateGhost(); // idle create-a-project ghost orb over empty sky (task 1896)
650
+ drawMinimap(); // live cluster overview + viewport rect (task 1897)
651
+ updateWhale(); // fly the space whale between orbs on updates (task 1898)
652
+ drawGrid();
653
+ }
654
+
655
+ var DT_MS = 1000 / GRAV.PHYS_HZ, physAcc = 0, physLast = performance.now();
656
+ function tick(now) {
657
+ var ft = now - physLast; physLast = now; if (ft > 250) ft = 250; // clamp tab-switch gaps
658
+ physAcc += ft;
659
+ var guard = 0;
660
+ while (physAcc >= DT_MS && guard < 8) { physStep(DT_MS / 1000); physAcc -= DT_MS; guard++; }
661
+ if (guard >= 8) physAcc = 0; // don't spiral if we fell behind
662
+ render(Math.max(0, Math.min(1, physAcc / DT_MS)));
663
+ requestAnimationFrame(tick);
664
+ }
665
+
666
+ /* ─── hover card ───────────────────────────────────────────────────────── */
667
+ function showHover(proj, el) {
668
+ if (diving || searchOpen || createOpen || panning || !isSpace()) return;
669
+ var rect = el.querySelector('.disc').getBoundingClientRect();
670
+ var sample = proj.type === 'sample' ? '<span class="hc-sample"> · sample</span>' : '';
671
+ var go = proj.type === 'self' ? 'click to enter →'
672
+ : proj.type === 'link' ? 'click to open its page →' : 'click to preview →';
673
+ hovercard.innerHTML =
674
+ '<div class="hc-name"><span class="hc-dot"></span>' + esc(proj.name) + '</div>' +
675
+ '<div class="hc-tag">' + esc(proj.tagline) + sample + '</div>' +
676
+ '<div class="hc-meta"><span>' + proj.builders + (proj.builders === 1 ? ' builder' : ' builders') + '</span><span class="chip">' + esc((proj.tags[0] || '')) + '</span></div>' +
677
+ '<div class="hc-go">' + go + '</div>';
678
+ hovercard.style.setProperty('--acc', proj.accent);
679
+ var cardW = 250;
680
+ var x = Math.max(12, Math.min(W - cardW - 12, rect.left + rect.width / 2 - cardW / 2));
681
+ var y = rect.top - 12;
682
+ hovercard.style.left = x + 'px';
683
+ hovercard.style.top = y + 'px';
684
+ hovercard.style.transform = 'translateY(-100%)';
685
+ hovercard.classList.add('show');
686
+ hoverCircle = el;
687
+ }
688
+ function hideHover() { hovercard.classList.remove('show'); hoverCircle = null; }
689
+
690
+ // Shared pointer sampling (tasks 1895/1896 + this follow-up). The orbs drift
691
+ // under a live gravity sim, so an orb can slide out from under a STATIONARY
692
+ // cursor and the browser never fires mouseleave (hover is only recomputed on
693
+ // pointer events). We track the cursor + how long it's been still, and each
694
+ // frame sample the element directly under it (throttled — elementFromPoint is a
695
+ // layout hit-test). checkHoverExit + updateGhost both read that one sample.
696
+ var hoverCircle = null; // the .circle whose card is showing
697
+ var ptr = { x: -1, y: -1 };
698
+ var lastMoveT = 0; // performance.now() of the last cursor move
699
+ var underEl = null, sampleTick = 0;
700
+ function sampleUnder() {
701
+ if (ptr.x < 0) { underEl = null; return; }
702
+ if ((++sampleTick % 4) === 0) underEl = document.elementFromPoint(ptr.x, ptr.y);
703
+ }
704
+ function underCircle() { return underEl && underEl.closest ? underEl.closest('.circle') : null; }
705
+ function checkHoverExit() {
706
+ // dismiss the card the moment the cursor is no longer over the hovered orb —
707
+ // using the SAME element-under-cursor test the browser would, so the hover
708
+ // region is the whole orb (disc + label), not a tight inscribed circle.
709
+ if (!hoverCircle || panning || anyOverlay() || diving) return;
710
+ if (underCircle() !== hoverCircle) hideHover();
711
+ }
712
+
713
+ // Idle "create a project" ghost orb (task 1896): a faint dashed circle that
714
+ // trails the cursor over EMPTY sky — a light nudge toward the click-to-create
715
+ // gesture. Shown only when not panning / no overlay / not over an orb or UI
716
+ // chrome. elementFromPoint is a layout hit-test, so we sample it a few times a
717
+ // second (not every frame) — enough to also hide when an orb drifts under a
718
+ // still cursor.
719
+ var ghost = document.getElementById('ghost-orb');
720
+ var ghostPlus = ghost ? ghost.querySelector('.ghost-plus') : null;
721
+ var SKY_IDS = { 'space': 1, 'space-canvas': 1, 'space-grid': 1, 'starfield': 1 };
722
+ var GHOST_DWELL = 1000; // cursor must rest still this long over empty sky before it appears
723
+ function updateGhost() {
724
+ if (!ghost) return;
725
+ var onSky = !!(underEl && SKY_IDS[underEl.id]);
726
+ var still = (performance.now() - lastMoveT) > GHOST_DWELL; // gone the moment the cursor moves
727
+ if (isSpace() && !panning && !anyOverlay() && !diving && ptr.x >= 0 && onSky && still) {
728
+ // size the outline to a STARTING project (builders=1), scaled by zoom, so it
729
+ // previews the true size the new orb will be — and shrinks on zoom-out with
730
+ // the orbs (task 1911).
731
+ var d = radiusFor(1) * 2 * zoom;
732
+ ghost.style.width = d + 'px'; ghost.style.height = d + 'px';
733
+ ghost.style.marginLeft = (-d / 2) + 'px'; ghost.style.marginTop = (-d / 2) + 'px';
734
+ ghost.style.left = ptr.x + 'px'; ghost.style.top = ptr.y + 'px';
735
+ if (ghostPlus) ghostPlus.style.fontSize = Math.max(9, d * 0.5) + 'px';
736
+ ghost.classList.add('show');
737
+ } else {
738
+ ghost.classList.remove('show');
739
+ }
740
+ }
741
+
742
+ function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]; }); }
743
+
744
+ /* ─── pan + zoom + press-to-click (space view only) ──────────────────────── */
745
+ var down = false, moved = false, panning = false, middlePan = false;
746
+ var sx = 0, sy = 0, startPanX = 0, startPanY = 0, startCircle = null;
747
+
748
+ // suppress the browser's middle-click autoscroll widget so a wheel-press reads
749
+ // purely as our pan gesture.
750
+ space.addEventListener('mousedown', function (e) { if (e.button === 1) e.preventDefault(); });
751
+
752
+ space.addEventListener('pointerdown', function (e) {
753
+ if (diving || !isSpace() || anyOverlay()) return;
754
+ if (e.button !== 0 && e.button !== 1) return; // left = drag/click, middle = pan-anywhere
755
+ down = true; moved = false; panning = false;
756
+ middlePan = (e.button === 1);
757
+ if (middlePan) {
758
+ // a wheel-press is ALWAYS a pan — never a click/dive/create, and it grabs
759
+ // even over an orb. Force pan mode up front.
760
+ moved = true; panning = true; userTouched = true;
761
+ space.classList.add('panning'); hideHover(); e.preventDefault();
762
+ }
763
+ space.classList.add('grabbing'); // pressing the surface = drag/pan
764
+ sx = e.clientX; sy = e.clientY; startPanX = panX; startPanY = panY;
765
+ startCircle = middlePan ? null : e.target.closest('.circle');
766
+ createAt.x = (e.clientX - panX) / zoom;
767
+ createAt.y = (e.clientY - panY) / zoom;
768
+ });
769
+ window.addEventListener('pointermove', function (e) {
770
+ ptr.x = e.clientX; ptr.y = e.clientY; lastMoveT = performance.now(); // track cursor + last-move time
771
+ if (!down) return;
772
+ if (!moved && Math.hypot(e.clientX - sx, e.clientY - sy) < DEADZONE) return;
773
+ if (!moved) { moved = true; panning = true; userTouched = true; space.classList.add('panning'); hideHover(); }
774
+ panX = startPanX + (e.clientX - sx);
775
+ panY = startPanY + (e.clientY - sy);
776
+ clampPan();
777
+ applyCanvas();
778
+ });
779
+ window.addEventListener('pointerup', function () {
780
+ if (!down) return;
781
+ down = false; space.classList.remove('panning', 'grabbing');
782
+ var wasPan = moved || middlePan; panning = false;
783
+ if (!wasPan) {
784
+ if (startCircle) enterProject(startCircle.dataset.id, startCircle);
785
+ else if (!anyOverlay()) openCreate();
786
+ }
787
+ startCircle = null; middlePan = false;
788
+ });
789
+
790
+ // Scroll the wheel to ZOOM toward the cursor. To PAN, either drag empty sky
791
+ // with the left button or hold the scroll wheel down (middle button) and drag
792
+ // anywhere — see the pointer handlers above.
793
+ space.addEventListener('wheel', function (e) {
794
+ if (diving || anyOverlay() || !isSpace()) return;
795
+ e.preventDefault();
796
+ userTouched = true;
797
+ var cx = e.clientX, cy = e.clientY;
798
+ var vx = (cx - panX) / zoom, vy = (cy - panY) / zoom;
799
+ var factor = Math.exp(-e.deltaY * 0.0016);
800
+ zoom = Math.max(ZMIN, Math.min(ZMAX, zoom * factor));
801
+ panX = cx - vx * zoom; panY = cy - vy * zoom;
802
+ clampPan(); applyCanvas(); hideHover();
803
+ }, { passive: false });
804
+
805
+ /* ─── which circles are on-screen right now (search scope) ───────────────── */
806
+ function inViewProjects() {
807
+ var out = [];
808
+ for (var k = 0; k < items.length; k++) {
809
+ var it = items[k];
810
+ var sxp = panX + it.x * zoom, syp = panY + it.y * zoom;
811
+ if (sxp > -it.r * zoom && sxp < W + it.r * zoom && syp > -it.r * zoom && syp < H + it.r * zoom) out.push(it);
812
+ }
813
+ return out;
814
+ }
815
+
816
+ /* ─── dive into a project ────────────────────────────────────────────────── */
817
+ function enterProject(id, fromEl) {
818
+ var proj = project(id);
819
+ if (!proj || diving) return;
820
+ hideHover();
821
+ diving = true;
822
+ saved = { panX: panX, panY: panY, zoom: zoom, from: browseView };
823
+
824
+ // Cloud Bongos itself (the self orb): fly from space, down through the
825
+ // clouds, into its world (the download page) — the #descent cinematic.
826
+ if (proj.type === 'self') { diveToWorld(); return; }
827
+
828
+ // dive origin: a circle's .disc, else the clicked row, else screen centre
829
+ var src = (fromEl && fromEl.querySelector && fromEl.querySelector('.disc')) || fromEl;
830
+ var rect = (src && src.getBoundingClientRect) ? src.getBoundingClientRect()
831
+ : { left: W / 2, top: H / 2, width: 0, height: 0 };
832
+ var fx = rect.left + rect.width / 2, fy = rect.top + rect.height / 2;
833
+ flash.style.setProperty('--fx', fx + 'px');
834
+ flash.style.setProperty('--fy', fy + 'px');
835
+ flash.classList.remove('go'); void flash.offsetWidth; flash.classList.add('go');
836
+
837
+ space.classList.add('diving');
838
+ var z2 = zoom * 2.7;
839
+ var vx = (fx - panX) / zoom, vy = (fy - panY) / zoom;
840
+ panX = fx - vx * z2; panY = fy - vy * z2; zoom = z2;
841
+ applyCanvas();
842
+ canvas.style.opacity = '0';
843
+
844
+ setTimeout(function () { finishEnter(proj); }, 700);
845
+ }
846
+
847
+ // non-self orbs (real instances / samples) settle into a themed detail page.
848
+ // The Cloud Bongos self orb never reaches here — it takes the #descent dive.
849
+ function finishEnter(proj) {
850
+ space.classList.remove('diving');
851
+ canvas.style.opacity = '1';
852
+ if (saved) { panX = saved.panX; panY = saved.panY; zoom = saved.zoom; applyCanvas(); }
853
+ openDetail(proj);
854
+ diving = false;
855
+ }
856
+
857
+ /* ═══ dive cinematic: space constellation → through the clouds → the world ═══
858
+ (task 1729) Clicking the Cloud Bongos orb plays #descent as a full-screen
859
+ curtain (deep space → sky-island → cloud punch-through → flash), then hands
860
+ off to the download page (view-download) under the flash and lifts the
861
+ curtain. Replayable (reset each dive); skippable via click / key / scroll;
862
+ reduced motion drops straight into the world with no cinematic. */
863
+ function makeDescentStars(el, n, big) {
864
+ if (!el) return;
865
+ var frag = document.createDocumentFragment();
866
+ for (var i = 0; i < n; i++) {
867
+ var s = document.createElement('div');
868
+ s.className = 'star';
869
+ var sz = Math.random() * (big ? 2.2 : 1.4) + (big ? 1.1 : 0.5);
870
+ s.style.width = sz.toFixed(1) + 'px'; s.style.height = sz.toFixed(1) + 'px';
871
+ s.style.left = (Math.random() * 100).toFixed(2) + '%';
872
+ s.style.top = (Math.random() * 100).toFixed(2) + '%';
873
+ s.style.opacity = (Math.random() * 0.5 + 0.4).toFixed(2);
874
+ frag.appendChild(s);
875
+ }
876
+ el.appendChild(frag);
877
+ }
878
+
879
+ function resetDescent() {
880
+ if (!descent) return;
881
+ descent.classList.remove('playing', 'done');
882
+ var f = document.getElementById('descent-stars-far'), n = document.getElementById('descent-stars-near');
883
+ if (f) { f.innerHTML = ''; makeDescentStars(f, 150, false); }
884
+ if (n) { n.innerHTML = ''; makeDescentStars(n, 70, true); }
885
+ void descent.offsetWidth; // reflow so the CSS keyframes restart on replay
886
+ }
887
+
888
+ function diveToWorld() {
889
+ diveLanded = false;
890
+ if (prefersReduced || !descent) { landInWorld(); return; }
891
+ resetDescent();
892
+ descent.classList.add('playing');
893
+ clearTimeout(descentTimer);
894
+ descentTimer = setTimeout(landInWorld, DESCENT_MS);
895
+ }
896
+
897
+ function landInWorld() {
898
+ if (diveLanded) return;
899
+ diveLanded = true;
900
+ clearTimeout(descentTimer);
901
+ // We're deep in the cloud (screen full of cloud). Put the world behind it,
902
+ // then let the cloud keep zooming past + dissolve (crossfade) to reveal it.
903
+ document.body.className = 'view-download';
904
+ document.dispatchEvent(new CustomEvent('cb:show-download'));
905
+ if (saved) { panX = saved.panX; panY = saved.panY; zoom = saved.zoom; applyCanvas(); }
906
+ space.classList.remove('diving');
907
+ canvas.style.opacity = '1';
908
+ if (descent) {
909
+ descent.classList.add('done'); // cloud zooms past + fades out, revealing the world
910
+ setTimeout(function () { descent.classList.remove('playing', 'done'); }, 760);
911
+ }
912
+ diving = false;
913
+ }
914
+
915
+ if (descent) {
916
+ descent.addEventListener('pointerdown', function () { landInWorld(); });
917
+ descent.addEventListener('wheel', function (e) { e.preventDefault(); landInWorld(); }, { passive: false });
918
+ }
919
+
920
+ // top-left ESC button (task 1913) — same as pressing Esc from download/detail
921
+ var escBtn = document.getElementById('esc-btn');
922
+ if (escBtn) escBtn.addEventListener('click', function () { exitToSpace(); });
923
+
924
+ function openDetail(proj) {
925
+ detail.style.setProperty('--acc', proj.accent);
926
+ detail.style.setProperty('--glow', hexGlow(proj.accent));
927
+ var visit = proj.url
928
+ ? '<a class="visit" href="' + esc(proj.url) + '" target="_blank" rel="noopener">Visit ' + esc(proj.name) + ' →</a>'
929
+ : '';
930
+ var sampleNote = proj.type === 'sample' ? '<div class="meta">a sample community instance</div>' : '';
931
+ detail.querySelector('.card').innerHTML =
932
+ '<div class="halo"></div>' +
933
+ '<h2>' + esc(proj.name) + '</h2>' +
934
+ '<div class="tag">' + esc(proj.tagline) + '</div>' +
935
+ '<div class="desc">' + esc(proj.description) + '</div>' +
936
+ '<div class="meta">' + proj.builders + (proj.builders === 1 ? ' builder' : ' builders') + ' · ' + esc(proj.tags.join(' · ')) + '</div>' +
937
+ sampleNote +
938
+ '<div class="actions">' + visit + '</div>' +
939
+ '<div class="soon">Full project story — coming soon</div>';
940
+ document.body.className = 'view-detail';
941
+ }
942
+
943
+ /* ─── rise back up to space ──────────────────────────────────────────────── */
944
+ function exitToSpace() {
945
+ var wasDownload = document.body.classList.contains('view-download');
946
+ if (wasDownload) document.dispatchEvent(new CustomEvent('cb:hide-download'));
947
+ closeSearch();
948
+ // return to whichever browse view the dive started from
949
+ var back = (saved && saved.from) || browseView || 'space';
950
+ if (back === 'table') {
951
+ if (saved) { panX = saved.panX; panY = saved.panY; zoom = saved.zoom; applyCanvas(); }
952
+ document.body.className = 'view-table';
953
+ setBrowseView('table');
954
+ renderTable();
955
+ return;
956
+ }
957
+ document.body.className = 'view-space';
958
+ setBrowseView('space');
959
+ if (!saved) return;
960
+ canvas.style.opacity = '1';
961
+ var z2 = saved.zoom * 1.5;
962
+ var vx = (W / 2 - saved.panX) / saved.zoom, vy = (H / 2 - saved.panY) / saved.zoom;
963
+ space.classList.remove('diving');
964
+ panX = W / 2 - vx * z2; panY = H / 2 - vy * z2; zoom = z2; applyCanvas();
965
+ setTimeout(function () {
966
+ space.classList.add('diving');
967
+ panX = saved.panX; panY = saved.panY; zoom = saved.zoom; applyCanvas();
968
+ }, 20);
969
+ setTimeout(function () { space.classList.remove('diving'); }, 560);
970
+ }
971
+
972
+ /* ─── search overlay ──────────────────────────────────────────────────────── */
973
+ function openSearch() {
974
+ if (searchOpen || createOpen || diving || !isSpace()) return;
975
+ searchOpen = true;
976
+ document.body.classList.add('searching'); // darkens the sky; matches spotlight (task 1892)
977
+ searchbar.classList.add('show');
978
+ searchInput.value = '';
979
+ updateSearch();
980
+ setTimeout(function () { searchInput.focus(); }, 60);
981
+ }
982
+ function closeSearch() {
983
+ searchOpen = false;
984
+ document.body.classList.remove('searching');
985
+ searchbar.classList.remove('show');
986
+ searchInput.value = '';
987
+ items.forEach(function (it) { it.el.classList.remove('dim', 'match'); });
988
+ }
989
+ function updateSearch() {
990
+ var q = searchInput.value.trim().toLowerCase();
991
+ var scope = inViewProjects();
992
+ var scopeIds = {};
993
+ scope.forEach(function (it) { scopeIds[it.proj.id] = true; });
994
+ searchCtxCount.textContent = scope.length;
995
+
996
+ items.forEach(function (it) {
997
+ it.el.classList.remove('dim', 'match');
998
+ if (!q) return;
999
+ if (!scopeIds[it.proj.id]) { it.el.classList.add('dim'); return; }
1000
+ matches(it.proj, q) ? it.el.classList.add('match') : it.el.classList.add('dim');
1001
+ });
1002
+ // AI seam — dormant for v1 (owner: "interaction now, AI wired next").
1003
+ // aiAnswer(q, scope.map(function(it){ return it.proj; }));
1004
+ }
1005
+ function matches(proj, q) {
1006
+ if (!q) return false;
1007
+ var hay = (proj.name + ' ' + proj.tagline + ' ' + proj.description + ' ' + proj.tags.join(' ')).toLowerCase();
1008
+ return q.split(/\s+/).every(function (tok) { return hay.indexOf(tok) !== -1; });
1009
+ }
1010
+ searchInput.addEventListener('input', updateSearch);
1011
+
1012
+ /* ─── create a project ────────────────────────────────────────────────────── */
1013
+ var pendingOrb = null; // the live preview orb shown while the create panel is open
1014
+ var createSeq = 0;
1015
+
1016
+ /* Naming an orb now CONTINUES into the real onboarding (goal 35, task 2087 —
1017
+ the panel's old "full setup coming soon" note, come due): the name rides
1018
+ ?name= into the branded wizard at /create (task 2081), which pre-fills it
1019
+ and runs sign-in → name/home/address → provision → live progress. On the
1020
+ apex we know the sign-in state from /api/bongos/me (same-origin via the
1021
+ Caddy catch-all); a signed-out visitor routes through GitHub sign-in first
1022
+ (the name survives in the return path). On a vanity DOOR the API isn't on
1023
+ this origin, so we send them to the cloudbongos.com wizard and let its own
1024
+ gate handle sign-in. */
1025
+ var ME = null; // { github_login, … } when signed in on the apex
1026
+ var IS_DOOR = !!BRAND.redirect;
1027
+ var signedIn = false; // resolved from /api/bongos/me below (task 2094)
1028
+ var signInOpen = false; // the signed-out create-gate dialog is up
1029
+ var acctEl = document.getElementById('cb-account');
1030
+ var createAuthNote = document.getElementById('create-auth-note');
1031
+ var signinScrim = document.getElementById('signin-scrim');
1032
+ var signinGo = document.getElementById('signin-go');
1033
+ var signinDismiss = document.getElementById('signin-dismiss');
1034
+ var GH_MARK = '<svg viewBox="0 0 16 16" fill="currentColor" aria-hidden="true"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82a7.6 7.6 0 0 1 2-.27c.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8z"/></svg>';
1035
+
1036
+ // Where "Sign in with GitHub" points: back to the orbs, signed in. On a vanity
1037
+ // DOOR the API + session live on cloudbongos.com, so sign-in happens there.
1038
+ function acctSignInHref() {
1039
+ var back = '/gate/?entered=1';
1040
+ return IS_DOOR ? (CLOUDBONGOS_ORIGIN + back)
1041
+ : ('/api/bongos/auth/web/start?return=' + encodeURIComponent(back));
1042
+ }
1043
+
1044
+ // The identity chip (top-right). Signed in = avatar + login (the visitor's
1045
+ // cloudbongos account) + a quiet sign-out; signed out = "Sign in with GitHub".
1046
+ function renderAccount() {
1047
+ if (!acctEl) return;
1048
+ if (signedIn && ME) {
1049
+ var avatar = ME.avatar_url ? '<img src="' + esc(ME.avatar_url) + '" alt="">' : '';
1050
+ acctEl.innerHTML = '<span class="cb-me">' + avatar +
1051
+ '<span class="cb-name">' + esc(ME.github_login || 'signed in') + '</span>' +
1052
+ '<a class="cb-out" href="#" id="cb-signout">sign out</a></span>';
1053
+ var out = document.getElementById('cb-signout');
1054
+ if (out) out.addEventListener('click', function (e) {
1055
+ e.preventDefault();
1056
+ // Stay on the orbs after signing out (task 1002262): a bare reload of
1057
+ // /gate/ with no ?entered=1 drops a visitor who typed "bongos" back on
1058
+ // the entry terminal. Force the direct-orbs flag so the reload re-opens
1059
+ // the sky, now signed out. location.replace so Back doesn't return to the
1060
+ // signed-in view; falls back to a plain reload if URL rewriting throws.
1061
+ var stayOnOrbs = function () {
1062
+ try {
1063
+ var u = new URL(location.href);
1064
+ u.searchParams.set('entered', '1');
1065
+ location.replace(u.pathname + u.search + u.hash);
1066
+ } catch (_) { location.reload(); }
1067
+ };
1068
+ fetch('/api/bongos/auth/logout', { method: 'POST', credentials: 'same-origin' })
1069
+ .then(stayOnOrbs, stayOnOrbs);
1070
+ });
1071
+ } else {
1072
+ acctEl.innerHTML = '<a class="cb-signin" href="' + acctSignInHref() + '">' + GH_MARK + 'Sign in with GitHub</a>';
1073
+ }
1074
+ }
1075
+
1076
+ // Signed-out: gray the create-a-project affordances + relabel the ghost orb.
1077
+ function refreshCreateAffordances() {
1078
+ if (ghost) ghost.classList.toggle('locked', !signedIn);
1079
+ var gl = ghost && ghost.querySelector('.ghost-label');
1080
+ if (gl) gl.textContent = signedIn ? 'click to create a project' : 'sign in to create a project';
1081
+ if (tvCreate) tvCreate.classList.toggle('locked', !signedIn);
1082
+ }
1083
+
1084
+ // The signed-out create gate: a click on "create a project" opens this instead
1085
+ // of the name field, encouraging GitHub sign-in first (task 2094).
1086
+ function openSignInPrompt() {
1087
+ if (signInOpen || diving) return;
1088
+ signInOpen = true;
1089
+ if (signinGo) signinGo.setAttribute('href', acctSignInHref());
1090
+ if (signinScrim) signinScrim.classList.add('show');
1091
+ }
1092
+ function closeSignIn() {
1093
+ signInOpen = false;
1094
+ if (signinScrim) signinScrim.classList.remove('show');
1095
+ }
1096
+ if (signinDismiss) signinDismiss.addEventListener('click', closeSignIn);
1097
+ if (signinScrim) signinScrim.addEventListener('pointerdown', function (e) { if (e.target === signinScrim) closeSignIn(); });
1098
+
1099
+ renderAccount(); // signed-out chip shows immediately
1100
+ refreshCreateAffordances();
1101
+
1102
+ if (!IS_DOOR) {
1103
+ try {
1104
+ fetch('/api/bongos/me', { credentials: 'same-origin' })
1105
+ .then(function (res) { if (!res.ok) throw new Error(String(res.status)); return res.json(); })
1106
+ .then(function (data) {
1107
+ ME = data.builder || data.me || data;
1108
+ signedIn = !!(ME && ME.github_login);
1109
+ renderAccount();
1110
+ refreshCreateAffordances();
1111
+ if (createAuthNote && signedIn) {
1112
+ createAuthNote.textContent = 'Continues into the build wizard — signed in as ' + ME.github_login + '.';
1113
+ }
1114
+ })
1115
+ .catch(function () { /* signed out or unreachable: signed-out visuals stand */ });
1116
+ } catch (_) { /* very old browsers: the default note + signed-out chip stand */ }
1117
+ }
1118
+
1119
+ // After the orb joins the sky, carry the name into the wizard. A moment of
1120
+ // delay on purpose: the visitor should SEE their circle become part of the
1121
+ // world before the wizard takes over.
1122
+ function handoffToWizard(name) {
1123
+ var base = IS_DOOR ? CLOUDBONGOS_ORIGIN : '';
1124
+ var createPath = '/create?name=' + encodeURIComponent(name);
1125
+ var target = (ME || IS_DOOR)
1126
+ ? base + createPath
1127
+ : base + '/api/bongos/auth/web/start?return=' + encodeURIComponent(createPath);
1128
+ var toast = document.getElementById('handoff-toast');
1129
+ if (toast) {
1130
+ toast.textContent = 'Opening the build wizard for “' + name + '”…';
1131
+ toast.classList.add('show');
1132
+ }
1133
+ setTimeout(function () { location.assign(target); }, 950);
1134
+ }
1135
+
1136
+ function openCreate() {
1137
+ if (createOpen || diving) return;
1138
+ if (!signedIn) { openSignInPrompt(); return; } // task 2094: sign in with GitHub first
1139
+ createOpen = true;
1140
+ spawnPendingOrb(); // the orb appears immediately, alongside the popup (task 1909)
1141
+ scrim.classList.add('show');
1142
+ createName.value = '';
1143
+ setTimeout(function () { createName.focus(); }, 60);
1144
+ }
1145
+
1146
+ // drop a provisional orb at the click point. It is deliberately kept OUT of the
1147
+ // physics `items` array (task 1912): it stays stationary and neither exerts nor
1148
+ // feels gravity until confirmed. We apply the centering transform ourselves
1149
+ // (render() does that for real orbs) so it sits centred on the click point.
1150
+ function spawnPendingOrb() {
1151
+ var id = 'new-' + (++createSeq);
1152
+ var proj = {
1153
+ id: id, name: 'New project', type: 'sample', builders: 1, accent: '#fb923c',
1154
+ tagline: 'Your new Cloud Bongos project',
1155
+ description: 'A brand-new project — set the beat and invite builders.',
1156
+ tags: ['new', 'project']
1157
+ };
1158
+ PROJECTS.push(proj);
1159
+ var it = makeCircle(proj, createAt.x, createAt.y);
1160
+ it.el.classList.add('pending');
1161
+ it.el.style.transform = 'translate(-50%,-50%)'; // static + centred (not driven by render)
1162
+ byId[id] = proj;
1163
+ pendingOrb = it;
1164
+ }
1165
+
1166
+ function removePendingOrb() {
1167
+ if (!pendingOrb) return;
1168
+ var it = pendingOrb; pendingOrb = null;
1169
+ if (hoverCircle === it.el) hideHover();
1170
+ var p = PROJECTS.indexOf(it.proj); if (p >= 0) PROJECTS.splice(p, 1);
1171
+ delete byId[it.proj.id];
1172
+ if (it.el && it.el.parentNode) it.el.parentNode.removeChild(it.el);
1173
+ // never entered the sim, so nothing to rebalance.
1174
+ }
1175
+
1176
+ function closeCreate() {
1177
+ createOpen = false;
1178
+ scrim.classList.remove('show');
1179
+ removePendingOrb(); // cancelled (esc / outside click / cancel) → the preview orb goes away
1180
+ }
1181
+
1182
+ function doCreate() {
1183
+ var name = createName.value.trim();
1184
+ if (!name) { createName.focus(); return; }
1185
+ if (pendingOrb) {
1186
+ var it = pendingOrb; pendingOrb = null; // keep it — closeCreate must not remove it now
1187
+ it.proj.name = name;
1188
+ it.el.classList.remove('pending');
1189
+ it.el.style.transform = ''; // hand transform control back to render()
1190
+ var nameEl = it.el.querySelector('.label .name');
1191
+ if (nameEl) nameEl.textContent = name;
1192
+ // NOW it joins the sim as a live orb: give it the field swirl + rebalance.
1193
+ items.push(it); byId[it.proj.id] = it.proj;
1194
+ var c = barycenter(), jit = GRAV.SWIRL * 60 * GRAV.SWIRL_JITTER;
1195
+ it.vx = -(it.y - c.y) * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1196
+ it.vy = (it.x - c.x) * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1197
+ killDrift(); keTarget = kineticEnergy();
1198
+ }
1199
+ renderTable(); // keep the directory in sync if it's open
1200
+ closeCreate();
1201
+ handoffToWizard(name); // the orb is in the sky — now make it real (task 2087)
1202
+ }
1203
+ createGo.addEventListener('click', doCreate);
1204
+ createCancel.addEventListener('click', closeCreate);
1205
+ createName.addEventListener('keydown', function (e) { if (e.key === 'Enter') { e.preventDefault(); doCreate(); } });
1206
+ scrim.addEventListener('pointerdown', function (e) { if (e.target === scrim) closeCreate(); });
1207
+
1208
+ /* ═══ view toggle + table directory (task 1731) ════════════════════════════
1209
+ A scannable, searchable, sortable alternative to the circle sky. Rows are
1210
+ built from the same PROJECTS data; clicking a row dives in exactly like an
1211
+ orb. Circles stay the default landing (owner's call); Table is a toggle. */
1212
+ function setBrowseView(v) {
1213
+ browseView = v;
1214
+ if (vtCircles) { vtCircles.classList.toggle('active', v === 'space'); vtCircles.setAttribute('aria-selected', v === 'space'); }
1215
+ if (vtTable) { vtTable.classList.toggle('active', v === 'table'); vtTable.setAttribute('aria-selected', v === 'table'); }
1216
+ }
1217
+ function showCircles() {
1218
+ if (diving) return;
1219
+ document.body.className = 'view-space';
1220
+ setBrowseView('space');
1221
+ if (!userTouched) fitToView();
1222
+ }
1223
+ function showTable() {
1224
+ if (diving) return;
1225
+ hideHover(); closeSearch();
1226
+ document.body.className = 'view-table';
1227
+ setBrowseView('table');
1228
+ renderTable();
1229
+ setTimeout(function () { if (tvSearchInput) tvSearchInput.focus(); }, 40);
1230
+ }
1231
+ if (vtCircles) vtCircles.addEventListener('click', showCircles);
1232
+ if (vtTable) vtTable.addEventListener('click', showTable);
1233
+ if (tvCreate) tvCreate.addEventListener('click', openCreate);
1234
+
1235
+ function typeMeta(t) {
1236
+ if (t === 'self') return { label: 'Platform', cls: 'tp-self', act: 'Enter →' };
1237
+ if (t === 'link') return { label: 'Live', cls: 'tp-live', act: 'Open →' };
1238
+ return { label: 'Sample', cls: 'tp-sample', act: 'Preview →' };
1239
+ }
1240
+ function tvSortValue(p, key) {
1241
+ if (key === 'builders') return (typeof p.builders === 'number' ? p.builders : -1);
1242
+ if (key === 'type') return p.type;
1243
+ return (p.name || '').toLowerCase();
1244
+ }
1245
+ function tvRows() {
1246
+ var q = ((tvSearchInput && tvSearchInput.value) || '').trim().toLowerCase();
1247
+ var rows = PROJECTS.filter(function (p) { return !q || matches(p, q); });
1248
+ var key = tvSort.key, dir = tvSort.dir;
1249
+ rows.sort(function (a, b) {
1250
+ var av = tvSortValue(a, key), bv = tvSortValue(b, key);
1251
+ if (av < bv) return -1 * dir;
1252
+ if (av > bv) return 1 * dir;
1253
+ if ((a.builders || 0) !== (b.builders || 0)) return (b.builders || 0) - (a.builders || 0); // tie-break
1254
+ return (a.name || '').localeCompare(b.name || '');
1255
+ });
1256
+ return { rows: rows, q: q };
1257
+ }
1258
+ function renderTable() {
1259
+ if (!tvBody) return;
1260
+ var f = tvRows();
1261
+ tvBody.innerHTML = '';
1262
+ if (!f.rows.length) {
1263
+ var tr0 = document.createElement('tr');
1264
+ tr0.innerHTML = '<td class="tv-empty" colspan="5">No projects match “' + esc(f.q) + '”</td>';
1265
+ tvBody.appendChild(tr0);
1266
+ } else {
1267
+ var frag = document.createDocumentFragment();
1268
+ f.rows.forEach(function (p) {
1269
+ var tm = typeMeta(p.type);
1270
+ var chips = (p.tags || []).slice(0, 3).map(function (t) { return '<span class="tv-chip">' + esc(t) + '</span>'; }).join('');
1271
+ var tr = document.createElement('tr');
1272
+ tr.className = 'tv-row';
1273
+ tr.dataset.id = p.id;
1274
+ tr.style.setProperty('--acc', p.accent);
1275
+ tr.innerHTML =
1276
+ '<td class="tv-proj"><span class="tv-dot"></span><span class="tv-proj-txt">' +
1277
+ '<span class="tv-name">' + esc(p.name) + (p.type === 'self' ? '<span class="tv-self">this platform</span>' : '') + '</span>' +
1278
+ '<span class="tv-tag">' + esc(p.tagline || '') + '</span></span></td>' +
1279
+ '<td class="tv-num">' + (typeof p.builders === 'number' ? p.builders : '—') + '</td>' +
1280
+ '<td class="col-type"><span class="tv-pill ' + tm.cls + '">' + tm.label + '</span></td>' +
1281
+ '<td class="tv-tags col-tags"><span class="tv-tagwrap">' + chips + '</span></td>' +
1282
+ '<td class="tv-act col-act"><button class="tv-go" type="button">' + tm.act + '</button></td>';
1283
+ frag.appendChild(tr);
1284
+ });
1285
+ tvBody.appendChild(frag);
1286
+ }
1287
+ if (tvCount) tvCount.textContent = f.q
1288
+ ? (f.rows.length + ' of ' + PROJECTS.length)
1289
+ : (PROJECTS.length + ' project' + (PROJECTS.length === 1 ? '' : 's'));
1290
+ updateSortHeaders();
1291
+ }
1292
+ function updateSortHeaders() {
1293
+ var ths = document.querySelectorAll('#table-view th.sortable');
1294
+ Array.prototype.forEach.call(ths, function (th) {
1295
+ var on = th.dataset.sort === tvSort.key;
1296
+ th.classList.toggle('sorted', on);
1297
+ th.setAttribute('aria-sort', on ? (tvSort.dir === 1 ? 'ascending' : 'descending') : 'none');
1298
+ var ind = th.querySelector('.sort-ind');
1299
+ if (ind) ind.textContent = on ? (tvSort.dir === 1 ? '▲' : '▼') : '';
1300
+ });
1301
+ }
1302
+ if (tvBody) tvBody.addEventListener('click', function (e) {
1303
+ var tr = e.target.closest('.tv-row');
1304
+ if (tr) enterProject(tr.dataset.id, tr);
1305
+ });
1306
+ Array.prototype.forEach.call(document.querySelectorAll('#table-view th.sortable'), function (th) {
1307
+ th.addEventListener('click', function () {
1308
+ var key = th.dataset.sort;
1309
+ if (tvSort.key === key) tvSort.dir *= -1;
1310
+ else { tvSort.key = key; tvSort.dir = (key === 'builders') ? -1 : 1; }
1311
+ renderTable();
1312
+ });
1313
+ });
1314
+ if (tvSearchInput) tvSearchInput.addEventListener('input', renderTable);
1315
+
1316
+ /* ═══ the entry gate — type "bongos" to enter (relocated into space) ═══════ */
1317
+ var ENTRY_TARGET = 'bongos', eProg = 0, eDone = false;
1318
+ function eRender() { var h = ''; for (var i = 0; i < eProg; i++) h += '<b>' + ENTRY_TARGET[i] + '</b>'; eTyped.innerHTML = h; }
1319
+ function eShake() { eCli.classList.add('shake'); setTimeout(function () { eCli.classList.remove('shake'); }, 340); }
1320
+ function eFlashHint(msg) {
1321
+ eHint.classList.add('flash-x'); if (msg) eHint.innerHTML = msg;
1322
+ setTimeout(function () { eHint.classList.remove('flash-x'); if (!eDone && !entering) eHint.innerHTML = 'type <b>bongos</b> to enter'; }, 820);
1323
+ }
1324
+ function eComplete() { eDone = true; eCaret.style.display = 'none'; eHint.innerHTML = 'press <b>enter</b>'; eEnter.classList.add('lit'); }
1325
+ function entryKey(e) {
1326
+ if (entering) return;
1327
+ if (e.key === 'Enter') { e.preventDefault(); if (eDone) enterSite(); else { eShake(); eFlashHint('finish typing <b>bongos</b>'); } return; }
1328
+ if (e.key === 'Backspace') { e.preventDefault(); if (eProg > 0 && !eDone) { eProg--; eRender(); } return; }
1329
+ if (e.key && e.key.length === 1 && !eDone) {
1330
+ e.preventDefault();
1331
+ if (e.key.toLowerCase() === ENTRY_TARGET[eProg]) { eProg++; eRender(); if (eProg === ENTRY_TARGET.length) eComplete(); }
1332
+ else eShake();
1333
+ }
1334
+ }
1335
+ if (eEnter) eEnter.addEventListener('click', function () { if (eDone && !entering) enterSite(); });
1336
+
1337
+ function enterSite() {
1338
+ if (entering) return;
1339
+ entering = true;
1340
+ flash.style.setProperty('--fx', '50%'); flash.style.setProperty('--fy', '46%');
1341
+ flash.classList.remove('go'); void flash.offsetWidth; flash.classList.add('go');
1342
+ entryEl.classList.add('leaving');
1343
+ // Vanity door (claudebongos.com / geminibongos.com): play the same enter
1344
+ // flash, then hand off to cloudbongos.com and land straight on the orbs
1345
+ // (?entered=1). A door never reveals a local space of its own — the orbs
1346
+ // live on cloudbongos.com.
1347
+ if (BRAND.redirect) {
1348
+ setTimeout(function () { location.href = BRAND.redirect; }, 420);
1349
+ return;
1350
+ }
1351
+ setTimeout(function () {
1352
+ document.body.className = 'view-space';
1353
+ setBrowseView('space');
1354
+ userTouched = false;
1355
+ fitToView();
1356
+ canvas.style.opacity = '0';
1357
+ canvas.style.transition = 'opacity .55s ease';
1358
+ setTimeout(function () { canvas.style.opacity = '1'; }, 30);
1359
+ }, 380);
1360
+ }
1361
+
1362
+ // ESC on the circles page rises back UP to the entry gate — retype bongos.
1363
+ function backToEntry() {
1364
+ hideHover(); closeSearch();
1365
+ setBrowseView('space');
1366
+ entering = false; eProg = 0; eDone = false;
1367
+ eEnter.classList.remove('lit'); eCaret.style.display = 'inline-block';
1368
+ eHint.innerHTML = 'type <b>bongos</b> to enter'; eRender();
1369
+ entryEl.classList.remove('leaving');
1370
+ flash.style.setProperty('--fx', '50%'); flash.style.setProperty('--fy', '46%');
1371
+ flash.classList.remove('go'); void flash.offsetWidth; flash.classList.add('go');
1372
+ document.body.className = 'view-entry';
1373
+ canvas.style.opacity = '0';
1374
+ userTouched = false;
1375
+ }
1376
+
1377
+ // Open the orbs directly — no type-bongos step, no entry flash. Used when a
1378
+ // vanity door already handled the entry and redirected here (?entered=1), so
1379
+ // the visitor lands right on the sky instead of typing "bongos" twice.
1380
+ function openSpaceDirect() {
1381
+ entering = true;
1382
+ eCaret.style.display = 'none';
1383
+ entryEl.classList.add('leaving');
1384
+ document.body.className = 'view-space';
1385
+ // The <head> anti-flash rule (html.direct-orbs #entry{display:none}) has done
1386
+ // its job — hand off to the view classes and drop it so a later ESC can bring
1387
+ // the entry back (backToEntry → view-entry).
1388
+ try { document.documentElement.classList.remove('direct-orbs'); } catch (e) {}
1389
+ setBrowseView('space');
1390
+ userTouched = false;
1391
+ fitToView();
1392
+ canvas.style.opacity = '0';
1393
+ canvas.style.transition = 'opacity .55s ease';
1394
+ setTimeout(function () { canvas.style.opacity = '1'; }, 30);
1395
+ }
1396
+
1397
+ /* ─── starfield (task 1725: actually in space) ───────────────────────────── */
1398
+ function makeStars(n) {
1399
+ if (!starfield) return;
1400
+ var frag = document.createDocumentFragment();
1401
+ for (var i = 0; i < n; i++) {
1402
+ var s = document.createElement('div');
1403
+ s.className = 'star';
1404
+ var big = Math.random() < 0.14;
1405
+ var sz = big ? (Math.random() * 2.0 + 1.8) : (Math.random() * 1.3 + 0.6);
1406
+ s.style.width = sz.toFixed(1) + 'px'; s.style.height = sz.toFixed(1) + 'px';
1407
+ s.style.left = (Math.random() * 100).toFixed(2) + '%';
1408
+ s.style.top = (Math.random() * 100).toFixed(2) + '%';
1409
+ s.style.opacity = (Math.random() * 0.55 + 0.3).toFixed(2);
1410
+ if (Math.random() < 0.35) {
1411
+ s.style.animation = 'twinkle ' + (2.2 + Math.random() * 3).toFixed(1) + 's ease-in-out infinite';
1412
+ s.style.animationDelay = (Math.random() * 3).toFixed(1) + 's';
1413
+ }
1414
+ frag.appendChild(s);
1415
+ }
1416
+ starfield.appendChild(frag);
1417
+ }
1418
+
1419
+ /* ─── Bongo Buddha idle mascot (ADR 0088) ──────────────────────────────────
1420
+ Injected + cycled above the download-page search bar. The sprite's own
1421
+ motion IS the bongo-play, so under prefers-reduced-motion we hold a single
1422
+ frame rather than cycling. 44 webp frames @ 12fps — same pattern as
1423
+ anim/mascot.html. Lives inside #land, which only renders in view-download,
1424
+ so it costs nothing off that page. */
1425
+ function mountBuddha() {
1426
+ var fig = document.getElementById('buddha-fig');
1427
+ if (!fig) return;
1428
+ var N = 44, FPS = 12, frames = [];
1429
+ for (var i = 0; i < N; i++) {
1430
+ var im = document.createElement('img');
1431
+ im.src = '../assets/anim/idle/frame_' + (i < 10 ? '0' + i : i) + '.webp';
1432
+ im.alt = ''; im.decoding = 'async';
1433
+ im.style.display = i === 0 ? 'block' : 'none';
1434
+ fig.appendChild(im); frames.push(im);
1435
+ }
1436
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
1437
+ // rAF + time accumulator: locked to FPS regardless of display refresh, and
1438
+ // it pauses when the tab is hidden (matches the download scene's tick loop).
1439
+ var cur = 0, prev = performance.now(), acc = 0, SPF = 1000 / FPS;
1440
+ (function loop(now) {
1441
+ acc += now - prev; prev = now;
1442
+ if (acc >= SPF) {
1443
+ var adv = Math.floor(acc / SPF); acc -= adv * SPF;
1444
+ frames[cur].style.display = 'none';
1445
+ cur = (cur + adv) % N;
1446
+ frames[cur].style.display = 'block';
1447
+ }
1448
+ requestAnimationFrame(loop);
1449
+ })(prev);
1450
+ }
1451
+
1452
+ /* ─── self-typing logo (task 1902) ─────────────────────────────────────────
1453
+ The top-left wordmark idles as the brand "Cloud Bongos" for a while after
1454
+ load, then comes alive like a terminal: it swaps the lead word "Cloud" for a
1455
+ lowercase platform (claude, gemini, chatgpt, …) and re-TYPES "bongos" after
1456
+ it, with a blinking caret — a living nudge that the ritual works in any AI.
1457
+ Reduced motion holds the static brand wordmark. */
1458
+ function mountLogoTerminal() {
1459
+ // Re-entrancy guard: only one typing chain may ever run. Two concurrent
1460
+ // cycles mutating #mk-tail would interleave letters into garbled text; the
1461
+ // single boot call can't do that today, but a future re-mount (or a
1462
+ // tab-throttle timer catch-up) must not be able to spawn a second chain.
1463
+ if (mountLogoTerminal._mounted) return;
1464
+ mountLogoTerminal._mounted = true;
1465
+ var lead = document.getElementById('mk-lead');
1466
+ var tail = document.getElementById('mk-tail');
1467
+ var caret = document.getElementById('mk-caret');
1468
+ if (!lead || !tail) return;
1469
+ lead.textContent = BRAND.display.toLowerCase(); // rest: lowercase (terminal format, door-aware)
1470
+ tail.textContent = ' bongos'; // lowercase "cloud bongos" at rest, matching the terminal
1471
+ if (prefersReduced) return;
1472
+
1473
+ var PLATFORMS = ['claude', 'gemini', 'chatgpt', 'copilot', 'grok', 'perplexity'];
1474
+ // leading nbsp = the word gap, typed AFTER the pre-type blink so the caret
1475
+ // first sits flush against the platform word (no space), like the entry screen.
1476
+ var CHARS = ' bongos'.split('');
1477
+ var pi = 0;
1478
+ var IDLE_START = 8000, TYPE_MS = 210, ERASE_MS = 130, HOLD_MS = 5200, GAP_MS = 1000, BLINK_BEFORE = 2100;
1479
+
1480
+ // type/erase one letter at a time as its own popping <b> (the entry terminal's
1481
+ // per-letter pop), so the caret always trails the last real character flush.
1482
+ function typeWord(done) {
1483
+ tail.innerHTML = '';
1484
+ var i = 0;
1485
+ (function step() {
1486
+ if (i >= CHARS.length) { done(); return; }
1487
+ var b = document.createElement('b');
1488
+ b.className = 'mk-ch';
1489
+ b.textContent = CHARS[i];
1490
+ tail.appendChild(b);
1491
+ i++;
1492
+ setTimeout(step, TYPE_MS);
1493
+ })();
1494
+ }
1495
+ function eraseWord(done) {
1496
+ (function step() {
1497
+ if (!tail.lastChild) { done(); return; }
1498
+ tail.removeChild(tail.lastChild);
1499
+ setTimeout(step, ERASE_MS);
1500
+ })();
1501
+ }
1502
+ function cycle() {
1503
+ lead.classList.add('swap'); // fade the old lead out
1504
+ setTimeout(function () {
1505
+ lead.textContent = PLATFORMS[pi]; // lowercase platform
1506
+ lead.classList.remove('swap');
1507
+ tail.innerHTML = ''; // empty → caret sits flush right after the new word
1508
+ if (caret) caret.classList.add('on');
1509
+ setTimeout(function () { // let the caret blink a couple of times first
1510
+ typeWord(function () { // then type " bongos" letter by letter
1511
+ setTimeout(function () { // hold on the full "{platform} bongos"
1512
+ eraseWord(function () {
1513
+ pi = (pi + 1) % PLATFORMS.length;
1514
+ setTimeout(cycle, GAP_MS);
1515
+ });
1516
+ }, HOLD_MS);
1517
+ });
1518
+ }, BLINK_BEFORE);
1519
+ }, 360);
1520
+ }
1521
+ setTimeout(cycle, IDLE_START);
1522
+ }
1523
+
1524
+ /* ─── minimap (task 1897) ─────────────────────────────────────────────────
1525
+ A live overview of the whole orb cluster in the bottom-right, with a
1526
+ viewport rectangle for the current pan/zoom. World is mapped from a fixed
1527
+ square centred on the origin (killDrift keeps the cluster there) so the
1528
+ view rect doesn't jitter as the bounds breathe. Click/drag it to recentre. */
1529
+ var mmEl = document.getElementById('minimap');
1530
+ var mmCanvas = document.getElementById('minimap-canvas');
1531
+ var mmctx = mmCanvas ? mmCanvas.getContext('2d') : null;
1532
+ var mmW = 0, mmH = 0, mmDPR = 1;
1533
+ var MM_HALF = 1000; // world half-extent (px) the minimap spans from the origin
1534
+
1535
+ function sizeMinimap() {
1536
+ if (!mmCanvas || !mmEl) return;
1537
+ mmDPR = window.devicePixelRatio || 1;
1538
+ var r = mmEl.getBoundingClientRect();
1539
+ mmW = r.width; mmH = r.height;
1540
+ mmCanvas.width = Math.max(1, Math.round(mmW * mmDPR));
1541
+ mmCanvas.height = Math.max(1, Math.round(mmH * mmDPR));
1542
+ }
1543
+ function mmScale() { return Math.min(mmW, mmH) / (2 * MM_HALF); }
1544
+
1545
+ function drawMinimap() {
1546
+ if (!mmctx || !isSpace()) return;
1547
+ // sizeMinimap() ran once at boot while the minimap was still display:none
1548
+ // (body.view-entry), so it measured 0×0 — and it was never re-run on entering
1549
+ // the orbs. Re-measure the first frame it's actually on screen: otherwise it
1550
+ // renders blank AND mmScale() stays 0, so a click divides by zero and blows
1551
+ // panX/panY up to NaN — which kills the grid and freezes every control.
1552
+ if (!mmW) sizeMinimap();
1553
+ if (!mmW) return; // still not laid out this frame — retry next frame
1554
+ mmctx.setTransform(mmDPR, 0, 0, mmDPR, 0, 0);
1555
+ mmctx.clearRect(0, 0, mmW, mmH);
1556
+ var s = mmScale(), cxp = mmW / 2, cyp = mmH / 2;
1557
+ // the viewport rectangle (visible world region → map)
1558
+ var vx0 = (0 - panX) / zoom, vy0 = (0 - panY) / zoom;
1559
+ var vx1 = (W - panX) / zoom, vy1 = (H - panY) / zoom;
1560
+ mmctx.fillStyle = 'rgba(255,217,138,0.10)';
1561
+ mmctx.strokeStyle = 'rgba(255,217,138,0.85)';
1562
+ mmctx.lineWidth = 1;
1563
+ mmctx.fillRect(cxp + vx0 * s, cyp + vy0 * s, (vx1 - vx0) * s, (vy1 - vy0) * s);
1564
+ mmctx.strokeRect(cxp + vx0 * s, cyp + vy0 * s, (vx1 - vx0) * s, (vy1 - vy0) * s);
1565
+ // the orbs
1566
+ for (var i = 0; i < items.length; i++) {
1567
+ var it = items[i], mx = cxp + it.x * s, my = cyp + it.y * s, rr = Math.max(1.6, it.r * s);
1568
+ mmctx.beginPath(); mmctx.arc(mx, my, rr, 0, Math.PI * 2);
1569
+ mmctx.fillStyle = it.proj.accent || '#fb923c';
1570
+ mmctx.globalAlpha = it.proj.type === 'self' ? 1 : 0.9; mmctx.fill();
1571
+ if (it.proj.type === 'self') { mmctx.globalAlpha = 1; mmctx.lineWidth = 1.5; mmctx.strokeStyle = 'rgba(255,255,255,.9)'; mmctx.stroke(); }
1572
+ }
1573
+ mmctx.globalAlpha = 1;
1574
+ }
1575
+
1576
+ if (mmCanvas) {
1577
+ var mmDown = false;
1578
+ var mmRecenter = function (e) {
1579
+ // Measure the minimap LIVE from its on-screen rect rather than the cached
1580
+ // mmW/mmH. sizeMinimap() first runs at boot while the minimap is still
1581
+ // display:none (0×0) and only re-runs on window resize — so before any resize
1582
+ // the cached scale is 0, and a click would divide pan up to ±Infinity → NaN,
1583
+ // wiping the grid and freezing every control. The live rect is always correct.
1584
+ var r = mmCanvas.getBoundingClientRect();
1585
+ var s = Math.min(r.width, r.height) / (2 * MM_HALF);
1586
+ if (!s) return; // truly not laid out (hidden / zero-size) — ignore, never /0
1587
+ var wx = (e.clientX - r.left - r.width / 2) / s, wy = (e.clientY - r.top - r.height / 2) / s;
1588
+ userTouched = true;
1589
+ panX = W / 2 - wx * zoom; panY = H / 2 - wy * zoom;
1590
+ clampPan(); applyCanvas(); hideHover();
1591
+ };
1592
+ // stopPropagation so a click on the minimap (a child of #space) never reaches
1593
+ // the #space pan/create handler underneath it.
1594
+ mmCanvas.addEventListener('pointerdown', function (e) {
1595
+ if (diving || anyOverlay()) return;
1596
+ mmDown = true; try { mmCanvas.setPointerCapture(e.pointerId); } catch (_) {}
1597
+ mmRecenter(e); e.preventDefault(); e.stopPropagation();
1598
+ });
1599
+ mmCanvas.addEventListener('pointermove', function (e) { if (mmDown) { mmRecenter(e); e.stopPropagation(); } });
1600
+ window.addEventListener('pointerup', function () { mmDown = false; });
1601
+ }
1602
+
1603
+ /* ─── space whale (task 1898) ──────────────────────────────────────────────
1604
+ A whale flies from the Cloud Bongos orb to a project when it UPDATES, and
1605
+ flies BACK (project → Cloud Bongos) when a project pushes a module. It's
1606
+ positioned in screen space each frame from the two orbs' live positions, so
1607
+ it tracks the drifting cluster + any pan/zoom. On arrival the target orb
1608
+ pings a gold ring. The self-driving loop keeps the sky alive; a real feed can
1609
+ drive it through the window.cbWhale seam (the aiAnswer() "wire it later"
1610
+ precedent). The art faces left, so a rightward flight flips horizontally. */
1611
+ var whaleEl = document.getElementById('whale');
1612
+ var whaleImg = document.getElementById('whale-img');
1613
+ var WHALE_SRC = '../world/assets/creatures/creature-whale-glide.png'; // cargo pose = ../world/assets/creatures/creature-whale-cargo.png
1614
+ var flight = null;
1615
+ var WHALE_DUR = 8500; // slow, drifting flight
1616
+ var WHALE_LEAD_CAP = 2.2; // seconds — cap the velocity extrapolation so a chaotic orb can't fling the aim point
1617
+
1618
+ function itemForId(id) { for (var i = 0; i < items.length; i++) if (items[i].proj.id === id) return items[i]; return null; }
1619
+ function minOrbDiam() { var m = Infinity; for (var i = 0; i < items.length; i++) m = Math.min(m, items[i].r * 2); return isFinite(m) ? m : 80; }
1620
+
1621
+ function startFlight(fromId, toId, type) {
1622
+ if (flight || !whaleEl || prefersReduced) return;
1623
+ var a = itemForId(fromId), b = itemForId(toId);
1624
+ if (!a || !b || a === b) return;
1625
+ if (whaleImg && whaleImg.getAttribute('src') !== WHALE_SRC) whaleImg.src = WHALE_SRC;
1626
+ // sx/sy = launch point (source orb, world coords). worldW = whale width in
1627
+ // world px — no more than 1/3 of the SMALLEST orb, so it scales with the orbs
1628
+ // and reads as a little creature next to a world. dir captured once (no mid-
1629
+ // flight flips). Everything lives in world space so it pans/zooms with the sky.
1630
+ flight = { sx: a.x, sy: a.y, b: b, t0: performance.now(), dur: WHALE_DUR,
1631
+ type: type || 'update', worldW: minOrbDiam() / 3, dir: (b.x >= a.x) ? -1 : 1 };
1632
+ // Size + position on THIS tick, before .show fades it in — otherwise the
1633
+ // first frame renders at the image's intrinsic ~1280px width (the full-screen
1634
+ // "balloon" bug) until the rAF loop's first updateWhale() shrinks it.
1635
+ updateWhale();
1636
+ whaleEl.classList.add('show');
1637
+ }
1638
+
1639
+ function endFlight() {
1640
+ if (flight && flight.b && flight.b.el) {
1641
+ var el = flight.b.el;
1642
+ el.classList.remove('pinged'); void el.offsetWidth; el.classList.add('pinged');
1643
+ setTimeout((function (e) { return function () { e.classList.remove('pinged'); }; })(el), 950);
1644
+ }
1645
+ flight = null;
1646
+ if (whaleEl) whaleEl.classList.remove('show');
1647
+ }
1648
+
1649
+ function updateWhale() {
1650
+ if (!whaleEl || !flight) return;
1651
+ var elapsed = performance.now() - flight.t0;
1652
+ var p = elapsed / flight.dur;
1653
+ if (p >= 1) { endFlight(); return; }
1654
+ var pe = p * p * (3 - 2 * p); // smoothstep ease
1655
+ var b = flight.b;
1656
+ // INTERCEPT, don't chase — and follow the orb's CURVED orbital path, not a
1657
+ // straight line: a 2nd-order prediction using the orb's live acceleration
1658
+ // (it.fx/fy, the n-body force per step): pos + vel·t + ½·accel·t². Recomputed
1659
+ // every frame (a predictor-corrector), with the lead t decaying to 0 at
1660
+ // arrival so the aim converges exactly onto the live orb.
1661
+ var lead = Math.min((flight.dur - elapsed) / 1000, WHALE_LEAD_CAP);
1662
+ var half = 0.5 * lead * lead;
1663
+ var ax = b.fx || 0, ay = b.fy || 0;
1664
+ var tx = b.x + b.vx * lead + ax * half;
1665
+ var ty = b.y + b.vy * lead + ay * half;
1666
+ var wx = flight.sx + (tx - flight.sx) * pe;
1667
+ var wy = flight.sy + (ty - flight.sy) * pe - 34 * Math.sin(p * Math.PI); // gentle world-space arc
1668
+ var x = panX + wx * zoom, y = panY + wy * zoom;
1669
+ whaleEl.style.width = (flight.worldW * zoom).toFixed(1) + 'px';
1670
+ whaleEl.style.transform = 'translate(' + x.toFixed(1) + 'px,' + y.toFixed(1) + 'px) translate(-50%,-50%) scaleX(' + flight.dir + ')';
1671
+ }
1672
+
1673
+ // self-driving demo feed: gently animate real-looking project activity so the
1674
+ // sky stays alive until a live registry feed is wired to window.cbWhale.
1675
+ function maybeFly() {
1676
+ if (flight || !isSpace() || diving || document.hidden) return;
1677
+ var others = items.filter(function (it) { return it.proj.type !== 'self'; });
1678
+ if (!others.length || !itemForId('cloud-bongos')) return;
1679
+ var target = others[Math.floor(Math.random() * others.length)];
1680
+ if (Math.random() < 0.4) startFlight(target.proj.id, 'cloud-bongos', 'module'); // a module push — fly back
1681
+ else startFlight('cloud-bongos', target.proj.id, 'update'); // a project update
1682
+ }
1683
+ function scheduleWhale() {
1684
+ setTimeout(function () { maybeFly(); scheduleWhale(); }, 9000 + Math.random() * 9000);
1685
+ }
1686
+ // dormant seam for a real feed (the aiAnswer precedent): cbWhale.update(id) /
1687
+ // cbWhale.modulePush(id).
1688
+ window.cbWhale = {
1689
+ update: function (id) { startFlight('cloud-bongos', id, 'update'); },
1690
+ modulePush: function (id) { startFlight(id, 'cloud-bongos', 'module'); }
1691
+ };
1692
+
1693
+ /* ─── global keys ─────────────────────────────────────────────────────────── */
1694
+ window.addEventListener('keydown', function (e) {
1695
+ // DIVE: any key skips the in-flight dive cinematic and drops into the world
1696
+ if (descent && descent.classList.contains('playing')) { e.preventDefault(); landInWorld(); return; }
1697
+ // ENTRY: every key drives the type-bongos terminal
1698
+ if (document.body.classList.contains('view-entry')) { entryKey(e); return; }
1699
+
1700
+ if (e.key === 'Escape') {
1701
+ if (signInOpen) { e.preventDefault(); closeSignIn(); return; }
1702
+ if (createOpen) { e.preventDefault(); closeCreate(); return; }
1703
+ if (searchOpen) { e.preventDefault(); closeSearch(); return; }
1704
+ if (document.body.classList.contains('view-download') || document.body.classList.contains('view-detail')) {
1705
+ e.preventDefault(); exitToSpace(); return;
1706
+ }
1707
+ if (document.body.classList.contains('view-table')) {
1708
+ e.preventDefault();
1709
+ if (tvSearchInput && document.activeElement === tvSearchInput && tvSearchInput.value) {
1710
+ tvSearchInput.value = ''; renderTable();
1711
+ } else backToEntry();
1712
+ return;
1713
+ }
1714
+ if (document.body.classList.contains('view-space')) { e.preventDefault(); backToEntry(); return; }
1715
+ return;
1716
+ }
1717
+
1718
+ });
1719
+
1720
+ /* ─── SHIFT-tap toggles the in-view search (task 1901) ─────────────────────
1721
+ A clean Shift tap (pressed + released with no other key in between) opens
1722
+ or closes search. Tracking "used as a modifier" means Shift held for a
1723
+ capital while typing in the search box never toggles it. Esc also closes. */
1724
+ var shiftDown = false, shiftAsMod = false;
1725
+ window.addEventListener('keydown', function (e) {
1726
+ if (e.key === 'Shift') { shiftDown = true; shiftAsMod = false; }
1727
+ else if (shiftDown) shiftAsMod = true; // Shift + another key → a modifier, not a tap
1728
+ }, true);
1729
+ window.addEventListener('keyup', function (e) {
1730
+ if (e.key !== 'Shift') return;
1731
+ var tap = shiftDown && !shiftAsMod;
1732
+ shiftDown = false; shiftAsMod = false;
1733
+ if (!tap || diving || createOpen || !isSpace()) return;
1734
+ searchOpen ? closeSearch() : openSearch();
1735
+ });
1736
+
1737
+ /* ─── resize ─────────────────────────────────────────────────────────────── */
1738
+ addEventListener('resize', function () {
1739
+ W = innerWidth; H = innerHeight;
1740
+ sizeGrid();
1741
+ sizeMinimap();
1742
+ if (!userTouched && (document.body.classList.contains('view-space') || document.body.classList.contains('view-entry'))) fitToView();
1743
+ else { clampPan(); applyCanvas(); }
1744
+ });
1745
+
1746
+ /* ─── live project catalog (task 1002260) ────────────────────────────────
1747
+ The sky's real content. Fetch the platform's public, curated featured-
1748
+ projects feed and pop each LIVE project in via the same dynamic-add path
1749
+ the create flow uses (makeCircle → items/byId → swirl → rebalance). Same-
1750
+ origin on the apex (Caddy proxies /api/* to the app); on a vanity door the
1751
+ feed lives on the hub and the endpoint is CORS-enabled. Fails soft — an
1752
+ empty/erroring feed just leaves the Cloud Bongos self-orb (a sparse sky). */
1753
+ function catalogAccent(seed) {
1754
+ var h = 2166136261; seed = String(seed || '');
1755
+ for (var i = 0; i < seed.length; i++) { h ^= seed.charCodeAt(i); h = (h * 16777619) >>> 0; }
1756
+ return 'hsl(' + (h % 360) + ', 66%, 60%)';
1757
+ }
1758
+ function catalogSlug(origin) {
1759
+ try { return new URL(origin).hostname.replace(/[^a-z0-9]+/gi, '-'); }
1760
+ catch (e) { return String(origin || '').replace(/[^a-z0-9]+/gi, '-'); }
1761
+ }
1762
+ function projectFromCatalog(row) {
1763
+ var origin = row.origin || '';
1764
+ return {
1765
+ id: 'cat-' + catalogSlug(origin),
1766
+ name: row.name || catalogSlug(origin),
1767
+ type: 'link',
1768
+ url: origin,
1769
+ builders: null, // the public feed carries no count
1770
+ accent: catalogAccent(origin || row.name),
1771
+ tagline: row.tagline || '',
1772
+ description: row.tagline || '',
1773
+ tags: ((row.name || '') + ' ' + (row.tagline || '')).toLowerCase().split(/\s+/).filter(Boolean),
1774
+ preview: row.art_url || undefined
1775
+ };
1776
+ }
1777
+ function addLiveOrb(proj) {
1778
+ if (byId[proj.id]) return; // already placed
1779
+ try { if (proj.url && new URL(proj.url).hostname === location.hostname) return; } catch (e) {} // never dupe ourselves
1780
+ PROJECTS.push(proj);
1781
+ var c = items.length ? barycenter() : { x: 0, y: 0 };
1782
+ var ang = Math.random() * Math.PI * 2, rad = SPIRAL_STEP * Math.sqrt(items.length + 1);
1783
+ var it = makeCircle(proj, c.x + Math.cos(ang) * rad, c.y + Math.sin(ang) * rad * 0.82);
1784
+ items.push(it); byId[proj.id] = proj;
1785
+ var b = barycenter(), jit = GRAV.SWIRL * 60 * GRAV.SWIRL_JITTER; // join the sim (mirror doCreate)
1786
+ it.vx = -(it.y - b.y) * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1787
+ it.vy = (it.x - b.x) * GRAV.SWIRL + (Math.random() - 0.5) * 2 * jit;
1788
+ }
1789
+ function loadProjects() {
1790
+ var url = (IS_DOOR ? 'https://cloudbongos.com' : '') + '/api/bongos/projects/featured';
1791
+ return fetch(url, { credentials: 'same-origin' })
1792
+ .then(function (r) { return r.ok ? r.json() : null; })
1793
+ .then(function (j) {
1794
+ var rows = (j && j.projects) || [];
1795
+ if (!rows.length) return;
1796
+ rows.forEach(function (row) { addLiveOrb(projectFromCatalog(row)); });
1797
+ killDrift(); keTarget = kineticEnergy();
1798
+ renderTable();
1799
+ if (!userTouched && !isSpace()) fitToView();
1800
+ })
1801
+ .catch(function () { /* sparse sky is fine */ });
1802
+ }
1803
+
1804
+ /* ─── boot ───────────────────────────────────────────────────────────────── */
1805
+ document.body.className = 'view-entry';
1806
+ makeStars(140);
1807
+ sizeGrid();
1808
+ sizeMinimap();
1809
+ mountBuddha();
1810
+ mountLogoTerminal();
1811
+ scheduleWhale();
1812
+ layout();
1813
+ eRender();
1814
+ loadProjects(); // pop the real, live catalog projects into the sky (task 1002260)
1815
+ // Frame the constellation now (revealed when the visitor types bongos). The
1816
+ // circles stay hidden behind the entry terminal until then.
1817
+ fitToView();
1818
+ canvas.style.opacity = '0';
1819
+ // Arrived from a vanity door (?entered=1)? Skip the type-bongos entry and
1820
+ // open the orbs immediately.
1821
+ if (wantsDirectOrbs()) openSpaceDirect();
1822
+ addEventListener('load', function () { if (!userTouched && !isSpace()) fitToView(); });
1823
+ setTimeout(function () { if (!userTouched && !isSpace()) fitToView(); }, 60);
1824
+ setTimeout(function () { if (!userTouched && !isSpace()) fitToView(); }, 300);
1825
+ requestAnimationFrame(tick);
1826
+ })();