@bongos/core 1.17.2 → 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 (77) hide show
  1. package/.bongos-core.json +292 -32
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +4 -2
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +65 -26
  8. package/docs/api-reference.md +3 -2
  9. package/modules/hall-ui/public/builders.js +96 -646
  10. package/modules/hall-ui/public/goal-inbox.js +67 -26
  11. package/modules/hall-ui/public/goals-page.js +4 -1
  12. package/modules/hall-ui/public/goals.html +8 -0
  13. package/modules/hall-ui/public/hall-kit.js +474 -0
  14. package/modules/hall-ui/public/hall-widgets.js +11 -15
  15. package/modules/hall-ui/public/instances-panel.js +24 -3
  16. package/modules/hall-ui/public/new-project-wizard.js +6 -5
  17. package/modules/hall-ui/public/style.css +125 -0
  18. package/modules/hall-ui/public/work.css +194 -0
  19. package/modules/hall-ui/public/work.html +46 -374
  20. package/modules/hall-ui/public/work.js +137 -102
  21. package/modules/lifecycle/criterion-suggest.js +80 -0
  22. package/modules/lifecycle/db.js +42 -5
  23. package/modules/lifecycle/routes/goals.js +26 -0
  24. package/modules/lifecycle/routes/tasks.js +21 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/hall-preview/README.md +103 -0
  28. package/scripts/hall-preview/fixtures/access-requests.json +1 -0
  29. package/scripts/hall-preview/fixtures/achievements.json +76 -0
  30. package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
  31. package/scripts/hall-preview/fixtures/audit-log.json +1 -0
  32. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +1 -0
  33. package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
  34. package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
  35. package/scripts/hall-preview/fixtures/builders__roster.json +1 -0
  36. package/scripts/hall-preview/fixtures/goals.json +502 -0
  37. package/scripts/hall-preview/fixtures/goals__25.json +96 -0
  38. package/scripts/hall-preview/fixtures/goals__26.json +67 -0
  39. package/scripts/hall-preview/fixtures/goals__3.json +103 -0
  40. package/scripts/hall-preview/fixtures/goals__35.json +49 -0
  41. package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
  42. package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
  43. package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
  44. package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
  45. package/scripts/hall-preview/fixtures/grades__by-builder.json +1 -0
  46. package/scripts/hall-preview/fixtures/inbox.json +330 -0
  47. package/scripts/hall-preview/fixtures/instance.json +124 -0
  48. package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
  49. package/scripts/hall-preview/fixtures/me.json +476 -0
  50. package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
  51. package/scripts/hall-preview/fixtures/override-requests.json +1 -0
  52. package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
  53. package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
  54. package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
  55. package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
  56. package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
  57. package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
  58. package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
  59. package/scripts/hall-preview/fixtures/public__grades.json +50 -0
  60. package/scripts/hall-preview/fixtures/public__progress.json +124 -0
  61. package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
  62. package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
  63. package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
  64. package/scripts/hall-preview/fixtures/security__docs.json +1 -0
  65. package/scripts/hall-preview/fixtures/security__reports.json +1 -0
  66. package/scripts/hall-preview/fixtures/sessions__search.json +1 -0
  67. package/scripts/hall-preview/fixtures/tasks.json +2420 -0
  68. package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
  69. package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
  70. package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
  71. package/scripts/hall-preview/fixtures/versions.json +89 -0
  72. package/scripts/hall-preview/refresh-fixtures.js +77 -0
  73. package/scripts/hall-preview/server.js +178 -0
  74. package/scripts/migrate.sh +29 -4
  75. package/src/bongos/optimizer.js +37 -2
  76. package/tests/criterion_suggest.mjs +106 -0
  77. package/tests/migrate_root_resolution.mjs +97 -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
  })();
@@ -280,6 +280,7 @@
280
280
  <span class="goal-card__title">${escapeHtml(g.title || 'Untitled goal')}</span>
281
281
  </div>
282
282
  <div class="goal-card__meta">
283
+ <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
284
  <span>${escapeHtml(g.version_id || '')}</span>
284
285
  ${statusBadge(g.status)}
285
286
  ${blockedPill}
@@ -857,7 +858,9 @@
857
858
  const iAmLead = members.some((m) => sameId(m.builder_id, selfId()) && m.role === 'lead');
858
859
 
859
860
  const h = $('#goal-detail-h');
860
- if (h) h.textContent = g.title || 'Untitled goal';
861
+ // task 2163 — surface the numeric goal id alongside the title (escaped title,
862
+ // so switching from textContent to innerHTML stays injection-safe).
863
+ if (h) h.innerHTML = `<span class="goal-detail__id" title="Goal id">#${escapeHtml(String(g.id))}</span> ${escapeHtml(g.title || 'Untitled goal')}`;
861
864
  const desc = $('#goal-detail-desc');
862
865
  if (desc) desc.textContent = g.description || '';
863
866
  const meta = $('#goal-detail-meta');
@@ -38,6 +38,14 @@
38
38
  .goal-deps--blocked { color: var(--warn); font-weight: 600; }
39
39
  .goal-card__title { font-weight: 650; font-size: 14px; flex: 1; min-width: 0; }
40
40
  .goal-card__meta { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: 12px; color: var(--ink-faint); }
41
+ /* task 2163 — the real goal id, shown as a monospace #NNN token so it reads as
42
+ an identifier and never gets confused with the priority ordinal on the left. */
43
+ .goal-card__id, .goal-detail__id {
44
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
45
+ font-variant-numeric: tabular-nums; font-weight: 600; color: var(--ink-faint);
46
+ }
47
+ .goal-card__id { padding: 0 5px; border: 1px solid var(--rule-soft); border-radius: 5px; }
48
+ .goal-detail__id { font-size: 0.72em; vertical-align: 0.12em; margin-right: 4px; }
41
49
  .goal-card__foot { display: flex; gap: 14px; font-size: 12.5px; color: var(--ink-soft); }
42
50
  .goal-card__foot strong { font-variant-numeric: tabular-nums; }
43
51
 
@@ -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
+ })();