@bongos/core 1.17.1 → 1.17.3

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 (233) hide show
  1. package/.bongos-core.json +521 -281
  2. package/.claude/skills/blocker-review/SKILL.md +1 -1
  3. package/.claude/skills/builder-claim/SKILL.md +2 -0
  4. package/.claude/skills/builder-exit/SKILL.md +2 -2
  5. package/.claude/skills/builder-redteam/SKILL.md +3 -3
  6. package/.claude/skills/builder-setup/SKILL.md +2 -2
  7. package/.claude/skills/builder-start/SKILL.md +1 -1
  8. package/.claude/skills/dev/SKILL.md +1 -1
  9. package/.claude/skills/idea-triage/SKILL.md +2 -2
  10. package/.claude/skills/ideate/SKILL.md +1 -1
  11. package/.claude/skills/merge-mode/SKILL.md +1 -1
  12. package/.claude/skills/new-project/SKILL.md +3 -0
  13. package/.claude/skills/paint/SKILL.md +1 -1
  14. package/GOVERNANCE.md +1 -1
  15. package/README.md +1 -1
  16. package/clients/bongos-client/README.md +1 -1
  17. package/clients/bongos-client/bongos-client.global.js +2 -0
  18. package/clients/bongos-client/index.cjs +2 -0
  19. package/clients/bongos-client/index.d.ts +4 -2
  20. package/clients/bongos-client/index.mjs +2 -0
  21. package/docs/adr/0001-stack-choice-single-vps.md +1 -1
  22. package/docs/adr/0008-google-chat-oauth-user-auth.md +1 -1
  23. package/docs/adr/0019-red-team-bounty-payout-table.md +3 -3
  24. package/docs/adr/0020-security-model-public-stub.md +1 -1
  25. package/docs/adr/0023-kind-multipliers-and-idea-bonus.md +1 -1
  26. package/docs/adr/0024-cloneable-repo-local-first-memory.md +1 -1
  27. package/docs/adr/0026-bfg-memory-hygiene-and-cross-builder-learning.md +4 -4
  28. package/docs/adr/0027-bfg-session-inefficiency-evaluator.md +3 -3
  29. package/docs/adr/0030-builders-hall-live-updates.md +1 -1
  30. package/docs/adr/0031-cloud-dev-environments-for-builders.md +4 -4
  31. package/docs/adr/0032-discord-mirror-of-gds.md +2 -2
  32. package/docs/adr/0034-thetes-graduated-newcomer-rank.md +1 -1
  33. package/docs/adr/0044-mediterranean-palette-replacement.md +1 -1
  34. package/docs/adr/0044-per-box-live-game-preview.md +1 -1
  35. package/docs/adr/0054-session-token-reward-cost-plus.md +10 -10
  36. package/docs/adr/0057-container-cost-ledger.md +3 -3
  37. package/docs/adr/0062-medusa-instance-model-decoupling.md +4 -4
  38. package/docs/adr/0065-agpl-nonprofit-ai-first-governance.md +2 -2
  39. package/docs/adr/0085-remove-default-per-builder-spend-cap.md +1 -1
  40. package/docs/adr/0086-goal-scoped-work-hierarchy.md +1 -1
  41. package/docs/adr/0096-require-reward-before-workable.md +9 -9
  42. package/docs/adr/0097-retroactive-reward-backfill.md +3 -3
  43. package/docs/adr/0100-two-repo-instance-model.md +1 -1
  44. package/docs/adr/0101-goal-hierarchy-phase-5-judge-membership-reward.md +3 -3
  45. package/docs/adr/0103-core-first-extraction-cloud-bongos-trunk.md +3 -3
  46. package/docs/adr/0107-module-upstreaming-contribution-to-core-catalog.md +1 -1
  47. package/docs/adr/0120-pay-on-land-and-builder-owned-rebase-gate.md +6 -6
  48. package/docs/adr/0136-update-channel-subscription-policy.md +51 -0
  49. package/docs/adr/0137-upstream-publish-policy-manual-owner-gated.md +40 -0
  50. package/docs/adr/0138-bundled-module-deps-declared-per-module.md +45 -0
  51. package/docs/adr/README.md +9 -6
  52. package/docs/api/openapi.json +65 -26
  53. package/docs/api-reference.md +3 -2
  54. package/docs/architecture.md +2 -2
  55. package/docs/branding-contract.md +5 -4
  56. package/docs/canonical-permissions.md +1 -1
  57. package/docs/design/vanilla-hall-ui-redesign-scope.md +1 -1
  58. package/docs/file-map.md +18 -17
  59. package/docs/handoff-template.md +2 -2
  60. package/docs/modules-contract.md +6 -1
  61. package/docs/onboarding/diagrams/README.md +7 -7
  62. package/docs/onboarding/drachmae-and-karma.md +18 -18
  63. package/docs/onboarding/primer.md +9 -9
  64. package/docs/recipes/audit-replay.md +2 -2
  65. package/docs/recipes/builder-box-lifecycle.md +2 -2
  66. package/docs/recipes/claude-design-loop.md +1 -1
  67. package/docs/recipes/ops-gotchas.md +1 -1
  68. package/docs/recipes/private-npm-distribution.md +17 -0
  69. package/docs/recipes/staging-environment.md +1 -1
  70. package/docs/recipes/upgrading-the-core.md +55 -0
  71. package/docs/research/cursor-competitive-analysis.md +1 -1
  72. package/docs/research/llm-model-agnostic-and-cheaper-grader.md +1 -1
  73. package/modules/discord/module.json +2 -1
  74. package/modules/economy/achievements.js +9 -1
  75. package/modules/economy/credits.js +10 -1
  76. package/modules/hall-ui/public/builders.js +137 -620
  77. package/modules/hall-ui/public/goal-inbox.js +232 -0
  78. package/modules/hall-ui/public/goals-page.js +4 -1
  79. package/modules/hall-ui/public/goals.html +8 -0
  80. package/modules/hall-ui/public/hall-kit.js +474 -0
  81. package/modules/hall-ui/public/hall-widgets.js +14 -5
  82. package/modules/hall-ui/public/index.html +3 -0
  83. package/modules/hall-ui/public/instances-panel.js +24 -3
  84. package/modules/hall-ui/public/new-project-wizard.js +7 -5
  85. package/modules/hall-ui/public/style.css +168 -0
  86. package/modules/hall-ui/public/work.css +194 -0
  87. package/modules/hall-ui/public/work.html +46 -374
  88. package/modules/hall-ui/public/work.js +137 -102
  89. package/modules/lifecycle/criterion-suggest.js +80 -0
  90. package/modules/lifecycle/db.js +42 -5
  91. package/modules/lifecycle/routes/goals.js +26 -0
  92. package/modules/lifecycle/routes/tasks.js +21 -0
  93. package/modules/provisioning/CLAUDE.md +15 -0
  94. package/modules/provisioning/migrations/provisioning_001_tables.sql +160 -0
  95. package/modules/provisioning/migrations/provisioning_002_standalone_shape.sql +11 -0
  96. package/modules/provisioning/migrations/provisioning_003_github_app_manifest.sql +95 -0
  97. package/modules/provisioning/module.json +17 -0
  98. package/modules/provisioning/onboard-plan.js +37 -0
  99. package/modules/provisioning/provisioning.js +539 -0
  100. package/modules/provisioning/rate-limit.js +35 -0
  101. package/modules/provisioning/routes/provisioning.js +607 -0
  102. package/modules/provisioning/tests/provisioning.mjs +232 -0
  103. package/modules/provisioning/ui/.gitkeep +0 -0
  104. package/modules/security/secret-scrub.js +10 -0
  105. package/package-lock.json +2 -2
  106. package/package.json +7 -2
  107. package/scripts/discord/README.md +1 -1
  108. package/scripts/gds/claim.js +98 -48
  109. package/scripts/gds/claude-materialize.js +30 -9
  110. package/scripts/gds/cutover-codemod.js +241 -0
  111. package/scripts/gds/fitness.js +54 -0
  112. package/scripts/gds/init.js +59 -3
  113. package/scripts/gds/lint-infra-exec.js +70 -0
  114. package/scripts/gds/lint-workflow-exec.js +63 -0
  115. package/scripts/gds/mirror-lineage-check.js +14 -5
  116. package/scripts/gds/mirror-redact.js +8 -0
  117. package/scripts/gds/onboard.js +4 -0
  118. package/scripts/gds/package-core.js +18 -3
  119. package/scripts/gds/publish-manifest.js +60 -2
  120. package/scripts/gds/run-routine.js +51 -13
  121. package/scripts/gds/run-unit-tests.js +4 -0
  122. package/scripts/gds/update-channel.js +186 -0
  123. package/scripts/gds/upgrade.js +116 -9
  124. package/scripts/hall-preview/README.md +103 -0
  125. package/scripts/hall-preview/fixtures/access-requests.json +1 -0
  126. package/scripts/hall-preview/fixtures/achievements.json +76 -0
  127. package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
  128. package/scripts/hall-preview/fixtures/audit-log.json +1 -0
  129. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +1 -0
  130. package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
  131. package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
  132. package/scripts/hall-preview/fixtures/builders__roster.json +1 -0
  133. package/scripts/hall-preview/fixtures/goals.json +502 -0
  134. package/scripts/hall-preview/fixtures/goals__25.json +96 -0
  135. package/scripts/hall-preview/fixtures/goals__26.json +67 -0
  136. package/scripts/hall-preview/fixtures/goals__3.json +103 -0
  137. package/scripts/hall-preview/fixtures/goals__35.json +49 -0
  138. package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
  139. package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
  140. package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
  141. package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
  142. package/scripts/hall-preview/fixtures/grades__by-builder.json +1 -0
  143. package/scripts/hall-preview/fixtures/inbox.json +330 -0
  144. package/scripts/hall-preview/fixtures/instance.json +124 -0
  145. package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
  146. package/scripts/hall-preview/fixtures/me.json +476 -0
  147. package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
  148. package/scripts/hall-preview/fixtures/override-requests.json +1 -0
  149. package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
  150. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
  151. package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
  152. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
  153. package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
  154. package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
  155. package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
  156. package/scripts/hall-preview/fixtures/public__grades.json +50 -0
  157. package/scripts/hall-preview/fixtures/public__progress.json +124 -0
  158. package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
  159. package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
  160. package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
  161. package/scripts/hall-preview/fixtures/security__docs.json +1 -0
  162. package/scripts/hall-preview/fixtures/security__reports.json +1 -0
  163. package/scripts/hall-preview/fixtures/sessions__search.json +1 -0
  164. package/scripts/hall-preview/fixtures/tasks.json +2420 -0
  165. package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
  166. package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
  167. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
  168. package/scripts/hall-preview/fixtures/versions.json +89 -0
  169. package/scripts/hall-preview/refresh-fixtures.js +77 -0
  170. package/scripts/hall-preview/server.js +178 -0
  171. package/scripts/migrate.sh +29 -4
  172. package/src/bongos/auth.js +14 -1
  173. package/src/bongos/optimizer.js +37 -2
  174. package/src/bongos/serve-internal.js +10 -1
  175. package/src/branding.js +1 -0
  176. package/src/module-api.js +12 -1
  177. package/src/module-loader/loader.js +36 -4
  178. package/src/module-loader/manifest-schema.js +16 -1
  179. package/tests/branding.mjs +11 -0
  180. package/tests/claim_error_surface.mjs +107 -0
  181. package/tests/claude_materialize.mjs +20 -0
  182. package/tests/consumer_layout_boot.mjs +302 -0
  183. package/tests/criterion_suggest.mjs +106 -0
  184. package/tests/cutover_codemod.mjs +184 -0
  185. package/tests/device_flow_start.mjs +25 -2
  186. package/tests/hook_core_resolve.mjs +125 -0
  187. package/tests/infra_exec_paths.mjs +104 -0
  188. package/tests/init.mjs +47 -0
  189. package/tests/migrate_root_resolution.mjs +97 -0
  190. package/tests/mirror_lineage_proof.mjs +14 -0
  191. package/tests/module_loader.mjs +42 -0
  192. package/tests/module_manifest.mjs +19 -0
  193. package/tests/onboard.mjs +13 -0
  194. package/tests/package_core.mjs +8 -0
  195. package/tests/provisioning_github_app_routes.mjs +15 -0
  196. package/tests/publish_manifest.mjs +52 -1
  197. package/tests/run_routine.mjs +26 -1
  198. package/tests/run_routine_two_root.mjs +77 -0
  199. package/tests/scrubber_corpus.mjs +17 -0
  200. package/tests/update_channel.mjs +186 -0
  201. package/tests/upgrade.mjs +131 -0
  202. package/tests/workflow_exec_paths.mjs +98 -0
  203. package/docs/design/container-exterior-tiles.md +0 -49
  204. package/docs/design/container-exterior.md +0 -81
  205. package/docs/design/container-interior.md +0 -77
  206. package/docs/design/world-map-scroll.md +0 -41
  207. package/docs/recipes/claude-gemini-bongos-doors.md +0 -155
  208. package/docs/recipes/cloudbongos-staging-gate.md +0 -139
  209. package/docs/recipes/cloudbongos-standup.md +0 -111
  210. package/docs/recipes/parallel-load-results-2026-05-30.md +0 -63
  211. package/docs/recipes/parallel-load-test.md +0 -32
  212. package/docs/recipes/pixel-art-pipeline.md +0 -411
  213. package/docs/recipes/staging-data.md +0 -50
  214. package/migrations/instance/018_pms_v3_planning_seed.sql +0 -131
  215. package/migrations/instance/080_gds_v4_planning_seed.sql +0 -75
  216. package/migrations/instance/110_medusa_planning_seed.sql +0 -70
  217. package/migrations/instance/120_bongos_v1_planning_seed.sql +0 -69
  218. package/migrations/instance/144_bongos_v1_module_system_criterion.sql +0 -26
  219. package/migrations/instance/159_bongos_v1_goal_hierarchy_criterion.sql +0 -29
  220. package/migrations/instance/172_v2_world_planning_seed.sql +0 -163
  221. package/migrations/instance/173_bongos_v1_c7_core_modularized.sql +0 -33
  222. package/migrations/instance/182_gdsv4_bongos_consolidation.sql +0 -132
  223. package/migrations/instance/183_gdsv4_carryover_themed_goals.sql +0 -116
  224. package/migrations/instance/186_bongos_v1_governance_planning_seed.sql +0 -69
  225. package/scripts/gds/art-stage.js +0 -152
  226. package/server.js +0 -212
  227. package/tests/art_key.mjs +0 -121
  228. package/tests/day_cycle.mjs +0 -197
  229. package/tests/snapshot_picker.mjs +0 -233
  230. package/tests/sprite_facing.mjs +0 -35
  231. package/tests/terrain_picker.mjs +0 -298
  232. package/tests/turn_in_place.mjs +0 -280
  233. package/tests/walkability.mjs +0 -188
