@bongos/core 1.17.2 → 1.18.0

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 (151) hide show
  1. package/.bongos-core.json +426 -96
  2. package/.devcontainer/healthcheck.sh +0 -0
  3. package/.devcontainer/init-firewall.sh +0 -0
  4. package/.devcontainer/post-create.sh +0 -0
  5. package/GOVERNANCE.md +1 -1
  6. package/README.md +26 -22
  7. package/bin/bongos.js +0 -0
  8. package/clients/bongos-client/README.md +1 -1
  9. package/clients/bongos-client/bongos-client.global.js +2 -0
  10. package/clients/bongos-client/index.cjs +2 -0
  11. package/clients/bongos-client/index.d.ts +4 -2
  12. package/clients/bongos-client/index.mjs +2 -0
  13. package/docker-entrypoint.sh +0 -0
  14. package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +94 -0
  15. package/docs/adr/0140-cloudbongos-standalone-reprovision.md +43 -0
  16. package/docs/adr/README.md +3 -0
  17. package/docs/api/openapi.json +68 -370
  18. package/docs/api-reference.md +7 -18
  19. package/docs/file-map.md +7 -25
  20. package/docs/onboarding/diagrams/05-versioning.mmd +4 -6
  21. package/docs/onboarding/diagrams/assertions.json +5 -9
  22. package/modules/hall-ui/public/builders.js +132 -645
  23. package/modules/hall-ui/public/goal-inbox.js +67 -26
  24. package/modules/hall-ui/public/goals-page.js +102 -177
  25. package/modules/hall-ui/public/goals.css +183 -0
  26. package/modules/hall-ui/public/goals.html +35 -253
  27. package/modules/hall-ui/public/goals.js +7 -2
  28. package/modules/hall-ui/public/hall-kit.js +474 -0
  29. package/modules/hall-ui/public/hall-widgets.js +24 -20
  30. package/modules/hall-ui/public/instances-panel.js +24 -3
  31. package/modules/hall-ui/public/new-project-wizard.js +6 -5
  32. package/modules/hall-ui/public/settings.css +355 -0
  33. package/modules/hall-ui/public/settings.html +86 -414
  34. package/modules/hall-ui/public/settings.js +96 -35
  35. package/modules/hall-ui/public/style.css +156 -0
  36. package/modules/hall-ui/public/watch.css +184 -0
  37. package/modules/hall-ui/public/watch.html +111 -285
  38. package/modules/hall-ui/public/watch.js +59 -21
  39. package/modules/hall-ui/public/work.css +194 -0
  40. package/modules/hall-ui/public/work.html +46 -374
  41. package/modules/hall-ui/public/work.js +137 -102
  42. package/modules/lifecycle/criterion-suggest.js +80 -0
  43. package/modules/lifecycle/db.js +42 -5
  44. package/modules/lifecycle/routes/goals.js +26 -0
  45. package/modules/lifecycle/routes/tasks.js +21 -0
  46. package/package-lock.json +2 -2
  47. package/package.json +1 -1
  48. package/public-landing/create.html +83 -7
  49. package/public-landing/index.html +11 -76
  50. package/scripts/cron/style_guide_audit.sh +0 -0
  51. package/scripts/gds/api.js +0 -0
  52. package/scripts/gds/architect-audit.js +0 -0
  53. package/scripts/gds/audit-authorship.sh +0 -0
  54. package/scripts/gds/audit-replay.sh +0 -0
  55. package/scripts/gds/box.js +0 -0
  56. package/scripts/gds/capture.js +0 -0
  57. package/scripts/gds/conductor-dispatch.js +0 -0
  58. package/scripts/gds/coreB-carve-proof.sh +0 -0
  59. package/scripts/gds/db-backup-nightly.sh +0 -0
  60. package/scripts/gds/db-backup-offsite.sh +0 -0
  61. package/scripts/gds/doctor.js +0 -0
  62. package/scripts/gds/grade-smoke.js +0 -0
  63. package/scripts/gds/hooks/pre-push.js +0 -0
  64. package/scripts/gds/install-git-hooks.js +0 -0
  65. package/scripts/gds/main-audit.js +0 -0
  66. package/scripts/gds/oauth-secret.js +0 -0
  67. package/scripts/gds/onboarding-analyze.js +0 -0
  68. package/scripts/gds/onboarding-ship.js +0 -0
  69. package/scripts/gds/paste-token.js +0 -0
  70. package/scripts/gds/portability-smoke-art.sh +0 -0
  71. package/scripts/gds/portability-smoke-memory.sh +0 -0
  72. package/scripts/gds/provision.js +125 -4
  73. package/scripts/gds/push-main.js +0 -0
  74. package/scripts/gds/render-ideas.js +0 -0
  75. package/scripts/gds/security-baseline-check.sh +0 -0
  76. package/scripts/gds/session.js +0 -0
  77. package/scripts/gds/ship.js +11 -1
  78. package/scripts/gds/smoke-batch-claim.sh +0 -0
  79. package/scripts/gds/smoke-batch-release.sh +0 -0
  80. package/scripts/gds/smoke-box.sh +0 -0
  81. package/scripts/gds/smoke-claim-consistency.sh +0 -0
  82. package/scripts/gds/smoke-dependencies.sh +0 -0
  83. package/scripts/gds/smoke-write.sh +0 -0
  84. package/scripts/hall-preview/README.md +103 -0
  85. package/scripts/hall-preview/fixtures/access-requests.json +20 -0
  86. package/scripts/hall-preview/fixtures/achievements.json +76 -0
  87. package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
  88. package/scripts/hall-preview/fixtures/audit-log.json +68 -0
  89. package/scripts/hall-preview/fixtures/blockers__detail.json +12 -0
  90. package/scripts/hall-preview/fixtures/box__me.json +5 -0
  91. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +69 -0
  92. package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
  93. package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
  94. package/scripts/hall-preview/fixtures/builders__roster.json +48 -0
  95. package/scripts/hall-preview/fixtures/goals.json +502 -0
  96. package/scripts/hall-preview/fixtures/goals__25.json +96 -0
  97. package/scripts/hall-preview/fixtures/goals__26.json +67 -0
  98. package/scripts/hall-preview/fixtures/goals__3.json +103 -0
  99. package/scripts/hall-preview/fixtures/goals__35.json +49 -0
  100. package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
  101. package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
  102. package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
  103. package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
  104. package/scripts/hall-preview/fixtures/grades__by-builder.json +32 -0
  105. package/scripts/hall-preview/fixtures/inbox.json +330 -0
  106. package/scripts/hall-preview/fixtures/inbox__detail.json +15 -0
  107. package/scripts/hall-preview/fixtures/instance.json +124 -0
  108. package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
  109. package/scripts/hall-preview/fixtures/me.json +476 -0
  110. package/scripts/hall-preview/fixtures/me__art-key__own.json +6 -0
  111. package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +209 -0
  112. package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
  113. package/scripts/hall-preview/fixtures/me__render.json +5 -0
  114. package/scripts/hall-preview/fixtures/me__skill-prefs.json +143 -0
  115. package/scripts/hall-preview/fixtures/me__sound-prefs.json +67 -0
  116. package/scripts/hall-preview/fixtures/me__wandering.json +43 -0
  117. package/scripts/hall-preview/fixtures/override-requests.json +15 -0
  118. package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
  119. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
  120. package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
  121. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
  122. package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
  123. package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
  124. package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
  125. package/scripts/hall-preview/fixtures/public__grades.json +50 -0
  126. package/scripts/hall-preview/fixtures/public__progress.json +124 -0
  127. package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
  128. package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
  129. package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
  130. package/scripts/hall-preview/fixtures/security__docs.json +10 -0
  131. package/scripts/hall-preview/fixtures/security__reports.json +31 -0
  132. package/scripts/hall-preview/fixtures/sessions__search.json +9 -0
  133. package/scripts/hall-preview/fixtures/tasks.json +2420 -0
  134. package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
  135. package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
  136. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
  137. package/scripts/hall-preview/fixtures/versions.json +89 -0
  138. package/scripts/hall-preview/refresh-fixtures.js +77 -0
  139. package/scripts/hall-preview/server.js +195 -0
  140. package/scripts/migrate.sh +29 -4
  141. package/scripts/migrate_cost_ledger.sh +0 -0
  142. package/scripts/render-diagrams.sh +0 -0
  143. package/scripts/security/dep-audit.js +0 -0
  144. package/scripts/setup-builder.js +0 -0
  145. package/scripts/setup-builder.sh +0 -0
  146. package/scripts/snapshots/maybe-render-on-deploy.sh +0 -0
  147. package/src/bongos/optimizer.js +37 -2
  148. package/src/module-api.js +20 -1
  149. package/tests/criterion_suggest.mjs +106 -0
  150. package/tests/migrate_root_resolution.mjs +97 -0
  151. package/tests/provision.mjs +69 -0
