@bongos/core 1.17.3 → 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 (95) hide show
  1. package/.bongos-core.json +161 -91
  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/docker-entrypoint.sh +0 -0
  9. package/docs/adr/0139-platform-history-migration-to-cloudbongos.md +94 -0
  10. package/docs/adr/0140-cloudbongos-standalone-reprovision.md +43 -0
  11. package/docs/adr/README.md +3 -0
  12. package/docs/api/openapi.json +4 -345
  13. package/docs/api-reference.md +5 -17
  14. package/docs/file-map.md +7 -25
  15. package/docs/onboarding/diagrams/05-versioning.mmd +4 -6
  16. package/docs/onboarding/diagrams/assertions.json +5 -9
  17. package/modules/hall-ui/public/builders.js +37 -0
  18. package/modules/hall-ui/public/goals-page.js +98 -176
  19. package/modules/hall-ui/public/goals.css +183 -0
  20. package/modules/hall-ui/public/goals.html +35 -261
  21. package/modules/hall-ui/public/goals.js +7 -2
  22. package/modules/hall-ui/public/hall-widgets.js +12 -4
  23. package/modules/hall-ui/public/settings.css +355 -0
  24. package/modules/hall-ui/public/settings.html +86 -414
  25. package/modules/hall-ui/public/settings.js +96 -35
  26. package/modules/hall-ui/public/style.css +31 -0
  27. package/modules/hall-ui/public/watch.css +184 -0
  28. package/modules/hall-ui/public/watch.html +111 -285
  29. package/modules/hall-ui/public/watch.js +59 -21
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/public-landing/create.html +83 -7
  33. package/public-landing/index.html +11 -76
  34. package/scripts/cron/style_guide_audit.sh +0 -0
  35. package/scripts/gds/api.js +0 -0
  36. package/scripts/gds/architect-audit.js +0 -0
  37. package/scripts/gds/audit-authorship.sh +0 -0
  38. package/scripts/gds/audit-replay.sh +0 -0
  39. package/scripts/gds/box.js +0 -0
  40. package/scripts/gds/capture.js +0 -0
  41. package/scripts/gds/conductor-dispatch.js +0 -0
  42. package/scripts/gds/coreB-carve-proof.sh +0 -0
  43. package/scripts/gds/db-backup-nightly.sh +0 -0
  44. package/scripts/gds/db-backup-offsite.sh +0 -0
  45. package/scripts/gds/doctor.js +0 -0
  46. package/scripts/gds/grade-smoke.js +0 -0
  47. package/scripts/gds/hooks/pre-push.js +0 -0
  48. package/scripts/gds/install-git-hooks.js +0 -0
  49. package/scripts/gds/main-audit.js +0 -0
  50. package/scripts/gds/oauth-secret.js +0 -0
  51. package/scripts/gds/onboarding-analyze.js +0 -0
  52. package/scripts/gds/onboarding-ship.js +0 -0
  53. package/scripts/gds/paste-token.js +0 -0
  54. package/scripts/gds/portability-smoke-art.sh +0 -0
  55. package/scripts/gds/portability-smoke-memory.sh +0 -0
  56. package/scripts/gds/provision.js +125 -4
  57. package/scripts/gds/push-main.js +0 -0
  58. package/scripts/gds/render-ideas.js +0 -0
  59. package/scripts/gds/security-baseline-check.sh +0 -0
  60. package/scripts/gds/session.js +0 -0
  61. package/scripts/gds/ship.js +11 -1
  62. package/scripts/gds/smoke-batch-claim.sh +0 -0
  63. package/scripts/gds/smoke-batch-release.sh +0 -0
  64. package/scripts/gds/smoke-box.sh +0 -0
  65. package/scripts/gds/smoke-claim-consistency.sh +0 -0
  66. package/scripts/gds/smoke-dependencies.sh +0 -0
  67. package/scripts/gds/smoke-write.sh +0 -0
  68. package/scripts/hall-preview/fixtures/access-requests.json +20 -1
  69. package/scripts/hall-preview/fixtures/audit-log.json +68 -1
  70. package/scripts/hall-preview/fixtures/blockers__detail.json +12 -0
  71. package/scripts/hall-preview/fixtures/box__me.json +5 -0
  72. package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +69 -1
  73. package/scripts/hall-preview/fixtures/builders__roster.json +48 -1
  74. package/scripts/hall-preview/fixtures/grades__by-builder.json +32 -1
  75. package/scripts/hall-preview/fixtures/inbox__detail.json +15 -0
  76. package/scripts/hall-preview/fixtures/me__art-key__own.json +6 -0
  77. package/scripts/hall-preview/fixtures/me__event-sound-prefs.json +209 -0
  78. package/scripts/hall-preview/fixtures/me__render.json +5 -0
  79. package/scripts/hall-preview/fixtures/me__skill-prefs.json +143 -0
  80. package/scripts/hall-preview/fixtures/me__sound-prefs.json +67 -0
  81. package/scripts/hall-preview/fixtures/me__wandering.json +43 -0
  82. package/scripts/hall-preview/fixtures/override-requests.json +15 -1
  83. package/scripts/hall-preview/fixtures/security__docs.json +10 -1
  84. package/scripts/hall-preview/fixtures/security__reports.json +31 -1
  85. package/scripts/hall-preview/fixtures/sessions__search.json +9 -1
  86. package/scripts/hall-preview/server.js +18 -1
  87. package/scripts/migrate.sh +0 -0
  88. package/scripts/migrate_cost_ledger.sh +0 -0
  89. package/scripts/render-diagrams.sh +0 -0
  90. package/scripts/security/dep-audit.js +0 -0
  91. package/scripts/setup-builder.js +0 -0
  92. package/scripts/setup-builder.sh +0 -0
  93. package/scripts/snapshots/maybe-render-on-deploy.sh +0 -0
  94. package/src/module-api.js +20 -1
  95. package/tests/provision.mjs +69 -0