@@ -0,0 +1,474 @@
1
+ // modules/hall-ui/public/hall-kit.js — the hall's shared UI component kit
2
+ // (task 2166, the hall UX-coherence program's foundation).
3
+ //
4
+ // One dependency-free IIFE exposing `window.OTBKit`: the SIX primitives every
5
+ // page redesign (tasks 2169–2176) adopts instead of re-implementing — pager,
6
+ // ledger, filter bar, tabs, section rail, empty/skeleton/collapsible. Before
7
+ // this file, the ledger+pager block existed in ~10 drifted copies across
8
+ // work.js / goals-page.js / builders.js; the section rail existed twice with
9
+ // different mechanics (Standing scrollspy vs Settings panel-switch).
10
+ //
11
+ // Contract:
12
+ // - Dependency-free like shell.js (own esc(), no window.OTB use) so load order
13
+ // only needs "before the page script". Node-require-safe: no eval-time DOM.
14
+ // - String-template + innerHTML factories returning { el, render(data) } — the
15
+ // exact idiom the pages already use. No state manager, no render loop.
16
+ // - Pager markup/ids and localStorage keys are caller-supplied so existing
17
+ // per-page prefs (otb.work.*PerPage, …) survive migration verbatim.
18
+ // - Tabs/rails toggle [hidden] — the window stays the hall's ONLY scroller.
19
+ // - The kit NEVER touches location hashes in the deep-link namespace ("#/…"
20
+ // — task/idea/blocker overlays, goal sub-routes). Guarded in makeTabs.
21
+ // - CSS lives in style.css §"hall-kit components" (same task); the kit reuses
22
+ // .ledger/.pager/.filters/.setup__tab/.rail-link classes that already exist.
23
+ (() => {
24
+ 'use strict';
25
+
26
+ // HTML-escape for text interpolated into innerHTML.
27
+ function esc(s) {
28
+ return String(s ?? '').replace(/[&<>"']/g, (c) => ({
29
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
30
+ }[c]));
31
+ }
32
+ const $id = (id) => document.getElementById(id);
33
+
34
+ // ---- pager ----------------------------------------------------------------
35
+ // Canonized from work.js (task 1931/1955): page + per-page persisted per list,
36
+ // clamping against shrunken lists, poll-safe page retention, bar hidden when
37
+ // the list fits one minimum page.
38
+
39
+ const PAGER_DEFAULTS = { perPageOptions: [10, 25, 50, 100], defaultPerPage: 25 };
40
+
41
+ function loadPerPage(key, options, fallback) {
42
+ try {
43
+ const v = Number(window.localStorage.getItem(key));
44
+ return options.includes(v) ? v : fallback;
45
+ } catch (_) { return fallback; }
46
+ }
47
+
48
+ // makePager(prefix, storageKey, opts?) → pager state. `prefix` names the DOM
49
+ // ids the bar paints (#<prefix>-{pager,range,page,prev,next,per-page});
50
+ // `storageKey` is the caller's localStorage key (kept stable across
51
+ // migrations so builders' per-page prefs survive).
52
+ function makePager(prefix, storageKey, opts = {}) {
53
+ const perPageOptions = opts.perPageOptions || PAGER_DEFAULTS.perPageOptions;
54
+ const defaultPerPage = opts.defaultPerPage || PAGER_DEFAULTS.defaultPerPage;
55
+ return {
56
+ prefix,
57
+ key: storageKey,
58
+ page: 1,
59
+ perPage: loadPerPage(storageKey, perPageOptions, defaultPerPage),
60
+ perPageOptions,
61
+ // ≤ this fits one page — hide the bar entirely.
62
+ minPerPage: opts.minPerPage ?? perPageOptions[0],
63
+ };
64
+ }
65
+
66
+ // The standard pager-bar markup. Pages stop hand-copying this block; the ids
67
+ // match what renderPagerBar/wirePager address.
68
+ function pagerBarHtml(prefix, { label = 'items' } = {}) {
69
+ const p = esc(prefix);
70
+ const l = esc(label);
71
+ return `
72
+ <div class="pager pager--paged" id="${p}-pager" hidden>
73
+ <span class="pager__range" id="${p}-range"></span>
74
+ <div class="pager__nav">
75
+ <label class="pager__perpage">
76
+ <span>Per page</span>
77
+ <select id="${p}-per-page" aria-label="${l} per page"></select>
78
+ </label>
79
+ <button class="btn-ghost pager__btn" type="button" id="${p}-prev" aria-label="Previous page">‹ Prev</button>
80
+ <span class="pager__page" id="${p}-page" aria-live="polite"></span>
81
+ <button class="btn-ghost pager__btn" type="button" id="${p}-next" aria-label="Next page">Next ›</button>
82
+ </div>
83
+ </div>`;
84
+ }
85
+
86
+ // Slice `list` to the pager's current page (clamped — a filtered list may
87
+ // have shrunk under a background poll), paint the bar, return the slice.
88
+ function paginate(list, p) {
89
+ const total = list.length;
90
+ const totalPages = Math.max(1, Math.ceil(total / p.perPage));
91
+ if (p.page > totalPages) p.page = totalPages;
92
+ if (p.page < 1) p.page = 1;
93
+ const start = (p.page - 1) * p.perPage;
94
+ const slice = list.slice(start, start + p.perPage);
95
+ renderPagerBar(p, total, totalPages, start, slice.length);
96
+ return slice;
97
+ }
98
+
99
+ function renderPagerBar(p, total, totalPages, start, shownCount) {
100
+ const pager = $id(`${p.prefix}-pager`);
101
+ if (!pager) return;
102
+ if (total <= p.minPerPage) { pager.hidden = true; return; }
103
+ pager.hidden = false;
104
+ const range = $id(`${p.prefix}-range`);
105
+ if (range) range.textContent = total
106
+ ? `Showing ${start + 1}–${start + shownCount} of ${total}`
107
+ : 'Showing 0 of 0';
108
+ const ind = $id(`${p.prefix}-page`);
109
+ if (ind) ind.textContent = `Page ${p.page} of ${totalPages}`;
110
+ const prev = $id(`${p.prefix}-prev`);
111
+ if (prev) prev.disabled = p.page <= 1;
112
+ const next = $id(`${p.prefix}-next`);
113
+ if (next) next.disabled = p.page >= totalPages;
114
+ const sel = $id(`${p.prefix}-per-page`);
115
+ if (sel && sel.value !== String(p.perPage)) sel.value = String(p.perPage);
116
+ }
117
+
118
+ // Wire a pager's controls once. `rerender` re-renders that list; a per-page
119
+ // change resets to page 1 and persists. Fills the per-page <select> from the
120
+ // pager's own options so pagerBarHtml stays option-agnostic.
121
+ function wirePager(p, rerender) {
122
+ const sel = $id(`${p.prefix}-per-page`);
123
+ if (sel && !sel.options.length) {
124
+ sel.innerHTML = p.perPageOptions.map((v) => `<option value="${v}">${v}</option>`).join('');
125
+ sel.value = String(p.perPage);
126
+ }
127
+ sel?.addEventListener('change', (e) => {
128
+ const v = Number(e.target.value);
129
+ if (!p.perPageOptions.includes(v)) return;
130
+ p.perPage = v; p.page = 1;
131
+ try { window.localStorage.setItem(p.key, String(v)); } catch (_) { /* private mode */ }
132
+ rerender();
133
+ });
134
+ $id(`${p.prefix}-prev`)?.addEventListener('click', () => { if (p.page > 1) { p.page -= 1; rerender(); } });
135
+ $id(`${p.prefix}-next`)?.addEventListener('click', () => { p.page += 1; rerender(); });
136
+ }
137
+
138
+ // ---- ledger ----------------------------------------------------------------
139
+ // makeLedger({ mount, columns, rowHtml, emptyMsg, emptyAction?, pager? })
140
+ // Stamps the standard table.ledger (+ pager bar when a pager is given) into
141
+ // `mount` and returns { el, tbody, render(rows) }. `columns` entries are
142
+ // strings or { label, class }. `rowHtml(row, i)` returns a <tr> string —
143
+ // callers keep full control of cells (and must esc() their own data).
144
+ function makeLedger({ mount, columns = [], rowHtml, emptyMsg = 'Nothing here yet.', emptyAction = null, pager = null }) {
145
+ if (!mount || typeof rowHtml !== 'function') {
146
+ throw new Error('makeLedger({ mount, columns, rowHtml, … }) requires mount + rowHtml()');
147
+ }
148
+ const head = columns.map((c) => {
149
+ const col = typeof c === 'string' ? { label: c } : c;
150
+ return `<th${col.class ? ` class="${esc(col.class)}"` : ''}>${esc(col.label)}</th>`;
151
+ }).join('');
152
+ mount.innerHTML = `
153
+ <table class="ledger">
154
+ <thead><tr>${head}</tr></thead>
155
+ <tbody></tbody>
156
+ </table>
157
+ ${pager ? pagerBarHtml(pager.prefix, { label: 'rows' }) : ''}`;
158
+ const tbody = mount.querySelector('tbody');
159
+ let last = [];
160
+ function render(rows) {
161
+ if (Array.isArray(rows)) last = rows;
162
+ const slice = pager ? paginate(last, pager) : last;
163
+ tbody.innerHTML = slice.length
164
+ ? slice.map((r, i) => rowHtml(r, i)).join('')
165
+ : `<tr><td class="ledger__empty" colspan="${columns.length || 1}">${emptyStateHtml(emptyMsg, emptyAction)}</td></tr>`;
166
+ }
167
+ if (pager) wirePager(pager, () => render());
168
+ return { el: mount, tbody, render };
169
+ }
170
+
171
+ // ---- filter bar ------------------------------------------------------------
172
+ // makeFilterBar({ mount, search?, primary?, overflow?, url?, onChange })
173
+ // Primary controls render inline; `overflow` controls sit behind a
174
+ // "More filters" disclosure with an active-count badge, plus removable chips
175
+ // when the overflow row is closed. Control spec:
176
+ // { id, label, type?: 'select'|'search', options?: [{value,label}|string],
177
+ // value?, default? } (default falls back to the first option / '')
178
+ // `url: true` round-trips non-default values through the query string
179
+ // (history.replaceState — shareable filtered views, hash untouched).
180
+ function makeFilterBar({ mount, search = null, primary = [], overflow = [], url = false, onChange = null }) {
181
+ if (!mount) throw new Error('makeFilterBar requires mount');
182
+ const controls = [...(search ? [{ type: 'search', ...search }] : []), ...primary, ...overflow]
183
+ .map((c) => ({ type: 'select', ...c, default: c.default ?? (c.type === 'search' || !c.options?.length ? '' : optVal(c.options[0])) }));
184
+ const byId = new Map(controls.map((c) => [c.id, c]));
185
+ const overflowIds = new Set(overflow.map((c) => c.id));
186
+
187
+ function optVal(o) { return typeof o === 'string' ? o : o.value; }
188
+ function optLabel(o) { return typeof o === 'string' ? o : o.label; }
189
+
190
+ if (url) {
191
+ const q = new URLSearchParams(window.location.search);
192
+ controls.forEach((c) => { if (q.has(c.id)) c.value = q.get(c.id); });
193
+ }
194
+
195
+ function controlHtml(c) {
196
+ const cur = c.value ?? c.default;
197
+ if (c.type === 'search') {
198
+ return `<label class="filter">${esc(c.label || 'Search')}
199
+ <input type="search" id="${esc(c.id)}" placeholder="${esc(c.placeholder || '')}" value="${esc(cur)}"></label>`;
200
+ }
201
+ const opts = (c.options || []).map((o) =>
202
+ `<option value="${esc(optVal(o))}"${String(optVal(o)) === String(cur) ? ' selected' : ''}>${esc(optLabel(o))}</option>`).join('');
203
+ return `<label class="filter">${esc(c.label)}<select id="${esc(c.id)}">${opts}</select></label>`;
204
+ }
205
+
206
+ const activeOverflow = () => overflow.filter((c) => String(get(c.id)) !== String(byId.get(c.id).default));
207
+
208
+ mount.innerHTML = `
209
+ <div class="filters">
210
+ ${(search ? [controls[0]] : []).map(controlHtml).join('')}
211
+ ${primary.map((c) => controlHtml(byId.get(c.id))).join('')}
212
+ ${overflow.length ? `<button type="button" class="btn-ghost filters__more" aria-expanded="false">Filters<span class="count-pill filters__more-count" hidden></span></button>` : ''}
213
+ </div>
214
+ ${overflow.length ? `<div class="filters filters--overflow" hidden>
215
+ ${overflow.map((c) => controlHtml(byId.get(c.id))).join('')}
216
+ <button type="button" class="btn-ghost filters__clear">Clear all</button>
217
+ </div>
218
+ <div class="filter-chips" hidden></div>` : ''}`;
219
+
220
+ const moreBtn = mount.querySelector('.filters__more');
221
+ const overflowRow = mount.querySelector('.filters--overflow');
222
+ const chipsRow = mount.querySelector('.filter-chips');
223
+
224
+ function get(id) {
225
+ const el = mount.querySelector(`#${CSS.escape(id)}`);
226
+ return el ? el.value : (byId.get(id)?.value ?? byId.get(id)?.default);
227
+ }
228
+ function set(id, value, { fire = true } = {}) {
229
+ const el = mount.querySelector(`#${CSS.escape(id)}`);
230
+ if (el) el.value = value;
231
+ if (fire) changed();
232
+ }
233
+ function values() {
234
+ const out = {};
235
+ controls.forEach((c) => { out[c.id] = get(c.id); });
236
+ return out;
237
+ }
238
+
239
+ function paintBadges() {
240
+ const active = activeOverflow();
241
+ const count = mount.querySelector('.filters__more-count');
242
+ if (count) { count.hidden = !active.length; count.textContent = String(active.length); }
243
+ if (chipsRow) {
244
+ const open = overflowRow && !overflowRow.hidden;
245
+ chipsRow.hidden = open || !active.length;
246
+ chipsRow.innerHTML = active.map((c) => {
247
+ const cur = get(c.id);
248
+ const lbl = (c.options || []).find((o) => String(optVal(o)) === String(cur));
249
+ return `<button type="button" class="chip" data-chip="${esc(c.id)}" title="Remove filter">
250
+ ${esc(c.label)}: ${esc(lbl ? optLabel(lbl) : cur)} <span aria-hidden="true">×</span></button>`;
251
+ }).join('');
252
+ }
253
+ }
254
+
255
+ function syncUrl() {
256
+ if (!url) return;
257
+ const q = new URLSearchParams(window.location.search);
258
+ controls.forEach((c) => {
259
+ const v = get(c.id);
260
+ if (String(v) === String(c.default) || v === '') q.delete(c.id); else q.set(c.id, v);
261
+ });
262
+ const qs = q.toString();
263
+ history.replaceState(null, '', window.location.pathname + (qs ? `?${qs}` : '') + window.location.hash);
264
+ }
265
+
266
+ function changed() {
267
+ paintBadges();
268
+ syncUrl();
269
+ if (onChange) onChange(values());
270
+ }
271
+
272
+ mount.addEventListener('change', (e) => { if (e.target.matches('select, input[type="search"]')) changed(); });
273
+ mount.addEventListener('input', (e) => { if (e.target.matches('input[type="search"]')) changed(); });
274
+ moreBtn?.addEventListener('click', () => {
275
+ const open = overflowRow.hidden;
276
+ overflowRow.hidden = !open;
277
+ moreBtn.setAttribute('aria-expanded', String(open));
278
+ paintBadges();
279
+ });
280
+ mount.querySelector('.filters__clear')?.addEventListener('click', () => {
281
+ overflow.forEach((c) => set(c.id, byId.get(c.id).default, { fire: false }));
282
+ changed();
283
+ });
284
+ chipsRow?.addEventListener('click', (e) => {
285
+ const chip = e.target.closest('[data-chip]');
286
+ if (chip) set(chip.dataset.chip, byId.get(chip.dataset.chip).default);
287
+ });
288
+
289
+ paintBadges();
290
+ return { el: mount, values, get, set };
291
+ }
292
+
293
+ // ---- tabs ------------------------------------------------------------------
294
+ // makeTabs({ mount, tabs: [{ id, label, panel }], initial?, hash?, onChange })
295
+ // The promoted .setup__tab idiom: a tablist whose buttons toggle [hidden] on
296
+ // their panel elements — exactly like the index hash views, so the window
297
+ // stays the only scroller. `hash: true` syncs "#<id>" for deep links and
298
+ // back/forward, but NEVER claims the "#/…" deep-link namespace (task/idea/
299
+ // blocker overlays, goal sub-routes) — those hashes pass through untouched.
300
+ function makeTabs({ mount, tabs = [], initial = null, hash = false, onChange = null }) {
301
+ if (!mount || !tabs.length) throw new Error('makeTabs requires mount + tabs');
302
+ const ids = new Set(tabs.map((t) => t.id));
303
+ mount.innerHTML = `<div class="kit-tabs" role="tablist">${tabs.map((t) => `
304
+ <button class="setup__tab" type="button" role="tab" id="tab-${esc(t.id)}" aria-selected="false"
305
+ data-tab="${esc(t.id)}">${esc(t.label)}<span class="count-pill" data-tab-count="${esc(t.id)}" hidden></span></button>`).join('')}
306
+ </div>`;
307
+
308
+ let current = null;
309
+ function activate(id, { push = true } = {}) {
310
+ if (!ids.has(id) || id === current) return;
311
+ current = id;
312
+ tabs.forEach((t) => {
313
+ mount.querySelector(`[data-tab="${CSS.escape(t.id)}"]`)?.setAttribute('aria-selected', String(t.id === id));
314
+ const panel = $id(t.panel);
315
+ if (panel) panel.hidden = t.id !== id;
316
+ });
317
+ if (hash && push && window.location.hash !== `#${id}`) {
318
+ history.replaceState(null, '', window.location.pathname + window.location.search + `#${id}`);
319
+ }
320
+ if (onChange) onChange(id);
321
+ }
322
+
323
+ mount.addEventListener('click', (e) => {
324
+ const btn = e.target.closest('[data-tab]');
325
+ if (btn) activate(btn.dataset.tab);
326
+ });
327
+
328
+ function fromHash() {
329
+ const h = window.location.hash.slice(1);
330
+ // "#/…" is the overlay/sub-route namespace — never ours.
331
+ if (!h || h.startsWith('/')) return false;
332
+ if (ids.has(h)) { activate(h, { push: false }); return true; }
333
+ return false;
334
+ }
335
+ if (hash) window.addEventListener('hashchange', fromHash);
336
+
337
+ if (!(hash && fromHash())) activate(initial || tabs[0].id, { push: false });
338
+
339
+ return {
340
+ el: mount,
341
+ activate,
342
+ active: () => current,
343
+ setCount(id, n) {
344
+ const pill = mount.querySelector(`[data-tab-count="${CSS.escape(id)}"]`);
345
+ if (pill) { pill.hidden = n == null; pill.textContent = n == null ? '' : String(n); }
346
+ },
347
+ };
348
+ }
349
+
350
+ // ---- section rail ------------------------------------------------------------
351
+ // makeSectionRail({ mount, items: [{ id, label }], mode, onSwitch })
352
+ // ONE rail, two modes — replaces the two divergent implementations:
353
+ // - 'scrollspy' (the Standing idiom): links are #anchors; an
354
+ // IntersectionObserver flips --active as section headlines cross a band a
355
+ // third of the way down the viewport; window scrolling only.
356
+ // - 'switch' (the Settings idiom): links carry data-key; clicking activates
357
+ // a key and hands it to onSwitch(key) — the caller toggles its panels.
358
+ function makeSectionRail({ mount, items = [], mode = 'scrollspy', onSwitch = null }) {
359
+ if (!mount) throw new Error('makeSectionRail requires mount');
360
+ let observer = null;
361
+ let current = null;
362
+
363
+ function renderLinks(list) {
364
+ mount.classList.add('section-rail');
365
+ mount.innerHTML = list.map((s) => mode === 'switch'
366
+ ? `<a class="rail-link" href="#${esc(s.id)}" data-key="${esc(s.id)}">${esc(s.label)}</a>`
367
+ : `<a class="rail-link" href="#${esc(s.id)}" data-section="${esc(s.id)}">${esc(s.label)}</a>`).join('');
368
+ mount.hidden = list.length < 2;
369
+ }
370
+
371
+ function markActive(id) {
372
+ current = id;
373
+ mount.querySelectorAll('.rail-link').forEach((a) => {
374
+ a.classList.toggle('rail-link--active', (a.dataset.key || a.dataset.section) === id);
375
+ });
376
+ const active = mount.querySelector('.rail-link--active');
377
+ if (active && window.matchMedia('(max-width: 640px)').matches) {
378
+ active.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
379
+ }
380
+ }
381
+
382
+ function observe(list) {
383
+ observer?.disconnect();
384
+ if (mode !== 'scrollspy') return;
385
+ // The active flip happens when a section crosses a ~5% horizontal band
386
+ // about a third of the way down the viewport (the Standing tuning).
387
+ observer = new IntersectionObserver((entries) => {
388
+ for (const entry of entries) {
389
+ if (entry.isIntersecting) { markActive(entry.target.id); break; }
390
+ }
391
+ }, { rootMargin: '-40% 0px -55% 0px', threshold: 0 });
392
+ list.forEach((s) => { const el = $id(s.id); if (el) observer.observe(el); });
393
+ }
394
+
395
+ function refresh(list) {
396
+ renderLinks(list);
397
+ observe(list);
398
+ }
399
+ refresh(items);
400
+
401
+ if (mode === 'switch') {
402
+ mount.addEventListener('click', (e) => {
403
+ const a = e.target.closest('.rail-link');
404
+ if (!a || !a.dataset.key) return;
405
+ e.preventDefault();
406
+ markActive(a.dataset.key);
407
+ if (onSwitch) onSwitch(a.dataset.key);
408
+ });
409
+ }
410
+
411
+ return {
412
+ el: mount,
413
+ refresh,
414
+ activate: markActive,
415
+ active: () => current,
416
+ dispose: () => observer?.disconnect(),
417
+ };
418
+ }
419
+
420
+ // ---- empty state / skeleton / collapsible ------------------------------------
421
+
422
+ // One standard empty pattern: a short line + at most one action link. An
423
+ // empty card should earn its height or hide — this is the "earn" form.
424
+ function emptyStateHtml(msg, action = null) {
425
+ return `<div class="empty-state"><p>${esc(msg)}</p>${action
426
+ ? `<a class="empty-state__action" href="${esc(action.href)}">${esc(action.label)}</a>` : ''}</div>`;
427
+ }
428
+
429
+ // CSS-only shimmer placeholder rows (style.css §hall-kit), standard heights
430
+ // so cards don't jump when data lands. For use inside a <tbody>.
431
+ function skeletonRows(n = 3, cols = 1) {
432
+ const cells = Array.from({ length: cols }, () => '<td><span class="kit-skel"></span></td>').join('');
433
+ return Array.from({ length: n }, () => `<tr class="kit-skel-row" aria-hidden="true">${cells}</tr>`).join('');
434
+ }
435
+
436
+ // collapsibleCard({ mount, title, open? }) — the Work-backlog <details> idiom,
437
+ // generalized: a card section whose body collapses behind its heading.
438
+ // Returns { el, body, setCount(n) }. Secondary content should default closed.
439
+ function collapsibleCard({ mount, title, open = false }) {
440
+ if (!mount) throw new Error('collapsibleCard requires mount');
441
+ mount.innerHTML = `
442
+ <details class="kit-collapse"${open ? ' open' : ''}>
443
+ <summary class="kit-collapse__summary">
444
+ <span class="scroll__h">${esc(title)}</span>
445
+ <span class="count-pill" hidden></span>
446
+ </summary>
447
+ <div class="kit-collapse__body"></div>
448
+ </details>`;
449
+ const details = mount.querySelector('details');
450
+ const pill = mount.querySelector('.count-pill');
451
+ return {
452
+ el: details,
453
+ body: mount.querySelector('.kit-collapse__body'),
454
+ setCount(n) {
455
+ if (pill) { pill.hidden = n == null; pill.textContent = n == null ? '' : String(n); }
456
+ },
457
+ };
458
+ }
459
+
460
+ const api = {
461
+ esc,
462
+ // pager (low-level exports so pages can adopt incrementally)
463
+ makePager, pagerBarHtml, paginate, renderPagerBar, wirePager,
464
+ // composed primitives
465
+ makeLedger, makeFilterBar, makeTabs, makeSectionRail,
466
+ // states
467
+ emptyStateHtml, skeletonRows, collapsibleCard,
468
+ };
469
+
470
+ // Node-require-safe (the dom-utils.js pattern): assignment is the only
471
+ // eval-time side effect, and only when a window exists.
472
+ if (typeof window !== 'undefined') window.OTBKit = api;
473
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
474
+ })();
@@ -53,13 +53,16 @@
53
53
  // 0c. Quick stats — headline numbers for the Home dashboard (task 1723).