@@ -1,6 +1,13 @@
1
- // goal-inbox.js — the home-page, two-sided goal-membership inbox (task 1954, ADR 0112).
1
+ // goal-inbox.js — the Home "Needs your attention" card (task 2169), grown from
2
+ // the two-sided goal-membership inbox (task 1954, ADR 0112).
2
3
  //
3
- // Surfaces the actionable slice of a builder's private-goals queue in ONE place:
4
+ // This card is the SINGLE landing place for actionable items on Home: it owns
5
+ // the section (id 'attention-scroll'), renders the goal-membership slice
6
+ // itself, and hosts the idea-triage slice (#attention-ideas, rendered by
7
+ // builders.js). New actionable item classes join as line-item slices here —
8
+ // never as new Home cards (the anti-accretion rule).
9
+ //
10
+ // The goal slice surfaces a builder's private-goals queue:
4
11
  // • "Invitations to you" — pending invites addressed to me (Accept / Decline).
5
12
  // • "Requests to approve" — pending join-requests on goals I own or manage
6
13
  // (Approve / Deny).
@@ -81,32 +88,41 @@
81
88
  return `<p class="ginbox__pending card-foot">Your pending join ${noun}: ${links} — awaiting approval.</p>`;
82
89
  }
83
90
 
91
+ // task 2169: this render fills ONLY the goals slice (#attention-goals) — the
92
+ // ideas slice (#attention-ideas) belongs to builders.js's triage renderer.
93
+ // Card visibility + the headline count derive from BOTH via OTBAttention.
94
+ const MAX_GOAL_ROWS = 5;
84
95
  function render(inbox) {
96
+ const box = host && host.querySelector('#attention-goals');
97
+ if (!box) return;
85
98
  const invitations = inbox.invitations || [];
86
99
  const joinRequests = inbox.joinRequests || [];
87
100
  const myPending = inbox.myPendingRequests || [];
88
- // Hide the whole card when there is nothing to see OR act on.
89
101
  if (!invitations.length && !joinRequests.length && !myPending.length) {
90
- host.hidden = true;
91
- host.innerHTML = '';
102
+ box.innerHTML = '';
103
+ window.OTBAttention.refresh();
92
104
  return;
93
105
  }
94
- const actionable = invitations.length + joinRequests.length;
95
- let html = `<h2 id="goal-inbox-h" class="scroll__h">Goal inbox${actionable ? `<span class="count-pill">${actionable}</span>` : ''}</h2>`;
96
- if (actionable) {
97
- html += '<p class="scroll__lede">Invitations and join-requests waiting on you.</p>';
106
+ // Cap the card at a handful of rows — it is a summary, not the queue.
107
+ let budget = MAX_GOAL_ROWS;
108
+ const inv = invitations.slice(0, budget); budget -= inv.length;
109
+ const reqs = joinRequests.slice(0, Math.max(0, budget));
110
+ const overflow = (invitations.length - inv.length) + (joinRequests.length - reqs.length);
111
+ let html = '';
112
+ if (inv.length) {
113
+ html += '<h3 class="ginbox__sec-h">Goal invitations to you</h3>';
114
+ html += `<ul class="ginbox">${inv.map(invitationRow).join('')}</ul>`;
98
115
  }
99
- if (invitations.length) {
100
- html += '<h3 class="ginbox__sec-h">Invitations to you</h3>';
101
- html += `<ul class="ginbox">${invitations.map(invitationRow).join('')}</ul>`;
116
+ if (reqs.length) {
117
+ html += '<h3 class="ginbox__sec-h">Join-requests to approve</h3>';
118
+ html += `<ul class="ginbox">${reqs.map(requestRow).join('')}</ul>`;
102
119
  }
103
- if (joinRequests.length) {
104
- html += '<h3 class="ginbox__sec-h">Requests to approve</h3>';
105
- html += `<ul class="ginbox">${joinRequests.map(requestRow).join('')}</ul>`;
120
+ if (overflow > 0) {
121
+ html += `<p class="ginbox__pending">…and ${overflow} more waiting — <a href="/goals">open your goals</a>.</p>`;
106
122
  }
107
123
  html += pendingLine(myPending);
108
- host.innerHTML = html;
109
- host.hidden = false;
124
+ box.innerHTML = html;
125
+ window.OTBAttention.refresh();
110
126
  }
111
127
 
112
128
  async function load() {
@@ -115,9 +131,11 @@
115
131
  const inbox = await getJSON(`${API}/goals/inbox`);
116
132
  render(inbox);
117
133
  } catch (err) {
118
- // Signed out (401) or an older server without the route (404) → render nothing.
119
- host.hidden = true;
120
- host.innerHTML = '';
134
+ // Signed out (401) or an older server without the route (404) → render
135
+ // nothing in the goals slice; the ideas slice may still have content.
136
+ const box = host.querySelector('#attention-goals');
137
+ if (box) box.innerHTML = '';
138
+ window.OTBAttention.refresh();
121
139
  if (err.code !== 'auth-needed' && err.code !== 'not-found') {
122
140
  console.error('[hall] goal inbox load failed', err);
123
141
  }
@@ -174,18 +192,41 @@
174
192
  }
175
193
  }
176
194
 
195
+ // task 2169: this widget IS the "Needs your attention" card — ONE capped
196
+ // summary hosting every actionable item class: goal invitations +
197
+ // join-requests (this file) and ideas awaiting triage (builders.js renders
198
+ // into #attention-ideas). Future actionable item classes add a slice div
199
+ // here + call OTBAttention.refresh() — never a new Home card.
177
200
  function mount(hostEl) {
178
201
  host = hostEl;
179
- host.hidden = true; // stays hidden until load() finds something actionable
202
+ host.hidden = true; // stays hidden until a slice finds something actionable
203
+ host.innerHTML =
204
+ '<h2 id="attention-h" class="scroll__h">Needs your attention<span class="count-pill" id="attention-count" hidden></span></h2>' +
205
+ '<div id="attention-goals"></div>' +
206
+ '<div id="attention-ideas"></div>';
180
207
  host.addEventListener('click', onClick);
181
208
  load();
182
209
  }
183
210
 
184
- // order 24 → sits at the top of the Home main zone (just above the idea-triage
185
- // inbox), since it is personal + actionable. hiddenByDefault so an empty inbox
186
- // costs nothing until load() un-hides it.
211
+ // The card's shared visibility contract: any slice renderer calls refresh()
212
+ // after painting; the card shows iff any slice has actionable rows, and the
213
+ // headline pill is the cross-slice total.
214
+ window.OTBAttention = {
215
+ refresh() {
216
+ const sec = document.getElementById('attention-scroll');
217
+ if (!sec) return;
218
+ const n = sec.querySelectorAll('.ginbox__item, .triage-inbox__item').length;
219
+ const pending = sec.querySelector('.ginbox__pending');
220
+ const pill = document.getElementById('attention-count');
221
+ if (pill) { pill.hidden = !n; pill.textContent = String(n); }
222
+ sec.hidden = n === 0 && !pending;
223
+ },
224
+ };
225
+
226
+ // order 20 → the top of the Home main zone: personal + actionable beats
227
+ // informational. hiddenByDefault so a quiet inbox costs nothing.
187
228
  window.OTB.contributeHallWidget({
188
- id: 'goal-inbox-scroll', label: 'Goal inbox', order: 24, mount,
189
- labelledby: 'goal-inbox-h', view: 'home', zone: 'home-main', hiddenByDefault: true,
229
+ id: 'attention-scroll', label: 'Needs your attention', order: 20, mount,
230
+ labelledby: 'attention-h', view: 'home', zone: 'home-main', hiddenByDefault: true,
190
231
  });
191
232
  })();
