@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.
- package/.bongos-core.json +292 -32
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -2
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +65 -26
- package/docs/api-reference.md +3 -2
- package/modules/hall-ui/public/builders.js +96 -646
- package/modules/hall-ui/public/goal-inbox.js +67 -26
- package/modules/hall-ui/public/goals-page.js +4 -1
- package/modules/hall-ui/public/goals.html +8 -0
- package/modules/hall-ui/public/hall-kit.js +474 -0
- package/modules/hall-ui/public/hall-widgets.js +11 -15
- package/modules/hall-ui/public/instances-panel.js +24 -3
- package/modules/hall-ui/public/new-project-wizard.js +6 -5
- package/modules/hall-ui/public/style.css +125 -0
- package/modules/hall-ui/public/work.css +194 -0
- package/modules/hall-ui/public/work.html +46 -374
- package/modules/hall-ui/public/work.js +137 -102
- package/modules/lifecycle/criterion-suggest.js +80 -0
- package/modules/lifecycle/db.js +42 -5
- package/modules/lifecycle/routes/goals.js +26 -0
- package/modules/lifecycle/routes/tasks.js +21 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/README.md +103 -0
- package/scripts/hall-preview/fixtures/access-requests.json +1 -0
- package/scripts/hall-preview/fixtures/achievements.json +76 -0
- package/scripts/hall-preview/fixtures/analytics__builder.json +251 -0
- package/scripts/hall-preview/fixtures/audit-log.json +1 -0
- package/scripts/hall-preview/fixtures/boxes__cost-ledger.json +1 -0
- package/scripts/hall-preview/fixtures/builders__active-claims.json +1 -0
- package/scripts/hall-preview/fixtures/builders__directory.json +81 -0
- package/scripts/hall-preview/fixtures/builders__roster.json +1 -0
- package/scripts/hall-preview/fixtures/goals.json +502 -0
- package/scripts/hall-preview/fixtures/goals__25.json +96 -0
- package/scripts/hall-preview/fixtures/goals__26.json +67 -0
- package/scripts/hall-preview/fixtures/goals__3.json +103 -0
- package/scripts/hall-preview/fixtures/goals__35.json +49 -0
- package/scripts/hall-preview/fixtures/goals__conflicts.json +154 -0
- package/scripts/hall-preview/fixtures/goals__detail.json +96 -0
- package/scripts/hall-preview/fixtures/goals__inbox.json +5 -0
- package/scripts/hall-preview/fixtures/goals__requests.json +3 -0
- package/scripts/hall-preview/fixtures/grades__by-builder.json +1 -0
- package/scripts/hall-preview/fixtures/inbox.json +330 -0
- package/scripts/hall-preview/fixtures/instance.json +124 -0
- package/scripts/hall-preview/fixtures/leaderboard.json +184 -0
- package/scripts/hall-preview/fixtures/me.json +476 -0
- package/scripts/hall-preview/fixtures/me__recent-ships.json +304 -0
- package/scripts/hall-preview/fixtures/override-requests.json +1 -0
- package/scripts/hall-preview/fixtures/provisioning__fleet.json +1 -0
- package/scripts/hall-preview/fixtures/provisioning__fleet__cost-ledger.json +1 -0
- package/scripts/hall-preview/fixtures/provisioning__instances.json +1 -0
- package/scripts/hall-preview/fixtures/public__analytics__ecosystem.json +516 -0
- package/scripts/hall-preview/fixtures/public__bounty-table.json +37 -0
- package/scripts/hall-preview/fixtures/public__cost-summary.json +742 -0
- package/scripts/hall-preview/fixtures/public__estimation-drift.json +41 -0
- package/scripts/hall-preview/fixtures/public__grades.json +50 -0
- package/scripts/hall-preview/fixtures/public__progress.json +124 -0
- package/scripts/hall-preview/fixtures/public__recent-shipped.json +426 -0
- package/scripts/hall-preview/fixtures/public__repo-health.json +160 -0
- package/scripts/hall-preview/fixtures/public__repo-info.json +5 -0
- package/scripts/hall-preview/fixtures/security__docs.json +1 -0
- package/scripts/hall-preview/fixtures/security__reports.json +1 -0
- package/scripts/hall-preview/fixtures/sessions__search.json +1 -0
- package/scripts/hall-preview/fixtures/tasks.json +2420 -0
- package/scripts/hall-preview/fixtures/tasks__claimable.json +2555 -0
- package/scripts/hall-preview/fixtures/tasks__detail.json +111 -0
- package/scripts/hall-preview/fixtures/tasks__peer-votes__feed.json +330 -0
- package/scripts/hall-preview/fixtures/versions.json +89 -0
- package/scripts/hall-preview/refresh-fixtures.js +77 -0
- package/scripts/hall-preview/server.js +178 -0
- package/scripts/migrate.sh +29 -4
- package/src/bongos/optimizer.js +37 -2
- package/tests/criterion_suggest.mjs +106 -0
- package/tests/migrate_root_resolution.mjs +97 -0
|
@@ -1,6 +1,13 @@
|
|
|
1
|
-
// goal-inbox.js — the
|
|
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
|
-
//
|
|
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
|
-
|
|
91
|
-
|
|
102
|
+
box.innerHTML = '';
|
|
103
|
+
window.OTBAttention.refresh();
|
|
92
104
|
return;
|
|
93
105
|
}
|
|
94
|
-
|
|
95
|
-
let
|
|
96
|
-
|
|
97
|
-
|
|
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 (
|
|
100
|
-
html += '<h3 class="ginbox__sec-h">
|
|
101
|
-
html += `<ul class="ginbox">${
|
|
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 (
|
|
104
|
-
html +=
|
|
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
|
-
|
|
109
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
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
|
-
//
|
|
185
|
-
//
|
|
186
|
-
//
|
|
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: '
|
|
189
|
-
labelledby: '
|
|
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
|
-
|
|
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
|
+
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
|
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
|
+
})();
|