54
54
  // Filled by builders.js: credits + karma + rank from /me (renderProfile),
55
55
  // shipped count once the ships feed settles (renderRecentShips).
56
+ // task 2169: each tile is a LINK — the stat row doubles as the fastest
57
+ // navigation on the page (Credits → the economy doc, Shipped/Karma → the
58
+ // Standing sections that explain them, Rank → the ladder).
56
59
  w('home-stats', 'Overview', 15,
57
60
  '<h2 id="home-stats-h" class="sr-only">Overview</h2>' +
58
61
  '<div class="stat-row" id="home-stats-row">' +
59
- '<div class="stat-tile"><span class="stat-tile__label" data-currency-label="caps">Credits</span><span class="stat-tile__num" id="stat-credits">—</span></div>' +
60
- '<div class="stat-tile"><span class="stat-tile__label">Shipped</span><span class="stat-tile__num" id="stat-shipped">—</span><span class="stat-tile__sub" id="stat-shipped-sub"></span></div>' +
61
- '<div class="stat-tile"><span class="stat-tile__label">Karma</span><span class="stat-tile__num" id="stat-karma">—</span><span class="stat-tile__sub" id="stat-karma-sub"></span></div>' +
62
- '<div class="stat-tile"><span class="stat-tile__label">Rank</span><span class="stat-tile__num stat-tile__num--badge" id="stat-rank">—</span><span class="stat-tile__sub" id="stat-rank-sub"></span></div>' +
62
+ '<a class="stat-tile" href="/drachmae"><span class="stat-tile__label" data-currency-label="caps">Credits</span><span class="stat-tile__num" id="stat-credits">—</span></a>' +
63
+ '<a class="stat-tile" href="#standing"><span class="stat-tile__label">Shipped</span><span class="stat-tile__num" id="stat-shipped">—</span><span class="stat-tile__sub" id="stat-shipped-sub"></span></a>' +
64
+ '<a class="stat-tile" href="#standing"><span class="stat-tile__label">Karma</span><span class="stat-tile__num" id="stat-karma">—</span><span class="stat-tile__sub" id="stat-karma-sub"></span></a>' +
65
+ '<a class="stat-tile" href="/ranks"><span class="stat-tile__label">Rank</span><span class="stat-tile__num stat-tile__num--badge" id="stat-rank">—</span><span class="stat-tile__sub" id="stat-rank-sub"></span></a>' +
63
66
  '</div>',
64
67
  { view: 'home', zone: 'home-stats', sectionClass: 'scroll scroll--bare', labelledby: 'home-stats-h', hiddenByDefault: true });