@@ -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,7 +234,7 @@
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">
@@ -292,7 +250,6 @@
292
250
  </div>
293
251
  </a>`;
294
252
  }).join('');
295
- renderGoalsPager(total, state.listPager.page, totalPages, start, pageRows.length);
296
253
  }
297
254
 
298
255
  // ---- drag-to-reorder (task 1760, Archon-only, one version at a time) ------
@@ -332,7 +289,7 @@
332
289
  // of the goals on other pages. Priority numbers reflect the global
333
290
  // position (start + i + 1).
334
291
  const filtered = filteredGoals();
335
- const start = (state.listPager.page - 1) * state.listPager.perPage;
292
+ const start = (pagers.list.page - 1) * pagers.list.perPage;
336
293
  const cards = [...grid.querySelectorAll('.goal-card')];
337
294
  cards.forEach((card, i) => {
338
295
  const num = card.querySelector('.goal-card__priority');
@@ -436,53 +393,12 @@
436
393
  el.textContent = String(n);
437
394
  }
438
395
 
439
- // task 1956 — a Work table's pagination bar (range, Per-page <select>, Prev/Next
440
- // around "Page X of Y"), keyed by DOM prefix (goal-todo / goal-done). Hidden
441
- // when the whole table fits one minimum page.
442
- function renderWorkPager(prefix, pager, total, totalPages, start, shownCount) {
443
- const bar = $(`#${prefix}-pager`);
444
- if (!bar) return;
445
- if (total <= LIST_MIN_PER_PAGE) { bar.hidden = true; return; }
446
- bar.hidden = false;
447
- const range = $(`#${prefix}-range`);
448
- if (range) range.textContent = total
449
- ? `Showing ${start + 1}–${start + shownCount} of ${total}`
450
- : 'Showing 0 of 0';
451
- const ind = $(`#${prefix}-page-ind`);
452
- if (ind) ind.textContent = `Page ${pager.page} of ${totalPages}`;
453
- const prev = $(`#${prefix}-prev`);
454
- if (prev) prev.disabled = pager.page <= 1;
455
- const next = $(`#${prefix}-next`);
456
- if (next) next.disabled = pager.page >= totalPages;
457
- const sel = $(`#${prefix}-per-page`);
458
- if (sel && sel.value !== String(pager.perPage)) sel.value = String(pager.perPage);
459
- }
460
-
461
- // Slice `rows` to a Work table's current page (clamped), paint its bar, return
462
- // the slice to render.
463
- function paginateWork(rows, pager, prefix) {
464
- const total = rows.length;
465
- const totalPages = Math.max(1, Math.ceil(total / pager.perPage));
466
- if (pager.page > totalPages) pager.page = totalPages;
467
- if (pager.page < 1) pager.page = 1;
468
- const start = (pager.page - 1) * pager.perPage;
469
- const slice = rows.slice(start, start + pager.perPage);
470
- renderWorkPager(prefix, pager, total, totalPages, start, slice.length);
471
- return slice;
472
- }
473
-
474
- // task 1888 — the Work card's To do / Completed tab toggle. Both tables stay
475
- // in the DOM (already rendered); switching only flips pane visibility.
476
- function setWorkTab(tab) {
477
- state.workTab = tab;
478
- const isTodo = tab === 'todo';
479
- $('#goal-tab-todo')?.setAttribute('aria-selected', String(isTodo));
480
- $('#goal-tab-done')?.setAttribute('aria-selected', String(!isTodo));
481
- const todoPane = $('#goal-todo-pane');
482
- const donePane = $('#goal-done-pane');
483
- if (todoPane) todoPane.hidden = !isTodo;
484
- if (donePane) donePane.hidden = isTodo;
485
- }
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); }
486
402
 