@@ -23,6 +23,10 @@
23
23
  (function () {
24
24
  'use strict';
25
25
  const { escapeHtml, $, fmtNum, fmtDate, toast, rankLabel } = window.OTB;
26
+ // Shared component kit (hall-kit.js, task 2166): pager, filter bar, tabs,
27
+ // empty/skeleton states — adopted here by task 2171.
28
+ const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar,
29
+ emptyStateHtml, skeletonRows } = window.OTBKit;
26
30
  const API = '/api/bongos';
27
31
  // Generated typed client (task 1996 / R09), served as window.BongosClient by
28
32
  // /lib/bongos-client.js. baseUrl:'' passes the full same-origin paths this file
@@ -38,27 +42,15 @@
38
42
  // more members, more criteria). Conflicts uses CONFLICT_CAP above.
39
43
  const MEMBER_CAP = 5;
40
44
  const CRITERIA_CAP = 4;
41
- // task 1932 — the Goals LIST (goal-card grid) is paginated with a "Per page"
42
- // dropdown. Options + the localStorage key that persists the choice; below
43
- // LIST_MIN_PER_PAGE the whole grid fits one page so the bar hides.
44
- const LIST_PER_PAGE_OPTIONS = [10, 25, 50, 100];
45
- const LIST_MIN_PER_PAGE = LIST_PER_PAGE_OPTIONS[0];
46
- const GOALS_PER_PAGE_KEY = 'otb.goals.perPage';
47
- function loadGoalsPerPage() {
48
- try {
49
- const v = Number(window.localStorage.getItem(GOALS_PER_PAGE_KEY));
50
- return LIST_PER_PAGE_OPTIONS.includes(v) ? v : 25;
51
- } catch (_) { return 25; }
52
- }
53
- // task 1956 — the goal-detail Work tables (To do / Completed) paginate like the
54
- // grid; each reads/persists its own per-page key.
55
- const WORK_PER_PAGE_KEY = { todo: 'otb.goals.todoPerPage', done: 'otb.goals.donePerPage' };
56
- function loadPerPageFor(key) {
57
- try {
58
- const v = Number(window.localStorage.getItem(key));
59
- return LIST_PER_PAGE_OPTIONS.includes(v) ? v : 25;
60
- } catch (_) { return 25; }
61
- }
45
+ // task 1932/1956 → kit (task 2171): the goal-card grid + the goal-detail Work
46
+ // tables (To do / Completed) all paginate through the shared OTBKit pager.
47
+ // localStorage keys are unchanged so builders' per-page prefs survive; the bar
48
+ // markup is stamped into the #…-pager-mount divs at boot via pagerBarHtml.
49
+ const pagers = {
50
+ list: makePager('goals', 'otb.goals.perPage'),
51
+ todo: makePager('goal-todo', 'otb.goals.todoPerPage'),
52
+ done: makePager('goal-done', 'otb.goals.donePerPage'),
53
+ };
62
54
  // Terminal "deliberately cut" states — neither work-to-do nor done. Excluded
63
55
  // from the todo list and the conflict scan the same way migration 017 keeps
64
56
  // them out of version_progress ("abandoned is a terminal state (deliberately
@@ -79,7 +71,6 @@
79
71
  view: 'list', // task 1828 — 'list' | 'detail' | 'conflicts'
80
72
  inviteOpen: false, // task 1762 — the invite form's open/closed state
81
73
  directory: null, // task 1877 — GET /builders/directory, fetched once and cached
82
- workTab: 'todo', // task 1888 — active pane in the Work card ('todo' | 'done')
83
74
  // task 1828 — per-table column sort. by=null → natural order (todo: server
84
75
  // order; done: shipped-desc, applied at load). dir: 'desc' | 'asc'.
85
76
  workSort: { todo: { by: null, dir: 'desc' }, done: { by: null, dir: 'desc' } },
@@ -88,12 +79,6 @@
88
79
  claimsByTask: new Map(), // task 1828 — String(task_id) → {claim_id, builder_id, login} (active claims; powers the Release button)
89
80
  conflictPairs: null, // task 1888 — unified pairs (server or client estimate)
90
81
  conflictSource: null, // 'server' | 'client' | null (while loading / on error)
91
- listPager: { page: 1, perPage: loadGoalsPerPage() }, // task 1932 — goal-grid pagination
92
- // task 1956 — goal-detail Work-table pagination (per table: To do / Completed).
93
- workPager: {
94
- todo: { page: 1, perPage: loadPerPageFor(WORK_PER_PAGE_KEY.todo) },
95
- done: { page: 1, perPage: loadPerPageFor(WORK_PER_PAGE_KEY.done) },
96
- },
97
82
  // task 1953 — private goals: my own pending join-request on the OPEN goal (row
98
83
  // from GET /goals/inbox's myPendingRequests, filtered to this goal), or null.
99
84
  myPendingRequest: null,
@@ -204,29 +189,6 @@
204
189
  (!mineOnly || iAmMember(g)));