65
68
 
@@ -73,6 +76,11 @@
73
76
 
74
77
  // (Goals — goals.js registers itself into the other half of the Home band.)
75
78
 
79
+ // (task 2169: the idea-triage cue — task 1471 — no longer registers its own
80
+ // card; its rows render into the "Needs your attention" card that
81
+ // goal-inbox.js owns (#attention-ideas), the single landing place for every
82
+ // actionable item. builders.js still gates the /inbox fetch to Metic+.)
83
+
76
84
  // 0e. Recent work — compact 5-row table (task 1723). Filled by builders.js
77
85
  // renderRecentShips from the same feed as the full Your-work table.
78
86
  w('home-recent', 'Recent work', 28,
@@ -179,7 +187,8 @@
179
187
  '<th class="ledger__sortable" id="lb-sort-rank" aria-sort="none" title="Sort by rank">Rank</th>' +
180
188
  '<th class="ledger__sortable" id="lb-sort-drachmae" aria-sort="descending" title="Sort by ' + curLabel() + ' (work shipped)" data-currency-label="caps">Credits</th>' +
181
189
  '<th class="ledger__sortable" id="lb-sort-karma" aria-sort="none" title="Sort by karma (peer recognition + approvals)">Karma</th>' +
182
- '</tr></thead><tbody><tr><td colspan="5" class="ledger__empty">…</td></tr></tbody></table>' +
190
+ '<th class="ledger__sortable" id="lb-sort-tokens" aria-sort="none" title="Sort by tokens used across recorded sessions — efficiency is tokens ÷ credits (hover a row for tokens-per-drachma)">Tokens</th>' +
191
+ '</tr></thead><tbody><tr><td colspan="6" class="ledger__empty">…</td></tr></tbody></table>' +
183
192
  '<div class="pager">' +
184
193
  '<button class="btn-ghost" id="lb-prev" disabled>← earlier</button>' +
185
194
  '<span id="lb-range" class="pager__range">1–10</span>' +
@@ -107,6 +107,9 @@
107
107
  <script src="/builders/hall-widgets.js?v=2026-06-27-r75" defer></script>
108
108
  <!-- BV1.R78 (task 1694): the Goals widget registers into the same registry. -->
109
109
  <script src="/builders/goals.js?v=2026-07-01-goals" defer></script>
110
+ <!-- ADR 0112 (task 1954): the home-page goal-membership inbox — invitations to
111
+ accept + join-requests to approve. Self-registers; hidden when empty. -->
112
+ <script src="/builders/goal-inbox.js?v=2026-07-10-goal-inbox" defer></script>
110
113
  <!-- BV1-prov-P6 (task 1947): the Instances widget — self-registers, gated on the provisioning module. -->
111
114
  <script src="/builders/new-project-wizard.js?v=2026-07-04-goal35" defer></script>
112
115
  <script src="/builders/instances-panel.js?v=2026-07-03-prov-p6" defer></script>
@@ -148,9 +148,17 @@
148
148
  }