487
403
  // task 1828 — sortable column key → header id, per table. Adding a column
488
404
  // means an entry here + a matching <th id> in goals.html.
@@ -547,21 +463,21 @@
547
463
  const doneBody = document.querySelector('#goal-done tbody');
548
464
  const todo = sortWorkRows(state.work.todo, state.workSort.todo);
549
465
  const done = sortWorkRows(state.work.done, state.workSort.done);
550
- // task 1956 — paginate each table (was ROW_CAP + a show-all toggle).
551
- const todoSlice = paginateWork(todo, state.workPager.todo, 'goal-todo');
552
- 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);
553
469
  if (todoBody) {
554
470
  todoBody.innerHTML = todo.length
555
471
  ? taskRowsTodo(todoSlice)
556
- : `<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>`;
557
473
  }
558
474
  if (doneBody) {
559
475
  doneBody.innerHTML = done.length
560
476
  ? taskRowsDone(doneSlice)
561
- : '<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>`;
562
478
  }
563
- setCount('#goal-todo-count', state.work.todo.length);
564
- setCount('#goal-done-count', state.work.done.length);
479
+ workTabs?.setCount('todo', state.work.todo.length);
480
+ workTabs?.setCount('done', state.work.done.length);
565
481
  reflectSortHeaders('todo');
566
482
  reflectSortHeaders('done');
567
483
  }
@@ -649,7 +565,7 @@
649
565
  function fillConflicts(container, pairs) {
650
566
  if (!container) return;
651
567
  if (pairs == null) {
652
- container.innerHTML = '<div class="profile__placeholder">Conflicts could not be computed.</div>';
568
+ container.innerHTML = emptyStateHtml('Conflicts could not be computed.');
653
569
  return;
654
570
  }
655
571
  const note = state.conflictSource === 'client'
@@ -657,7 +573,7 @@
657
573
  : '';
658
574
  container.innerHTML = (pairs.length
659
575
  ? pairs.map(conflictHtml).join('')
660
- : '<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;
661
577
  }
662
578
 
663
579
  const currentDetail = () => (state.current == null ? null : state.details.get(String(state.current)));
@@ -720,7 +636,7 @@
720
636
  // (withDetail=false) instead reveals detail via a per-criterion <details> expand
721
637
  // (BV1.R79) so the full text is one click away, not hover-only.
722
638
  function critListHtml(list, withDetail) {
723
- 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.');
724
640
  return `<ul class="crit-list${withDetail ? ' crit-list--full' : ''}">${list.map((c) => {
725
641
  const parts = window.OTB.splitCriterion ? window.OTB.splitCriterion(c.criterion_md) : { label: c.criterion_md, detail: '' };
726
642
  const body = withDetail
@@ -809,7 +725,7 @@
809
725
  if (list) {
810
726
  list.innerHTML = members.length
811
727
  ? members.slice(0, MEMBER_CAP).map(memberLi).join('')
812
- : '<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>`;
813
729
  }
814
730
  renderViewAllFoot('#goal-members-foot', members.length, MEMBER_CAP, 'members', 'members');
815
731
  renderMembersFull(d);
@@ -834,14 +750,14 @@
834
750
  <span class="mstat"><strong>${fmtNum(earned)}</strong> ${escapeHtml(cur)} earned</span>
835
751
  <span class="mstat"><strong>${fmtNum(open)}</strong> open</span>`;
836
752
  } else {
837
- 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>';
838
754
  }
839
755
  }
840
756
  const full = $('#goal-members-full');
841
757
  if (full) {
842
758
  full.innerHTML = members.length
843
759
  ? `<ul class="member-list">${members.map((m) => memberStatLi(m, stats)).join('')}</ul>`
844
- : '<div class="profile__placeholder">No members yet — be the first to join.</div>';
760
+ : emptyStateHtml('No members yet — be the first to join.');
845
761
  }
846
762
  const back = $('#goal-members-back');
847
763
  if (back && state.current != null) back.setAttribute('href', `#/goal/${state.current}`);
@@ -1043,7 +959,7 @@
1043
959
  }