205
190
  }
206
191
 
207
- // task 1932 — the goal grid's pagination bar (range, Per-page <select>,
208
- // Prev/Next around "Page X of Y"). Hidden when the whole set fits one page
209
- // (total <= LIST_MIN_PER_PAGE); otherwise shown so the per-page control stays
210
- // reachable. Pure render from the slice math renderList computed.
211
- function renderGoalsPager(total, page, totalPages, start, shownCount) {
212
- const pager = $('#goals-pager');
213
- if (!pager) return;
214
- if (total <= LIST_MIN_PER_PAGE) { pager.hidden = true; return; }
215
- pager.hidden = false;
216
- const range = $('#goals-range');
217
- if (range) range.textContent = total
218
- ? `Showing ${start + 1}–${start + shownCount} of ${total}`
219
- : 'Showing 0 of 0';
220
- const ind = $('#goals-page-ind');
221
- if (ind) ind.textContent = `Page ${page} of ${totalPages}`;
222
- const prev = $('#goals-prev');
223
- if (prev) prev.disabled = page <= 1;
224
- const next = $('#goals-next');
225
- if (next) next.disabled = page >= totalPages;
226
- const sel = $('#goals-per-page');
227
- if (sel && sel.value !== String(state.listPager.perPage)) sel.value = String(state.listPager.perPage);
228
- }
229
-
230
192
  function renderList() {
231
193
  const grid = $('#goal-grid');
232
194
  if (!grid) return;
@@ -239,24 +201,20 @@
239
201
  if (count) count.textContent = `${rows.length} goal${rows.length === 1 ? '' : 's'}`;
240
202
 
241
203
  if (!rows.length) {
242
- grid.innerHTML = '<div class="profile__placeholder">No goals match these filters.</div>';
243
- state.listPager.page = 1;
204
+ grid.innerHTML = emptyStateHtml('No goals match these filters.');
205
+ pagers.list.page = 1;
244
206
  const emptyPager = $('#goals-pager');
245
207
  if (emptyPager) emptyPager.hidden = true;
246
208
  return;
247
209
  }
248
210
 
249
- // task 1932 — paginate the grid. Clamp the page (the filtered set may have
250
- // shrunk under a re-render), slice to the current page, then paint the bar.
251
- // Priority numbers reflect the GLOBAL position (start + i + 1), not the
252
- // page-local index, so drag-reorder priorities stay meaningful across pages.
253
- const total = rows.length;
254
- const perPage = state.listPager.perPage;
255
- const totalPages = Math.max(1, Math.ceil(total / perPage));
256
- if (state.listPager.page > totalPages) state.listPager.page = totalPages;
257
- if (state.listPager.page < 1) state.listPager.page = 1;
258
- const start = (state.listPager.page - 1) * perPage;
259
- const pageRows = rows.slice(start, start + perPage);
211
+ // task 1932 → kit (2171): paginate the grid through the shared pager (it
212
+ // clamps the page — the filtered set may have shrunk under a re-render —
213
+ // and paints the bar). Priority numbers reflect the GLOBAL position
214
+ // (start + i + 1), not the page-local index, so drag-reorder priorities
215
+ // stay meaningful across pages.
216
+ const pageRows = paginate(rows, pagers.list);
217
+ const start = (pagers.list.page - 1) * pagers.list.perPage;
260
218
  grid.innerHTML = pageRows.map((g, i) => {
261
219
  const d = state.details.get(String(g.id));
262
220
  const crit = d ? (d.criteria || []) : [];
@@ -276,10 +234,11 @@
276
234
  return `<a class="goal-card${blocked ? ' goal-card--blocked' : ''}" href="#/goal/${escapeHtml(String(g.id))}" data-goal-id="${escapeHtml(String(g.id))}"${reorderable ? ' draggable="true"' : ''}>
277
235
  <div class="goal-card__head">
278
236
  ${handle}${priority}
279
- ${d ? ring(sat, crit.length, g.status === 'achieved') : ''}
237
+ ${d ? ring(sat, crit.length, g.status === 'achieved', 34) : ''}
280
238
  <span class="goal-card__title">${escapeHtml(g.title || 'Untitled goal')}</span>
281
239
  </div>
282
240
  <div class="goal-card__meta">
241
+ <span class="goal-card__id" title="Goal id — use this to reference the goal (e.g. 'goal ${escapeHtml(String(g.id))}'). Distinct from the priority position on the left.">#${escapeHtml(String(g.id))}</span>
283
242
  <span>${escapeHtml(g.version_id || '')}</span>
284
243
  ${statusBadge(g.status)}
285
244
  ${blockedPill}
@@ -291,7 +250,6 @@
291
250
  </div>
292
251
  </a>`;
293
252
  }).join('');
294
- renderGoalsPager(total, state.listPager.page, totalPages, start, pageRows.length);
295
253
  }