149
149
  }
150
150
 
151
+ // task 2169: this is the "Projects" card — instances state + the
152
+ // Start-a-project wizard merged. The wizard (an idle multi-step form, the
153
+ // page's worst length offender as its own card) renders ON DEMAND behind the
154
+ // header button, via window.OTBNewProject (new-project-wizard.js).
151
155
  const SKELETON =
152
- '<h2 id="instances-h" class="scroll__h">Instances</h2>' +
153
- '<p class="scroll__lede">Hosting you’ve provisioned through Cloud Bongos — each instance’s status and estimated monthly cost. Provision one with <code>bongos init</code> or the provisioning API.</p>' +
156
+ '<div class="proj-head">' +
157
+ '<h2 id="instances-h" class="scroll__h">Projects</h2>' +
158
+ '<button type="button" class="btn-ghost" id="new-project-toggle" aria-expanded="false" aria-controls="new-project-mount">+ Start a project</button>' +
159
+ '</div>' +
160
+ '<p class="scroll__lede">Hosting you’ve provisioned through Cloud Bongos — each instance’s status and estimated monthly cost. Provision one right here, or with <code>bongos init</code>.</p>' +
161
+ '<div id="new-project-mount" hidden></div>' +
154
162
  '<div id="instances-own-body"><div class="profile__placeholder">Loading your instances…</div></div>' +