1044
960
  box.hidden = false;
1045
961
  if (!state.inviteOpen) {
1046
- 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>';
1047
963
  $('#goal-invite-open')?.addEventListener('click', async () => {
1048
964
  state.inviteOpen = true;
1049
965
  await loadDirectory();
@@ -1208,8 +1124,8 @@
1208
1124
  <span class="pending-row__note">invited — awaiting their response</span>
1209
1125
  </li>`;
1210
1126
  const parts = [];
1211
- 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>`);
1212
- 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>`);
1213
1129
  return parts.join('');
1214
1130
  }
1215
1131
  function renderPendingRequests() {
@@ -1268,10 +1184,9 @@
1268
1184
  // task 1888 — a freshly opened goal starts from the compact defaults: To do
1269
1185
  // tab selected, capped lists, counts hidden until its own data lands.
1270
1186
  function resetWorkUi() {
1271
- state.workTab = 'todo';
1272
1187
  // task 1956 — a freshly opened goal starts both Work tables on page 1 (per-page persists).
1273
- state.workPager.todo.page = 1;
1274
- state.workPager.done.page = 1;
1188
+ pagers.todo.page = 1;
1189
+ pagers.done.page = 1;
1275
1190
  state.workSort = { todo: { by: null, dir: 'desc' }, done: { by: null, dir: 'desc' } };
1276
1191
  state.work = { todo: [], done: [] };
1277
1192
  state.workLoaded = false;
@@ -1285,7 +1200,9 @@
1285
1200
  const pending = $('#goal-pending');
1286
1201
  if (pending) { pending.hidden = true; pending.innerHTML = ''; }
1287
1202
  setWorkTab('todo');
1288
- ['#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',
1289
1206
  '#goal-members-count', '#goal-members-view-count', '#goal-completion-count'].forEach((sel) => {
1290
1207
  const el = $(sel);
1291
1208
  if (el) { el.hidden = true; el.textContent = ''; }
@@ -1297,14 +1214,15 @@
1297
1214
  ['#goal-conflicts-card', '#goal-conflicts-view-card'].forEach((sel) => $(sel)?.classList.remove('goal-conflicts-card--live'));
1298
1215
  reflectSortHeaders('todo');
1299
1216
  reflectSortHeaders('done');
1217
+ // Kit shimmer placeholders while the goal's tasks load (was "…" cells).
1300
1218
  const todoBody = document.querySelector('#goal-todo tbody');
1301
1219
  const doneBody = document.querySelector('#goal-done tbody');
1302
- if (todoBody) todoBody.innerHTML = '<tr><td colspan="6" class="ledger__empty">…</td></tr>';
1303
- 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);
1304
1222
  const conflicts = $('#goal-conflicts');
1305
- if (conflicts) conflicts.innerHTML = '<div class="profile__placeholder">…</div>';
1223
+ if (conflicts) conflicts.innerHTML = '<span class="kit-skel"></span>';
1306
1224
  const conflictsFull = $('#goal-conflicts-full');
1307
- if (conflictsFull) conflictsFull.innerHTML = '<div class="profile__placeholder">…</div>';
1225
+ if (conflictsFull) conflictsFull.innerHTML = '<span class="kit-skel"></span>';
1308
1226
  }
1309
1227
 
1310
1228
  // task 1828 — views sharing this page: the list, a goal's detail, and the
@@ -1397,6 +1315,12 @@
1397
1315
  }
1398
1316
 
1399
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
+ }
1400
1324
  try {
1401
1325
  state.me = await getJSON(`${API}/me`).catch(() => null);
1402
1326
  // task 1828 — "My goals" needs an identity; disable it when signed out.
@@ -1415,30 +1339,37 @@
1415
1339
  const sub = $('#goals-sub');
1416
1340
  if (sub) sub.textContent = 'Goals could not be loaded. Refresh to try again.';
1417
1341
  const grid = $('#goal-grid');
1418
- 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.');
1419
1343
  }
1420
1344
  }
1421
1345
 
1422
1346
  // task 1932 — any list filter change re-paginates the grid from page 1.
1423
- const onListFilterChange = () => { state.listPager.page = 1; renderList(); };
1424
- $('#f-goal-version')?.addEventListener('change', onListFilterChange);
1425
- $('#f-goal-status')?.addEventListener('change', onListFilterChange);
1426
- $('#f-goal-mine')?.addEventListener('change', onListFilterChange); // task 1828 — My goals filter
1427
- // task 1932 — the goal grid's pagination controls (per-page dropdown + prev/next).
1428
- $('#goals-per-page')?.addEventListener('change', (e) => {
1429
- const v = Number(e.target.value);
1430
- if (!LIST_PER_PAGE_OPTIONS.includes(v)) return;
1431
- state.listPager.perPage = v;
1432
- state.listPager.page = 1;
1433
- try { window.localStorage.setItem(GOALS_PER_PAGE_KEY, String(v)); } catch (_) { /* private mode → in-memory only */ }
1434
- renderList();
1435
- });
1436
- $('#goals-prev')?.addEventListener('click', () => {
1437
- if (state.listPager.page > 1) { state.listPager.page -= 1; renderList(); }
1438
- });
1439
- $('#goals-next')?.addEventListener('click', () => {
1440
- 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,
1441
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
+ }
1442
1373
  $('#goal-back')?.addEventListener('click', (e) => { e.preventDefault(); window.location.hash = ''; });
1443
1374
  // task 1828 — "← Back to goal" from any of the full views.
1444
1375
  ['#goal-conflicts-back', '#goal-members-back', '#goal-completion-back'].forEach((sel) => {
@@ -1447,25 +1378,16 @@
1447
1378
  if (state.current != null) window.location.hash = `#/goal/${state.current}`;
1448
1379
  });