296
254
 
297
255
  // ---- drag-to-reorder (task 1760, Archon-only, one version at a time) ------
@@ -331,7 +289,7 @@
331
289
  // of the goals on other pages. Priority numbers reflect the global
332
290
  // position (start + i + 1).
333
291
  const filtered = filteredGoals();
334
- const start = (state.listPager.page - 1) * state.listPager.perPage;
292
+ const start = (pagers.list.page - 1) * pagers.list.perPage;
335
293
  const cards = [...grid.querySelectorAll('.goal-card')];
336
294
  cards.forEach((card, i) => {
337
295
  const num = card.querySelector('.goal-card__priority');
@@ -435,53 +393,12 @@
435
393
  el.textContent = String(n);
436
394
  }
437
395
 
438
- // task 1956 — a Work table's pagination bar (range, Per-page <select>, Prev/Next
439
- // around "Page X of Y"), keyed by DOM prefix (goal-todo / goal-done). Hidden
440
- // when the whole table fits one minimum page.
441
- function renderWorkPager(prefix, pager, total, totalPages, start, shownCount) {
442
- const bar = $(`#${prefix}-pager`);
443
- if (!bar) return;
444
- if (total <= LIST_MIN_PER_PAGE) { bar.hidden = true; return; }
445
- bar.hidden = false;
446
- const range = $(`#${prefix}-range`);
447
- if (range) range.textContent = total
448
- ? `Showing ${start + 1}–${start + shownCount} of ${total}`
449
- : 'Showing 0 of 0';
450
- const ind = $(`#${prefix}-page-ind`);
451
- if (ind) ind.textContent = `Page ${pager.page} of ${totalPages}`;
452
- const prev = $(`#${prefix}-prev`);
453
- if (prev) prev.disabled = pager.page <= 1;
454
- const next = $(`#${prefix}-next`);
455
- if (next) next.disabled = pager.page >= totalPages;
456
- const sel = $(`#${prefix}-per-page`);
457
- if (sel && sel.value !== String(pager.perPage)) sel.value = String(pager.perPage);
458
- }
459
-
460
- // Slice `rows` to a Work table's current page (clamped), paint its bar, return
461
- // the slice to render.
462
- function paginateWork(rows, pager, prefix) {
463
- const total = rows.length;
464
- const totalPages = Math.max(1, Math.ceil(total / pager.perPage));
465
- if (pager.page > totalPages) pager.page = totalPages;
466
- if (pager.page < 1) pager.page = 1;
467
- const start = (pager.page - 1) * pager.perPage;
468
- const slice = rows.slice(start, start + pager.perPage);
469
- renderWorkPager(prefix, pager, total, totalPages, start, slice.length);
470
- return slice;
471
- }
472
-
473
- // task 1888 — the Work card's To do / Completed tab toggle. Both tables stay
474
- // in the DOM (already rendered); switching only flips pane visibility.
475
- function setWorkTab(tab) {
476
- state.workTab = tab;
477
- const isTodo = tab === 'todo';
478
- $('#goal-tab-todo')?.setAttribute('aria-selected', String(isTodo));
479
- $('#goal-tab-done')?.setAttribute('aria-selected', String(!isTodo));
480
- const todoPane = $('#goal-todo-pane');
481
- const donePane = $('#goal-done-pane');
482
- if (todoPane) todoPane.hidden = !isTodo;
483
- if (donePane) donePane.hidden = isTodo;
484
- }
396
+ // task 1888 → kit (2171): the Work card's To do / Completed toggle is an
397
+ // OTBKit tabs instance (built in wireKit below). hash:false is load-bearing —
398
+ // this page owns the #/goal/N sub-route namespace, so the tabs key off
399
+ // in-page state and never touch location.hash.
400
+ let workTabs = null;
401
+ function setWorkTab(tab) { workTabs?.activate(tab); }
485
402
 
486
403
  // task 1828 — sortable column key → header id, per table. Adding a column
487
404
  // means an entry here + a matching <th id> in goals.html.
@@ -546,21 +463,21 @@
546
463
  const doneBody = document.querySelector('#goal-done tbody');
547
464
  const todo = sortWorkRows(state.work.todo, state.workSort.todo);
548
465
  const done = sortWorkRows(state.work.done, state.workSort.done);
549
- // task 1956 — paginate each table (was ROW_CAP + a show-all toggle).
550
- const todoSlice = paginateWork(todo, state.workPager.todo, 'goal-todo');
551
- const doneSlice = paginateWork(done, state.workPager.done, 'goal-done');
466
+ // task 1956 → kit (2171): paginate each table through the shared pager.
467
+ const todoSlice = paginate(todo, pagers.todo);
468
+ const doneSlice = paginate(done, pagers.done);
552
469
  if (todoBody) {
553
470
  todoBody.innerHTML = todo.length
554
471
  ? taskRowsTodo(todoSlice)
555
- : `<tr><td colspan="6" class="ledger__empty">Nothing queued under this goal. Members can author bounded tasks here${state.me ? '' : ' once signed in'}.</td></tr>`;
472
+ : `<tr><td colspan="6" class="ledger__empty">${emptyStateHtml(`Nothing queued under this goal. Members can author bounded tasks here${state.me ? '' : ' once signed in'}.`)}</td></tr>`;
556
473
  }
557
474
  if (doneBody) {
558
475
  doneBody.innerHTML = done.length
559
476
  ? taskRowsDone(doneSlice)
560
- : '<tr><td colspan="5" class="ledger__empty">Nothing completed yet.</td></tr>';
477
+ : `<tr><td colspan="5" class="ledger__empty">${emptyStateHtml('Nothing completed yet.')}</td></tr>`;
561
478
  }
562
- setCount('#goal-todo-count', state.work.todo.length);
563
- setCount('#goal-done-count', state.work.done.length);
479
+ workTabs?.setCount('todo', state.work.todo.length);
480
+ workTabs?.setCount('done', state.work.done.length);
564
481
  reflectSortHeaders('todo');
565
482
  reflectSortHeaders('done');
566
483
  }