155
163
  '<div id="instances-fleet" hidden>' +
156
164
  '<h3 class="scroll__h" style="font-size:1rem;margin-top:1rem">Fleet</h3>' +
@@ -161,11 +169,24 @@
161
169
  function mount(hostEl) {
162
170
  host = hostEl;
163
171
  host.innerHTML = SKELETON;
172
+ const toggle = host.querySelector('#new-project-toggle');
173
+ const wizardMount = host.querySelector('#new-project-mount');
174
+ let wizardRendered = false;
175
+ toggle?.addEventListener('click', () => {
176
+ if (!wizardRendered && window.OTBNewProject) {
177
+ window.OTBNewProject.renderInto(wizardMount);
178
+ wizardRendered = true;
179
+ }
180
+ const open = wizardMount.hidden;
181
+ wizardMount.hidden = !open;
182
+ toggle.setAttribute('aria-expanded', String(open));
183
+ toggle.textContent = open ? '− Start a project' : '+ Start a project';
184
+ });
164
185
  load();
165
186
  }
166
187
 
167
188
  window.OTB.contributeHallWidget({
168
- id: 'instances-scroll', label: 'Instances', order: 34,
189
+ id: 'instances-scroll', label: 'Projects', order: 33,
169
190
  module: 'provisioning', view: 'home', zone: 'home-main',
170
191
  labelledby: 'instances-h', mount,
171
192
  });