1449
1380
  });
1450
- // task 1888 — the Work card's tab toggle.
1451
- $('#goal-tab-todo')?.addEventListener('click', () => setWorkTab('todo'));
1452
- $('#goal-tab-done')?.addEventListener('click', () => setWorkTab('done'));
1453
- // task 1956 — pagination controls for each Work table (per-page dropdown + prev/next).
1454
- [['goal-todo', 'todo'], ['goal-done', 'done']].forEach(([prefix, key]) => {
1455
- $(`#${prefix}-per-page`)?.addEventListener('change', (e) => {
1456
- const v = Number(e.target.value);
1457
- if (!LIST_PER_PAGE_OPTIONS.includes(v)) return;
1458
- state.workPager[key].perPage = v;
1459
- state.workPager[key].page = 1;
1460
- try { window.localStorage.setItem(WORK_PER_PAGE_KEY[key], String(v)); } catch (_) { /* private mode → in-memory only */ }
1461
- renderWork();
1462
- });
1463
- $(`#${prefix}-prev`)?.addEventListener('click', () => {
1464
- if (state.workPager[key].page > 1) { state.workPager[key].page -= 1; renderWork(); }
1465
- });
1466
- $(`#${prefix}-next`)?.addEventListener('click', () => {
1467
- state.workPager[key].page += 1; renderWork(); // renderWork clamps to the last page
1468
- });
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
+ ],
1469
1391
  });
1470
1392
  // task 1828 — click / keyboard sort on the Work-table column headers.
1471
1393
  Object.entries(WORK_SORT_HEADERS).forEach(([table, cols]) => {