@@ -648,7 +565,7 @@
648
565
  function fillConflicts(container, pairs) {
649
566
  if (!container) return;
650
567
  if (pairs == null) {
651
- container.innerHTML = '<div class="profile__placeholder">Conflicts could not be computed.</div>';
568
+ container.innerHTML = emptyStateHtml('Conflicts could not be computed.');
652
569
  return;
653
570
  }
654
571
  const note = state.conflictSource === 'client'
@@ -656,7 +573,7 @@
656
573
  : '';
657
574
  container.innerHTML = (pairs.length
658
575
  ? pairs.map(conflictHtml).join('')
659
- : '<div class="profile__placeholder">No conflicts detected among this goal’s open tasks.</div>') + note;
576
+ : emptyStateHtml('No conflicts detected among this goal’s open tasks.')) + note;
660
577
  }
661
578
 
662
579
  const currentDetail = () => (state.current == null ? null : state.details.get(String(state.current)));
@@ -719,7 +636,7 @@
719
636
  // (withDetail=false) instead reveals detail via a per-criterion <details> expand
720
637
  // (BV1.R79) so the full text is one click away, not hover-only.
721
638
  function critListHtml(list, withDetail) {
722
- if (!list.length) return '<div class="profile__placeholder">No done-when criteria yet.</div>';
639
+ if (!list.length) return emptyStateHtml('No done-when criteria yet.');
723
640
  return `<ul class="crit-list${withDetail ? ' crit-list--full' : ''}">${list.map((c) => {
724
641
  const parts = window.OTB.splitCriterion ? window.OTB.splitCriterion(c.criterion_md) : { label: c.criterion_md, detail: '' };
725
642
  const body = withDetail
@@ -808,7 +725,7 @@
808
725
  if (list) {
809
726
  list.innerHTML = members.length
810
727
  ? members.slice(0, MEMBER_CAP).map(memberLi).join('')
811
- : '<li class="member"><span class="profile__placeholder">No members yet — be the first to join.</span></li>';
728
+ : `<li>${emptyStateHtml('No members yet — be the first to join.')}</li>`;
812
729
  }
813
730
  renderViewAllFoot('#goal-members-foot', members.length, MEMBER_CAP, 'members', 'members');
814
731
  renderMembersFull(d);
@@ -833,14 +750,14 @@
833
750
  <span class="mstat"><strong>${fmtNum(earned)}</strong> ${escapeHtml(cur)} earned</span>
834
751
  <span class="mstat"><strong>${fmtNum(open)}</strong> open</span>`;
835
752
  } else {
836
- agg.innerHTML = '<span class="mstat profile__placeholder">Loading this goal’s work…</span>';
753
+ agg.innerHTML = '<span class="mstat"><span class="kit-skel"></span></span>';
837
754
  }
838
755
  }
839
756
  const full = $('#goal-members-full');
840
757
  if (full) {
841
758
  full.innerHTML = members.length
842
759
  ? `<ul class="member-list">${members.map((m) => memberStatLi(m, stats)).join('')}</ul>`
843
- : '<div class="profile__placeholder">No members yet — be the first to join.</div>';
760
+ : emptyStateHtml('No members yet — be the first to join.');
844
761
  }
845
762
  const back = $('#goal-members-back');
846
763
  if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
@@ -857,7 +774,9 @@
857
774
  const iAmLead = members.some((m) => sameId(m.builder_id, selfId()) && m.role === 'lead');
858
775
 
859
776
  const h = $('#goal-detail-h');
860
- if (h) h.textContent = g.title || 'Untitled goal';
777
+ // task 2163 — surface the numeric goal id alongside the title (escaped title,
778
+ // so switching from textContent to innerHTML stays injection-safe).
779
+ if (h) h.innerHTML = `<span class="goal-detail__id" title="Goal id">#${escapeHtml(String(g.id))}</span> ${escapeHtml(g.title || 'Untitled goal')}`;
861
780
  const desc = $('#goal-detail-desc');
862
781
  if (desc) desc.textContent = g.description || '';
863
782
  const meta = $('#goal-detail-meta');