@@ -83,6 +83,7 @@
83
83
  <button id="np-github-app" type="button" class="np-create-btn" data-instance="${escapeHtml(String(instanceId))}">Set up GitHub sign-in</button>
84
84
  <span id="np-github-app-msg" class="ledger__note"></span>
85
85
  </div>
86
+ <p class="ledger__note">Terminal CLI only: after approving, enable <strong>Device Flow</strong> on the app (GitHub → Settings → Developer settings → GitHub Apps → your app → “Enable Device Flow”) so <code>bongos login</code> works. Web sign-in needs nothing.</p>
86
87
  <details class="np-manual"><summary class="ledger__note">Prefer to do it by hand?</summary>
87
88
  <ol class="np-steps">
88
89
  <li><strong>Create the GitHub OAuth app</strong> — GitHub → <em>your account's</em> Settings → Developer settings → OAuth Apps → New OAuth App.
@@ -179,9 +180,10 @@
179
180
  if (btn) btn.addEventListener('click', submit);
180
181
  }
181
182
 
182
- window.OTB.contributeHallWidget({
183
- id: 'new-project-scroll', label: 'Start a project', order: 33,
184
- module: 'provisioning', view: 'home', zone: 'home-main',
185
- labelledby: 'np-h', mount,
186
- });
183
+ // task 2169: the wizard no longer registers its own always-open Home card —
184
+ // an idle multi-step form was the page's worst length offender. It renders
185
+ // on demand into the Projects card (instances-panel.js) behind its
186
+ // "Start a project" disclosure button. Module gating is inherited: only the
187
+ // provisioning-gated Projects card ever calls this.
188
+ window.OTBNewProject = { renderInto: mount };
187
189
  })();