@@ -1040,7 +959,7 @@
1040
959
  }
1041
960
  box.hidden = false;
1042
961
  if (!state.inviteOpen) {
1043
- box.innerHTML = '<button class="btn-ghost" id="goal-invite-open" type="button">+ Invite member</button>';
962
+ box.innerHTML = '<button class="btn-ghost" id="goal-invite-open" type="button">+ Invite</button>';
1044
963
  $('#goal-invite-open')?.addEventListener('click', async () => {
1045
964
  state.inviteOpen = true;
1046
965
  await loadDirectory();
@@ -1205,8 +1124,8 @@
1205
1124
  <span class="pending-row__note">invited — awaiting their response</span>
1206
1125
  </li>`;
1207
1126
  const parts = [];
1208
- if (requests.length) parts.push(`<div class="pending-group"><h3 class="pending-group__h">Join requests<span class="goal-count">${requests.length}</span></h3><ul class="pending-list">${requests.map(requestLi).join('')}</ul></div>`);
1209
- if (invites.length) parts.push(`<div class="pending-group"><h3 class="pending-group__h">Pending invitations<span class="goal-count">${invites.length}</span></h3><ul class="pending-list">${invites.map(inviteLi).join('')}</ul></div>`);
1127
+ if (requests.length) parts.push(`<div class="pending-group"><h3 class="pending-group__h">Join requests<span class="count-pill">${requests.length}</span></h3><ul class="pending-list">${requests.map(requestLi).join('')}</ul></div>`);
1128
+ if (invites.length) parts.push(`<div class="pending-group"><h3 class="pending-group__h">Pending invitations<span class="count-pill">${invites.length}</span></h3><ul class="pending-list">${invites.map(inviteLi).join('')}</ul></div>`);
1210
1129
  return parts.join('');
1211
1130
  }
1212
1131
  function renderPendingRequests() {
@@ -1265,10 +1184,9 @@
1265
1184
  // task 1888 — a freshly opened goal starts from the compact defaults: To do
1266
1185
  // tab selected, capped lists, counts hidden until its own data lands.
1267
1186
  function resetWorkUi() {
1268
- state.workTab = 'todo';
1269
1187
  // task 1956 — a freshly opened goal starts both Work tables on page 1 (per-page persists).
1270
- state.workPager.todo.page = 1;
1271
- state.workPager.done.page = 1;
1188
+ pagers.todo.page = 1;
1189
+ pagers.done.page = 1;
1272
1190
  state.workSort = { todo: { by: null, dir: 'desc' }, done: { by: null, dir: 'desc' } };
1273
1191
  state.work = { todo: [], done: [] };
1274
1192
  state.workLoaded = false;
@@ -1282,7 +1200,9 @@
1282
1200
  const pending = $('#goal-pending');
1283
1201
  if (pending) { pending.hidden = true; pending.innerHTML = ''; }
1284
1202
  setWorkTab('todo');
1285
- ['#goal-todo-count', '#goal-done-count', '#goal-conflicts-count', '#goal-conflicts-view-count',
1203
+ workTabs?.setCount('todo', null);
1204
+ workTabs?.setCount('done', null);
1205
+ ['#goal-conflicts-count', '#goal-conflicts-view-count',
1286
1206
  '#goal-members-count', '#goal-members-view-count', '#goal-completion-count'].forEach((sel) => {
1287
1207
  const el = $(sel);
1288
1208
  if (el) { el.hidden = true; el.textContent = ''; }
@@ -1294,14 +1214,15 @@
1294
1214
  ['#goal-conflicts-card', '#goal-conflicts-view-card'].forEach((sel) => $(sel)?.classList.remove('goal-conflicts-card--live'));
1295
1215
  reflectSortHeaders('todo');
1296
1216
  reflectSortHeaders('done');
1217
+ // Kit shimmer placeholders while the goal's tasks load (was "…" cells).
1297
1218
  const todoBody = document.querySelector('#goal-todo tbody');
1298
1219
  const doneBody = document.querySelector('#goal-done tbody');
1299
- if (todoBody) todoBody.innerHTML = '<tr><td colspan="6" class="ledger__empty">…</td></tr>';
1300
- if (doneBody) doneBody.innerHTML = '<tr><td colspan="5" class="ledger__empty">…</td></tr>';
1220
+ if (todoBody) todoBody.innerHTML = skeletonRows(3, 6);
1221
+ if (doneBody) doneBody.innerHTML = skeletonRows(3, 5);
1301
1222
  const conflicts = $('#goal-conflicts');
1302
- if (conflicts) conflicts.innerHTML = '<div class="profile__placeholder">…</div>';
1223
+ if (conflicts) conflicts.innerHTML = '<span class="kit-skel"></span>';
1303
1224
  const conflictsFull = $('#goal-conflicts-full');
1304
- if (conflictsFull) conflictsFull.innerHTML = '<div class="profile__placeholder">…</div>';
1225
+ if (conflictsFull) conflictsFull.innerHTML = '<span class="kit-skel"></span>';
1305
1226
  }
1306
1227
 
1307
1228
  // task 1828 — views sharing this page: the list, a goal's detail, and the
@@ -1394,6 +1315,12 @@
1394
1315
  }
1395
1316
 
1396
1317
  async function boot() {
1318
+ // Kit shimmer placeholders while the goal list loads (was one "…" div).
1319
+ const grid = $('#goal-grid');
1320
+ if (grid) {
1321
+ grid.innerHTML = Array.from({ length: 8 }, () =>
1322
+ '<div class="goal-card goal-card--skel" aria-hidden="true"><span class="kit-skel"></span><span class="kit-skel"></span></div>').join('');
1323
+ }
1397
1324
  try {
1398
1325
  state.me = await getJSON(`${API}/me`).catch(() => null);
1399
1326
  // task 1828 — "My goals" needs an identity; disable it when signed out.
@@ -1412,30 +1339,37 @@
1412
1339
  const sub = $('#goals-sub');
1413
1340
  if (sub) sub.textContent = 'Goals could not be loaded. Refresh to try again.';
1414
1341
  const grid = $('#goal-grid');
1415
- if (grid) grid.innerHTML = '<div class="profile__placeholder">Goals could not be loaded.</div>';
1342
+ if (grid) grid.innerHTML = emptyStateHtml('Goals could not be loaded.');
1416
1343
  }
1417
1344
  }
1418
1345
 
1419
1346
  // task 1932 — any list filter change re-paginates the grid from page 1.
1420
- const onListFilterChange = () => { state.listPager.page = 1; renderList(); };
1421
- $('#f-goal-version')?.addEventListener('change', onListFilterChange);
1422
- $('#f-goal-status')?.addEventListener('change', onListFilterChange);
1423
- $('#f-goal-mine')?.addEventListener('change', onListFilterChange); // task 1828 — My goals filter
1424
- // task 1932 — the goal grid's pagination controls (per-page dropdown + prev/next).
1425
- $('#goals-per-page')?.addEventListener('change', (e) => {
1426
- const v = Number(e.target.value);
1427
- if (!LIST_PER_PAGE_OPTIONS.includes(v)) return;
1428
- state.listPager.perPage = v;
1429
- state.listPager.page = 1;
1430
- try { window.localStorage.setItem(GOALS_PER_PAGE_KEY, String(v)); } catch (_) { /* private mode → in-memory only */ }
1431
- renderList();
1432
- });
1433
- $('#goals-prev')?.addEventListener('click', () => {
1434
- if (state.listPager.page > 1) { state.listPager.page -= 1; renderList(); }
1435
- });
1436
- $('#goals-next')?.addEventListener('click', () => {
1437
- state.listPager.page += 1; renderList(); // renderList clamps to the last page
1347
+ const onListFilterChange = () => { pagers.list.page = 1; renderList(); };
1348
+ // task 2171 — the list filter bar is a kit filterBar: Version / Status /
1349
+ // Membership, all primary (no overflow needed). Control ids are unchanged
1350
+ // (f-goal-version, f-goal-status, f-goal-mine) so filteredGoals' reads +
1351
+ // populateVersionFilter's dynamic option fill keep working untouched.
1352
+ makeFilterBar({
1353
+ mount: $('#goals-filters'),
1354
+ primary: [
1355
+ { id: 'f-goal-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
1356
+ { id: 'f-goal-status', label: 'Status', value: 'open', options: [{ value: '', label: '— any —' }, 'open', 'achieved', 'archived'] },
1357
+ { id: 'f-goal-mine', label: 'Membership', options: [{ value: '', label: 'All goals' }, { value: 'mine', label: 'My goals' }] },
1358
+ ],
1359
+ onChange: onListFilterChange,
1438
1360
  });
1361
+ // task 2171 — stamp + wire the kit pager bars (grid + both Work tables); the
1362
+ // bar ids and localStorage prefs are exactly what the bespoke pagers used.
1363
+ const pagerMounts = [
1364
+ ['#goals-pager-mount', pagers.list, 'Goals', renderList],
1365
+ ['#goal-todo-pager-mount', pagers.todo, 'To-do tasks', renderWork],
1366
+ ['#goal-done-pager-mount', pagers.done, 'Completed tasks', renderWork],
1367
+ ];
1368
+ for (const [sel, pager, label, rerender] of pagerMounts) {
1369
+ const mount = $(sel);
1370
+ if (mount) mount.innerHTML = pagerBarHtml(pager.prefix, { label });
1371
+ wirePager(pager, rerender);
1372
+ }
1439
1373
  $('#goal-back')?.addEventListener('click', (e) => { e.preventDefault(); window.location.hash = ''; });
1440
1374
  // task 1828 — "← Back to goal" from any of the full views.
1441
1375
  ['#goal-conflicts-back', '#goal-members-back', '#goal-completion-back'].forEach((sel) => {
@@ -1444,25 +1378,16 @@
1444
1378
  if (state.current != null) window.location.hash = `#/goal/${state.current}`;
1445
1379
  });
1446
1380
  });
1447
- // task 1888 — the Work card's tab toggle.
1448
- $('#goal-tab-todo')?.addEventListener('click', () => setWorkTab('todo'));
1449
- $('#goal-tab-done')?.addEventListener('click', () => setWorkTab('done'));
1450
- // task 1956 — pagination controls for each Work table (per-page dropdown + prev/next).
1451
- [['goal-todo', 'todo'], ['goal-done', 'done']].forEach(([prefix, key]) => {
1452
- $(`#${prefix}-per-page`)?.addEventListener('change', (e) => {
1453
- const v = Number(e.target.value);
1454
- if (!LIST_PER_PAGE_OPTIONS.includes(v)) return;
1455
- state.workPager[key].perPage = v;
1456
- state.workPager[key].page = 1;
1457
- try { window.localStorage.setItem(WORK_PER_PAGE_KEY[key], String(v)); } catch (_) { /* private mode → in-memory only */ }
1458
- renderWork();
1459
- });
1460
- $(`#${prefix}-prev`)?.addEventListener('click', () => {
1461
- if (state.workPager[key].page > 1) { state.workPager[key].page -= 1; renderWork(); }
1462
- });
1463
- $(`#${prefix}-next`)?.addEventListener('click', () => {
1464
- state.workPager[key].page += 1; renderWork(); // renderWork clamps to the last page
1465
- });
1381
+ // task 1888 → kit (2171): the Work card's tab toggle. hash:false is
1382
+ // load-bearing — this page owns the #/goal/N sub-route namespace, so the
1383
+ // tabs key off in-page state and never touch location.hash.
1384
+ workTabs = makeTabs({
1385
+ mount: $('#goal-work-tabs'),
1386
+ hash: false,
1387
+ tabs: [
1388
+ { id: 'todo', label: 'To do', panel: 'goal-todo-pane' },
1389
+ { id: 'done', label: 'Completed', panel: 'goal-done-pane' },
1390
+ ],
1466
1391
  });
1467
1392
  // task 1828 — click / keyboard sort on the Work-table column headers.
1468
1393
  Object.entries(WORK_SORT_HEADERS).forEach(([table, cols]) => {