@bongos/core 1.19.457 → 1.19.458

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.
@@ -19,6 +19,13 @@
19
19
  // "unavailable" message rather than blanking the whole page. Each section
20
20
  // loads independently in its own try/catch so one failing endpoint cannot
21
21
  // break the others.
22
+ //
23
+ // TASK 1003469 put the page in the chrome world's composition: every list is
24
+ // the kit's .ledger through makeLedger (eight of nine were a page-local table
25
+ // that duplicated it with a hand-rolled th voice), every figure row is the oversight
26
+ // family's .ov-figs, every sub-section is an .ov-group head, the access requests
27
+ // are oversight rows, and the five most recent sessions the page always fetched
28
+ // and threw away are now rendered under the tally.
22
29
 
23
30
  (() => {
24
31
  'use strict';
@@ -35,7 +42,7 @@
35
42
  // empty; the 3200ms non-clearing toast). fmtDay / fmtDuration / fmtPct stay local.
36
43
  const { escapeHtml, $, num, fmtInt, fmtCompact } = window.OTB;
37
44
  // Shared component kit (hall-kit.js, task 2166): tabs, ledger, pager, empty
38
- // states — adopted here by task 2173.
45
+ // states — adopted here by task 2173, and by every list since task 1003469.
39
46
  const { makeTabs, makeLedger, makePager, emptyStateHtml } = window.OTBKit;
40
47
  const fmtDate = (iso) => window.OTB.fmtDate(iso, { time: true });
41
48
  const rankLabel = (rank) => window.OTB.rankLabel(rank, { empty: '' });
@@ -58,6 +65,17 @@
58
65
  return `${Math.round(n * 100)}%`;
59
66
  }
60
67
 
68
+ // seconds → "1h 31m" / "12m" / "45s". "—" for empty/bad input.
69
+ function fmtDuration(seconds) {
70
+ const s = Number(seconds);
71
+ if (!Number.isFinite(s) || s < 0) return '—';
72
+ const h = Math.floor(s / 3600);
73
+ const m = Math.floor((s % 3600) / 60);
74
+ if (h > 0) return `${h}h ${m}m`;
75
+ if (m > 0) return `${m}m`;
76
+ return `${Math.round(s)}s`;
77
+ }
78
+
61
79
  // ---- fetch helper ---------------------------------------------------------
62
80
 
63
81
  // Fetch JSON. Throws { kind: '401'|'403' } on those statuses so each section
@@ -157,28 +175,55 @@
157
175
  });
158
176
  }
159
177
 
160
- // renderTable — the `<div class="watch-table-wrap"><table class="watch">` shell the
161
- // panels build by hand. `headers` are <th> inner-HTML strings (static literals —
162
- // not escaped here); `rowsHtml` is the pre-built <tbody> content. Returns ONLY the
163
- // wrapper+table; trailing content (notes, lifecycle lines, sub-headed sibling
164
- // tables, mounted <div>s) stays at the call site — it is not uniform across panels.
165
- function renderTable(headers, rowsHtml) {
166
- return (
167
- '<div class="watch-table-wrap"><table class="watch"><thead><tr>' +
168
- headers.map((h) => `<th>${h}</th>`).join('') +
169
- '</tr></thead>' +
170
- `<tbody>${rowsHtml}</tbody></table></div>`
171
- );
178
+ // delegate — the same dance as ONE listener on the section, for a list that
179
+ // pages: a row the kit re-renders on a page flip has no listener of its own,
180
+ // so the section listens once (per selector, guarded) and resolves the button
181
+ // from the click. `event` is 'click' unless a control says otherwise.
182
+ function delegate(root, selector, handler, errPrefix, event = 'click') {
183
+ const key = `wired:${event}:${selector}`;
184
+ if (root[key]) return;
185
+ root[key] = true;
186
+ root.addEventListener(event, async (e) => {
187
+ const el = e.target.closest(selector);
188
+ if (!el || !root.contains(el)) return;
189
+ el.disabled = true;
190
+ try {
191
+ await handler(el);
192
+ } catch (err) {
193
+ if (err && err.kind === '401') return;
194
+ el.disabled = false;
195
+ toast(`${errPrefix}: ${err.message || err}`);
196
+ }
197
+ });
198
+ }
199
+
200
+ // ledger — the kit's makeLedger over a fresh mount appended to `target`, so a
201
+ // section can compose heads and foot lines around the table and still page
202
+ // through the kit. Every list on this page is a kit ledger since task 1003469;
203
+ // the ones that can grow carry a pager, whose bar hides itself when the list
204
+ // fits one page.
205
+ function ledger(target, { columns, rowHtml, pager = null, emptyMsg = 'Nothing here yet.' }) {
206
+ const mount = document.createElement('div');
207
+ target.appendChild(mount);
208
+ return makeLedger({ mount, columns, rowHtml, pager, emptyMsg });
172
209
  }
173
210
 
174
- // Stat cards from [{ num, label }].
175
- function statCards(cards) {
211
+ // A group head inside a panel (oversight.css .ov-group): a run of rows under a
212
+ // name in the label voice. Returns the section; append the ledger to it.
213
+ function group(target, name, n) {
214
+ const sec = document.createElement('section');
215
+ sec.className = 'ov-group';
216
+ sec.innerHTML = `<div class="ov-group__head"><span class="ov-group__name">${escapeHtml(name)}</span>${n === undefined ? '' : `<span class="ov-group__n">${fmtInt(n)}</span>`}</div>`;
217
+ target.appendChild(sec);
218
+ return sec;
219
+ }
220
+
221
+ const foot = (html) => `<p class="ov-foot">${html}</p>`;
222
+
223
+ // The figure band from [{ num, label }] — the oversight family's .ov-figs.
224
+ function figs(cards) {
176
225
  return cards
177
- .map(
178
- (c) =>
179
- `<div class="watch-stat"><div class="watch-stat__num">${escapeHtml(c.num)}</div>` +
180
- `<div class="watch-stat__label">${escapeHtml(c.label)}</div></div>`
181
- )
226
+ .map((c) => `<div class="ov-fig"><span class="ov-fig__l">${escapeHtml(c.label)}</span><span class="ov-fig__v">${escapeHtml(c.num)}</span></div>`)
182
227
  .join('');
183
228
  }
184
229
 
@@ -192,6 +237,9 @@
192
237
  return `<span class="badge ${cls}">${escapeHtml(sev || '—')}</span>`;
193
238
  }
194
239
 
240
+ const ghost = (attrs, label, danger = false) =>
241
+ `<button type="button" class="btn-ghost${danger ? ' btn-ghost--danger' : ''}" ${attrs}>${label}</button>`;
242
+
195
243
  // =========================================================================
196
244
  // 1. GATE — access requests (pending)
197
245
  // =========================================================================
@@ -210,13 +258,13 @@
210
258
  ? 'This project is open — anyone who signs in is admitted, so an invite is rarely needed.'
211
259
  : 'Admit someone before they ask — once invited, their sign-in gets in.';
212
260
  return (
213
- '<form class="watch-invite" id="gate-invite" novalidate>' +
214
- '<label class="watch-invite__label" for="gate-invite-login">Invite by GitHub username</label>' +
215
- '<div class="watch-invite__row">' +
261
+ '<form class="ov-form" id="gate-invite" novalidate>' +
262
+ '<div class="ov-field">' +
263
+ '<label class="ov-field__l" for="gate-invite-login">Invite by GitHub username</label>' +
216
264
  '<input type="text" id="gate-invite-login" name="github_login" autocomplete="off" placeholder="e.g. octocat" maxlength="40">' +
217
- '<button type="submit" class="watch-btn">Invite</button>' +
218
265
  '</div>' +
219
- `<p class="watch-invite__why">${escapeHtml(why)}</p>` +
266
+ '<button type="submit" class="btn-accent">Invite</button>' +
267
+ `<p class="ov-form__why">${escapeHtml(why)}</p>` +
220
268
  '</form>'
221
269
  );
222
270
  }
@@ -243,6 +291,8 @@
243
291
  });
244
292
  }
245
293
 
294
+ // One request, one oversight row: who, their note as the sub line, when they
295
+ // asked as the fact, and the two decisions on the right.
246
296
  function renderGate(data, target) {
247
297
  const requests = Array.isArray(data.requests) ? data.requests : [];
248
298
  setAttention('att-access', requests.length); // tile count = table content
@@ -251,29 +301,27 @@
251
301
  wireInviteForm(target);
252
302
  return;
253
303
  }
254
- target.innerHTML = inviteFormHtml() + requests
304
+ target.innerHTML = inviteFormHtml() + '<ul class="ov-rows">' + requests
255
305
  .map((r) => {
256
306
  const who = r.display_name
257
- ? `${escapeHtml(r.display_name)} <span class="watch-mono">@${escapeHtml(r.github_login)}</span>`
258
- : `<span class="watch-mono">@${escapeHtml(r.github_login)}</span>`;
259
- const note = r.note
260
- ? `<div class="watch-req__note">“${escapeHtml(r.note)}”</div>`
261
- : '';
307
+ ? `${escapeHtml(r.display_name)}<span class="ov-mono">@${escapeHtml(r.github_login)}</span>`
308
+ : `<span class="ov-mono">@${escapeHtml(r.github_login)}</span>`;
309
+ const note = r.note ? `<p class="ov-row__sub">“${escapeHtml(r.note)}”</p>` : '';
262
310
  return (
263
- `<div class="watch-req" data-req-id="${escapeHtml(r.id)}">` +
264
- '<div>' +
265
- `<div class="watch-req__who">${who}</div>` +
266
- `<div class="watch-req__meta">Asked ${escapeHtml(fmtDate(r.created_at))}</div>` +
311
+ `<li class="ov-row" data-req-id="${escapeHtml(r.id)}">` +
312
+ '<div class="ov-row__main">' +
313
+ `<span class="ov-row__title">${who}</span>` +
267
314
  note +
315
+ `<div class="ov-row__facts"><span class="ov-row__when">Asked ${escapeHtml(fmtDate(r.created_at))}</span></div>` +
268
316
  '</div>' +
269
- '<div class="watch-actions">' +
270
- `<button type="button" class="watch-btn" data-gate-action="invited" data-id="${escapeHtml(r.id)}">Invite</button>` +
271
- `<button type="button" class="watch-btn watch-btn--danger" data-gate-action="dismissed" data-id="${escapeHtml(r.id)}">Dismiss</button>` +
272
- '</div>' +
273
- '</div>'
317
+ '<div class="ov-row__right"><div class="ov-row__actions">' +
318
+ ghost(`data-gate-action="invited" data-id="${escapeHtml(r.id)}"`, 'Invite') +
319
+ ghost(`data-gate-action="dismissed" data-id="${escapeHtml(r.id)}"`, 'Dismiss', true) +
320
+ '</div></div>' +
321
+ '</li>'
274
322
  );
275
323
  })
276
- .join('');
324
+ .join('') + '</ul>';
277
325
 
278
326
  wireActions(target, '[data-gate-action]', async (btn) => {
279
327
  const id = btn.getAttribute('data-id');
@@ -305,38 +353,33 @@
305
353
  const reports = Array.isArray(data.reports) ? data.reports : [];
306
354
  const statuses = Array.isArray(data.statuses) ? data.statuses : [];
307
355
  setAttention('att-security', reports.filter(OPEN_REPORT).length);
356
+ target.innerHTML = '';
308
357
  if (!reports.length) {
309
358
  target.innerHTML = emptyStateHtml('No vulnerability reports filed.');
310
359
  return;
311
360
  }
312
- const rows = reports
313
- .map((r) => {
314
- // The status-update actions are POSTs to dedicated verbs:
315
- // /security/reports/:id/confirm | /dispute | /fix
316
- const actions =
317
- '<div class="watch-actions">' +
318
- `<button type="button" class="watch-btn" data-rep-action="confirm" data-id="${escapeHtml(r.id)}">Confirm</button>` +
319
- `<button type="button" class="watch-btn" data-rep-action="dispute" data-id="${escapeHtml(r.id)}">Dispute</button>` +
320
- `<button type="button" class="watch-btn" data-rep-action="fix" data-id="${escapeHtml(r.id)}">Fixed</button>` +
321
- '</div>';
322
- return (
323
- '<tr>' +
324
- `<td>${sevBadge(r.severity)}</td>` +
325
- `<td>${escapeHtml(r.title)}</td>` +
326
- `<td><span class="watch-mono">${escapeHtml(r.target)}</span></td>` +
327
- `<td><span class="badge badge--status">${escapeHtml(r.status)}</span></td>` +
328
- `<td>${actions}</td>` +
329
- '</tr>'
330
- );
331
- })
332
- .join('');
333
- target.innerHTML =
334
- renderTable(['Severity', 'Title', 'Target', 'Status', 'Action'], rows) +
335
- (statuses.length
336
- ? `<p class="watch-note">Lifecycle: ${escapeHtml(statuses.join(' → '))}</p>`
337
- : '');
338
-
339
- wireActions(target, '[data-rep-action]', async (btn) => {
361
+ ledger(target, {
362
+ columns: ['Severity', 'Title', 'Target', 'Status', 'Action'],
363
+ pager: makePager('watch-reports', 'otb.watch.reportsPerPage'),
364
+ // The status-update actions are POSTs to dedicated verbs:
365
+ // /security/reports/:id/confirm | /dispute | /fix
366
+ rowHtml: (r) => (
367
+ '<tr>' +
368
+ `<td>${sevBadge(r.severity)}</td>` +
369
+ `<td>${escapeHtml(r.title)}</td>` +
370
+ `<td><span class="ov-mono">${escapeHtml(r.target)}</span></td>` +
371
+ `<td><span class="badge badge--status">${escapeHtml(r.status)}</span></td>` +
372
+ '<td><div class="watch-actions">' +
373
+ ghost(`data-rep-action="confirm" data-id="${escapeHtml(r.id)}"`, 'Confirm') +
374
+ ghost(`data-rep-action="dispute" data-id="${escapeHtml(r.id)}"`, 'Dispute') +
375
+ ghost(`data-rep-action="fix" data-id="${escapeHtml(r.id)}"`, 'Fixed') +
376
+ '</div></td>' +
377
+ '</tr>'
378
+ ),
379
+ }).render(reports);
380
+ if (statuses.length) target.insertAdjacentHTML('beforeend', foot(`Lifecycle: ${escapeHtml(statuses.join(' → '))}`));
381
+
382
+ delegate(target, '[data-rep-action]', async (btn) => {
340
383
  const id = btn.getAttribute('data-id');
341
384
  const action = btn.getAttribute('data-rep-action');
342
385
  await postJson(`/security/reports/${encodeURIComponent(id)}/${action}`, {});
@@ -363,41 +406,45 @@
363
406
  // reports, so we never inject untrusted HTML.
364
407
  function renderEngagementDocs(data, target) {
365
408
  const docs = Array.isArray(data.docs) ? data.docs : [];
409
+ target.innerHTML = '';
366
410
  if (!docs.length) {
367
411
  target.innerHTML = emptyStateHtml('No engagement reports filed. Upload one via POST /api/bongos/security/docs.');
368
412
  return;
369
413
  }
370
- const rows = docs
371
- .map((d) => {
414
+ ledger(target, {
415
+ columns: ['Title', 'Slug', 'Updated', { label: 'Size', class: 'num' }, ''],
416
+ pager: makePager('watch-docs', 'otb.watch.docsPerPage'),
417
+ rowHtml: (d) => {
372
418
  const kb = d.bytes != null ? `${Math.max(1, Math.round(Number(d.bytes) / 1024))} KB` : '—';
373
419
  return (
374
420
  '<tr>' +
375
421
  `<td>${escapeHtml(d.title)}</td>` +
376
- `<td><span class="watch-mono">${escapeHtml(d.slug)}</span></td>` +
422
+ `<td><span class="ov-mono">${escapeHtml(d.slug)}</span></td>` +
377
423
  `<td>${escapeHtml(fmtDate(d.updated_at))}</td>` +
378
- `<td>${escapeHtml(kb)}</td>` +
379
- `<td><div class="watch-actions"><button type="button" class="watch-btn" data-doc-open="${escapeHtml(d.slug)}">Read</button></div></td>` +
424
+ `<td class="num">${escapeHtml(kb)}</td>` +
425
+ `<td><div class="watch-actions">${ghost(`data-doc-open="${escapeHtml(d.slug)}"`, 'Read')}</div></td>` +
380
426
  '</tr>'
381
427
  );
382
- })
383
- .join('');
384
- target.innerHTML =
385
- renderTable(['Title', 'Slug', 'Updated', 'Size', ''], rows) +
386
- '<div id="engagement-doc-view"></div>';
428
+ },
429
+ }).render(docs);
430
+ target.insertAdjacentHTML('beforeend', '<div id="engagement-doc-view"></div>');
387
431
 
388
432
  // Bespoke handler: opening a doc renders its body into the mounted view (no
389
- // toast, no reload), so this is NOT a wireActions site.
390
- target.querySelectorAll('[data-doc-open]').forEach((btn) => {
391
- btn.addEventListener('click', async () => {
433
+ // toast, no reload), so this is NOT a delegate() site — the button stays live.
434
+ if (!target['wired:docs']) {
435
+ target['wired:docs'] = true;
436
+ target.addEventListener('click', async (e) => {
437
+ const btn = e.target.closest('[data-doc-open]');
438
+ if (!btn || !target.contains(btn)) return;
392
439
  const slug = btn.getAttribute('data-doc-open');
393
440
  const view = $('#engagement-doc-view');
394
- if (view) view.innerHTML = '<p class="watch-empty">Reading…</p>';
441
+ if (view) view.innerHTML = foot('Reading…');
395
442
  try {
396
443
  const res = await fetchJson(`/security/docs/${encodeURIComponent(slug)}`);
397
444
  const doc = res.doc || {};
398
445
  if (view) {
399
446
  view.innerHTML =
400
- `<h4 class="watch-sub-h">${escapeHtml(doc.title || slug)}</h4>` +
447
+ `<h4 class="watch-doc__h">${escapeHtml(doc.title || slug)}</h4>` +
401
448
  `<pre class="watch-doc">${escapeHtml(doc.content_md || '')}</pre>`;
402
449
  }
403
450
  } catch (err) {
@@ -405,7 +452,7 @@
405
452
  if (view) view.innerHTML = `<p class="watch-unavailable">Could not read the report: ${escapeHtml((err && err.message) || String(err))}</p>`;
406
453
  }
407
454
  });
408
- });
455
+ }
409
456
  }
410
457
 
411
458
  function loadEngagementDocs() {
@@ -419,33 +466,33 @@
419
466
  // 2b. Override requests (grader-bypass queue).
420
467
  function renderOverrides(data, target) {
421
468
  const requests = Array.isArray(data.requests) ? data.requests : [];
469
+ target.innerHTML = '';
422
470
  if (!requests.length) {
423
471
  target.innerHTML = emptyStateHtml('No overrides sought.');
424
472
  return;
425
473
  }
426
- const rows = requests
427
- .map((r) => {
474
+ ledger(target, {
475
+ columns: ['Task', 'Requester', 'Rationale', { label: 'Reward', class: 'num' }, 'Asked', 'Decide'],
476
+ pager: makePager('watch-overrides', 'otb.watch.overridesPerPage'),
477
+ rowHtml: (r) => {
428
478
  const who = r.requester_display_name || r.requester_login || ('#' + r.requested_by_builder_id);
429
- const actions =
430
- '<div class="watch-actions">' +
431
- `<button type="button" class="watch-btn" data-ovr-action="approve" data-id="${escapeHtml(r.id)}">Approve</button>` +
432
- `<button type="button" class="watch-btn watch-btn--danger" data-ovr-action="deny" data-id="${escapeHtml(r.id)}">Deny</button>` +
433
- '</div>';
434
479
  return (
435
480
  '<tr>' +
436
- `<td><span class="watch-mono">#${escapeHtml(r.task_id)}</span><br>${escapeHtml(r.task_title || '')}</td>` +
481
+ `<td><span class="ov-mono">#${escapeHtml(r.task_id)}</span><br>${escapeHtml(r.task_title || '')}</td>` +
437
482
  `<td>${escapeHtml(who)}</td>` +
438
483
  `<td>${escapeHtml(r.rationale || '—')}</td>` +
439
484
  `<td class="num">${fmtInt(r.credits_reward)}</td>` +
440
485
  `<td>${escapeHtml(fmtDate(r.created_at))}</td>` +
441
- `<td>${actions}</td>` +
486
+ '<td><div class="watch-actions">' +
487
+ ghost(`data-ovr-action="approve" data-id="${escapeHtml(r.id)}"`, 'Approve') +
488
+ ghost(`data-ovr-action="deny" data-id="${escapeHtml(r.id)}"`, 'Deny', true) +
489
+ '</div></td>' +
442
490
  '</tr>'
443
491
  );
444
- })
445
- .join('');
446
- target.innerHTML = renderTable(['Task', 'Requester', 'Rationale', 'Reward', 'Asked', 'Decide'], rows);
492
+ },
493
+ }).render(requests);
447
494
 
448
- wireActions(target, '[data-ovr-action]', async (btn) => {
495
+ delegate(target, '[data-ovr-action]', async (btn) => {
449
496
  const id = btn.getAttribute('data-id');
450
497
  const decision = btn.getAttribute('data-ovr-action');
451
498
  await postJson(`/override-requests/${encodeURIComponent(id)}/decide`, { decision, note: '' });
@@ -462,37 +509,35 @@
462
509
  });
463
510
  }
464
511
 
465
- // 2c. Audit log (recent 50) — a read-only long table, so it adopts the kit
466
- // ledger + pager (task 2173). Rebuilt per load; the pager pref persists.
512
+ // 2c. Audit log (recent 50) — a read-only long table on the kit ledger + pager
513
+ // (task 2173). Rebuilt per load; the pager pref persists.
467
514
  function renderAudit(data, target) {
468
515
  const rows = Array.isArray(data.rows) ? data.rows : [];
516
+ target.innerHTML = '';
469
517
  if (!rows.length) {
470
518
  target.innerHTML = emptyStateHtml('No audit entries yet.');
471
519
  return;
472
520
  }
473
- const pager = makePager('watch-audit', 'otb.watch.auditPerPage');
474
- const ledger = makeLedger({
475
- mount: target,
521
+ ledger(target, {
476
522
  columns: ['When', 'Builder', 'Method', 'Route', { label: 'Status', class: 'num' }],
523
+ pager: makePager('watch-audit', 'otb.watch.auditPerPage'),
477
524
  rowHtml: (r) => {
478
525
  const status = num(r.response_status);
479
526
  const warn = status >= 400 ? ' warn' : '';
480
527
  return (
481
528
  '<tr>' +
482
529
  `<td>${escapeHtml(fmtDate(r.recorded_at))}</td>` +
483
- `<td><span class="watch-mono">#${escapeHtml(r.builder_id ?? '—')}</span>` +
484
- (r.surface ? ` <span class="watch-rank">(${escapeHtml(r.surface)})</span>` : '') +
530
+ `<td><span class="ov-mono">#${escapeHtml(r.builder_id ?? '—')}</span>` +
531
+ (r.surface ? ` <span class="ov-mono">(${escapeHtml(r.surface)})</span>` : '') +
485
532
  '</td>' +
486
533
  `<td><span class="badge badge--status">${escapeHtml(r.method)}</span></td>` +
487
- `<td><span class="watch-mono">${escapeHtml(r.route)}</span></td>` +
534
+ `<td><span class="ov-mono">${escapeHtml(r.route)}</span></td>` +
488
535
  `<td class="num${warn}">${escapeHtml(r.response_status ?? '—')}</td>` +
489
536
  '</tr>'
490
537
  );
491
538
  },
492
- pager,
493
- });
494
- ledger.render(rows);
495
- target.insertAdjacentHTML('beforeend', '<p class="watch-note">Newest first. The fifty most recent write operations.</p>');
539
+ }).render(rows);
540
+ target.insertAdjacentHTML('beforeend', foot('Newest first. The fifty most recent write operations.'));
496
541
  }
497
542
 
498
543
  function loadAudit() {
@@ -513,97 +558,106 @@
513
558
  const RANK_OPTIONS = ['xenos', 'thetes', 'metic', 'archon'];
514
559
 
515
560
  function renderRoster(data, target) {
516
- const builders = Array.isArray(data.builders) ? data.builders : [];
517
- setAttention('att-stale', builders.filter((b) => b.stale_claim).length);
518
- if (!builders.length) {
519
- target.innerHTML = emptyStateHtml('No builders on the roster.');
520
- return;
521
- }
522
- const rows = builders
523
- .map((b) => {
524
- const opts = RANK_OPTIONS.slice();
525
- if (b.rank && !opts.includes(b.rank)) opts.unshift(b.rank);
526
- const rankSelect =
527
- `<select class="watch-select" data-roster-rank data-id="${escapeHtml(b.id)}" data-current="${escapeHtml(b.rank || '')}">` +
528
- opts
529
- .map(
530
- (rk) =>
531
- `<option value="${escapeHtml(rk)}"${rk === b.rank ? ' selected' : ''}>${escapeHtml(rankLabel(rk))}</option>`
532
- )
533
- .join('') +
534
- '</select>';
535
- const isInactive = String(b.status) === 'inactive';
536
- const statusBtn = isInactive
537
- ? `<button type="button" class="watch-btn" data-roster-status="active" data-id="${escapeHtml(b.id)}">Reactivate</button>`
538
- : `<button type="button" class="watch-btn watch-btn--danger" data-roster-status="inactive" data-id="${escapeHtml(b.id)}">Offboard</button>`;
539
- const claims = num(b.active_claim_count);
540
- const staleFlag = b.stale_claim ? ' <span class="badge badge--sev-medium">stale</span>' : '';
541
- // task 1467: the roster name links to the builder's public profile page.
542
- const rosterName = escapeHtml(b.display_name || b.github_login || ('#' + b.id));
543
- const rosterNameCell = b.id != null
544
- ? `<a class="lb-name" href="/profile?id=${encodeURIComponent(b.id)}">${rosterName}</a>`
545
- : rosterName;
546
- return (
547
- '<tr>' +
548
- `<td>${rosterNameCell}` +
549
- (b.github_login ? ` <span class="watch-rank">@${escapeHtml(b.github_login)}</span>` : '') +
550
- '</td>' +
551
- `<td>${rankSelect}</td>` +
552
- `<td class="num">${fmtInt(b.total_credits)}</td>` +
553
- `<td class="num">${fmtInt(b.karma)}</td>` +
554
- `<td class="num${b.stale_claim ? ' warn' : ''}">${fmtInt(claims)}${staleFlag}</td>` +
555
- `<td><span class="badge badge--status">${escapeHtml(b.status || '—')}</span></td>` +
556
- `<td><div class="watch-actions">${statusBtn}</div></td>` +
557
- '</tr>'
558
- );
559
- })
560
- .join('');
561
- target.innerHTML = renderTable(['Builder', 'Rank', (window.OTBBranding?.currencyLabel?.({ caps: true }) ?? 'Credits'), 'Karma', 'Active claims', 'Standing', 'Action'], rows);
562
-
563
- // Rank change via the <select>. Bespoke handler — a 'change' event that
564
- // rolls the select back to its previous value on failure (not wireActions).
565
- target.querySelectorAll('[data-roster-rank]').forEach((sel) => {
566
- sel.addEventListener('change', async () => {
567
- const id = sel.getAttribute('data-id');
568
- const rank = sel.value;
569
- const prev = sel.getAttribute('data-current');
570
- if (rank === prev) return;
571
- sel.disabled = true;
572
- try {
573
- await patchJson(`/builders/${encodeURIComponent(id)}/rank`, { rank });
574
- toast(`Rank set to ${rankLabel(rank)}.`);
575
- await loadRoster();
576
- } catch (err) {
577
- if (err && err.kind === '401') return;
578
- sel.value = prev; // roll back the select on failure
579
- sel.disabled = false;
580
- toast(`Could not change rank: ${err.message || err}`);
581
- }
582
- });
561
+ const builders = Array.isArray(data.builders) ? data.builders : [];
562
+ setAttention('att-stale', builders.filter((b) => b.stale_claim).length);
563
+ target.innerHTML = '';
564
+ if (!builders.length) {
565
+ target.innerHTML = emptyStateHtml('No builders on the roster.');
566
+ return;
567
+ }
568
+ ledger(target, {
569
+ columns: [
570
+ 'Builder', 'Rank',
571
+ { label: (window.OTBBranding?.currencyLabel?.({ caps: true }) ?? 'Credits'), class: 'num' },
572
+ { label: 'Karma', class: 'num' }, { label: 'Active claims', class: 'num' },
573
+ 'Standing', 'Action',
574
+ ],
575
+ pager: makePager('watch-roster', 'otb.watch.rosterPerPage'),
576
+ rowHtml: (b) => {
577
+ const opts = RANK_OPTIONS.slice();
578
+ if (b.rank && !opts.includes(b.rank)) opts.unshift(b.rank);
579
+ const rankSelect =
580
+ `<select data-roster-rank data-id="${escapeHtml(b.id)}" data-current="${escapeHtml(b.rank || '')}" aria-label="Rank">` +
581
+ opts.map((rk) => `<option value="${escapeHtml(rk)}"${rk === b.rank ? ' selected' : ''}>${escapeHtml(rankLabel(rk))}</option>`).join('') +
582
+ '</select>';
583
+ const isInactive = String(b.status) === 'inactive';
584
+ const statusBtn = isInactive
585
+ ? ghost(`data-roster-status="active" data-id="${escapeHtml(b.id)}"`, 'Reactivate')
586
+ : ghost(`data-roster-status="inactive" data-id="${escapeHtml(b.id)}"`, 'Offboard', true);
587
+ const claims = num(b.active_claim_count);
588
+ const staleFlag = b.stale_claim ? ' <span class="badge badge--sev-medium">stale</span>' : '';
589
+ // task 1467: the roster name links to the builder's public profile page.
590
+ const rosterName = escapeHtml(b.display_name || b.github_login || ('#' + b.id));
591
+ const rosterNameCell = b.id != null
592
+ ? `<a class="lb-name" href="/profile?id=${encodeURIComponent(b.id)}">${rosterName}</a>`
593
+ : rosterName;
594
+ return (
595
+ '<tr>' +
596
+ `<td>${rosterNameCell}` +
597
+ (b.github_login ? ` <span class="ov-mono">@${escapeHtml(b.github_login)}</span>` : '') +
598
+ '</td>' +
599
+ `<td>${rankSelect}</td>` +
600
+ `<td class="num">${fmtInt(b.total_credits)}</td>` +
601
+ `<td class="num">${fmtInt(b.karma)}</td>` +
602
+ `<td class="num${b.stale_claim ? ' warn' : ''}">${fmtInt(claims)}${staleFlag}</td>` +
603
+ `<td><span class="badge badge--status">${escapeHtml(b.status || '—')}</span></td>` +
604
+ `<td><div class="watch-actions">${statusBtn}</div></td>` +
605
+ '</tr>'
606
+ );
607
+ },
608
+ }).render(builders);
609
+
610
+ // Rank change via the <select>. Bespoke: a 'change' listener that rolls the
611
+ // select back to its previous value on failure, delegated so a page flip
612
+ // cannot strand a row without one.
613
+ if (!target['wired:rank']) {
614
+ target['wired:rank'] = true;
615
+ target.addEventListener('change', async (e) => {
616
+ const sel = e.target.closest('[data-roster-rank]');
617
+ if (!sel || !target.contains(sel)) return;
618
+ const id = sel.getAttribute('data-id');
619
+ const rank = sel.value;
620
+ const prev = sel.getAttribute('data-current');
621
+ if (rank === prev) return;
622
+ sel.disabled = true;
623
+ try {
624
+ await patchJson(`/builders/${encodeURIComponent(id)}/rank`, { rank });
625
+ toast(`Rank set to ${rankLabel(rank)}.`);
626
+ await loadRoster();
627
+ } catch (err) {
628
+ if (err && err.kind === '401') return;
629
+ sel.value = prev; // roll back the select on failure
630
+ sel.disabled = false;
631
+ toast(`Could not change rank: ${err.message || err}`);
632
+ }
583
633
  });
634
+ }
584
635
 
585
- // Status change (offboard / reactivate). Bespoke handler — it gathers an
586
- // offboarding reason via window.prompt BEFORE disabling, so not wireActions.
587
- target.querySelectorAll('[data-roster-status]').forEach((btn) => {
588
- btn.addEventListener('click', async () => {
589
- const id = btn.getAttribute('data-id');
590
- const status = btn.getAttribute('data-roster-status');
591
- let reason = null;
592
- if (status === 'inactive') {
593
- reason = window.prompt('Reason for offboarding (optional):', '') || null;
594
- }
595
- btn.disabled = true;
596
- try {
597
- await patchJson(`/builders/${encodeURIComponent(id)}/status`, { status, reason });
598
- toast(status === 'inactive' ? 'Builder offboarded.' : 'Builder reactivated.');
599
- await loadRoster();
600
- } catch (err) {
601
- if (err && err.kind === '401') return;
602
- btn.disabled = false;
603
- toast(`Could not change standing: ${err.message || err}`);
604
- }
605
- });
636
+ // Status change (offboard / reactivate). Bespoke: it gathers an offboarding
637
+ // reason via window.prompt BEFORE disabling, so not delegate().
638
+ if (!target['wired:status']) {
639
+ target['wired:status'] = true;
640
+ target.addEventListener('click', async (e) => {
641
+ const btn = e.target.closest('[data-roster-status]');
642
+ if (!btn || !target.contains(btn)) return;
643
+ const id = btn.getAttribute('data-id');
644
+ const status = btn.getAttribute('data-roster-status');
645
+ let reason = null;
646
+ if (status === 'inactive') {
647
+ reason = window.prompt('Reason for offboarding (optional):', '') || null;
648
+ }
649
+ btn.disabled = true;
650
+ try {
651
+ await patchJson(`/builders/${encodeURIComponent(id)}/status`, { status, reason });
652
+ toast(status === 'inactive' ? 'Builder offboarded.' : 'Builder reactivated.');
653
+ await loadRoster();
654
+ } catch (err) {
655
+ if (err && err.kind === '401') return;
656
+ btn.disabled = false;
657
+ toast(`Could not change standing: ${err.message || err}`);
658
+ }
606
659
  });
660
+ }
607
661
  }
608
662
 
609
663
  function loadRoster() {
@@ -617,29 +671,28 @@
617
671
  // ---- Box costs (#602): per-builder container-cost totals + recent ledger ----
618
672
  function renderBoxCosts(data, target) {
619
673
  const totals = Array.isArray(data.totals_by_builder) ? data.totals_by_builder : [];
620
- const ledger = Array.isArray(data.ledger) ? data.ledger : [];
674
+ const recent = Array.isArray(data.ledger) ? data.ledger : [];
621
675
  const usd = (n) => `$${(Number(n) || 0).toFixed(2)}`;
676
+ target.innerHTML = '';
622
677
  if (!totals.length) {
623
678
  target.innerHTML = emptyStateHtml('No dev-box costs accrued yet.');
624
679
  return;
625
680
  }
626
- const totalRows = totals
627
- .map((tb) => `<tr><td>${escapeHtml(tb.builder_login || ('#' + (tb.builder_id ?? '?')))}</td><td class="num">${usd(tb.total_usd)}</td></tr>`)
628
- .join('');
629
- const recentRows = ledger
630
- .slice(0, 40)
631
- .map((r) => `<tr><td>${escapeHtml(String(r.recorded_at || '').slice(0, 16))}</td>` +
681
+ ledger(group(target, 'Totals by builder', totals.length), {
682
+ columns: ['Builder', { label: 'Total', class: 'num' }],
683
+ rowHtml: (tb) => `<tr><td>${escapeHtml(tb.builder_login || ('#' + (tb.builder_id ?? '?')))}</td><td class="num">${usd(tb.total_usd)}</td></tr>`,
684
+ }).render(totals);
685
+ ledger(group(target, 'Recent ledger', recent.length), {
686
+ columns: ['When', 'Builder', 'Kind', { label: 'Amount', class: 'num' }, 'Detail'],
687
+ pager: makePager('watch-boxcosts', 'otb.watch.boxCostsPerPage'),
688
+ emptyMsg: 'No ledger rows yet.',
689
+ rowHtml: (r) => `<tr><td>${escapeHtml(String(r.recorded_at || '').slice(0, 16).replace('T', ' '))}</td>` +
632
690
  `<td>${escapeHtml(r.builder_login || ('#' + (r.builder_id ?? '?')))}</td>` +
633
- `<td><span class="watch-mono">${escapeHtml((r.source || '').replace('box-', ''))}</span></td>` +
691
+ `<td><span class="ov-mono">${escapeHtml((r.source || '').replace('box-', ''))}</span></td>` +
634
692
  `<td class="num">${usd(r.amount_usd)}</td>` +
635
- `<td>${escapeHtml(r.description || '')}</td></tr>`)
636
- .join('');
637
- target.innerHTML =
638
- '<h3 class="watch-subh">Totals by builder</h3>' +
639
- renderTable(['Builder', 'Total'], totalRows) +
640
- '<h3 class="watch-subh">Recent ledger</h3>' +
641
- renderTable(['When', 'Builder', 'Kind', 'Amount', 'Detail'], recentRows) +
642
- '<p class="watch-note">Container cost is tracked for oversight — not charged in the early stage.</p>';
693
+ `<td>${escapeHtml(r.description || '')}</td></tr>`,
694
+ }).render(recent);
695
+ target.insertAdjacentHTML('beforeend', foot('Container cost is tracked for oversight — not charged in the early stage.'));
643
696
  }
644
697
 
645
698
  function loadBoxCosts() {
@@ -662,20 +715,23 @@
662
715
  try {
663
716
  const data = await fetchJson('/grades/by-builder?days=30');
664
717
  const builders = Array.isArray(data.builders) ? data.builders : [];
718
+ target.innerHTML = '';
665
719
  if (!builders.length) {
666
720
  target.innerHTML = emptyStateHtml('No reviews recorded in this window.');
667
721
  return;
668
722
  }
669
723
  const sorted = builders.slice().sort((a, b) => num(b.n) - num(a.n));
670
- const rows = sorted
671
- .map((b) => {
724
+ ledger(target, {
725
+ columns: ['Builder', { label: 'Graded', class: 'num' }, { label: 'Avg score', class: 'num' }, { label: 'Pass rate', class: 'num' }, 'Last graded'],
726
+ pager: makePager('watch-marks', 'otb.watch.marksPerPage'),
727
+ rowHtml: (b) => {
672
728
  const avg = (b.avg_score === null || b.avg_score === undefined || b.avg_score === '')
673
729
  ? '—'
674
730
  : escapeHtml(String(b.avg_score));
675
731
  return (
676
732
  '<tr>' +
677
733
  `<td>${escapeHtml(b.display_name || b.github_login || ('#' + b.builder_id))}` +
678
- (b.github_login ? ` <span class="watch-rank">@${escapeHtml(b.github_login)}</span>` : '') +
734
+ (b.github_login ? ` <span class="ov-mono">@${escapeHtml(b.github_login)}</span>` : '') +
679
735
  '</td>' +
680
736
  `<td class="num">${fmtInt(b.n)}</td>` +
681
737
  `<td class="num">${avg}</td>` +
@@ -683,11 +739,9 @@
683
739
  `<td>${escapeHtml(fmtDate(b.last_graded_at))}</td>` +
684
740
  '</tr>'
685
741
  );
686
- })
687
- .join('');
688
- target.innerHTML =
689
- renderTable(['Builder', 'Graded', 'Avg score', 'Pass rate', 'Last graded'], rows) +
690
- (data.window_days ? `<p class="watch-note">Window: last ${escapeHtml(data.window_days)} days.</p>` : '');
742
+ },
743
+ }).render(sorted);
744
+ if (data.window_days) target.insertAdjacentHTML('beforeend', foot(`Window: last ${escapeHtml(data.window_days)} days.`));
691
745
  } catch (err) {
692
746
  if (err && err.kind === '401') return;
693
747
  if (err && err.kind === '403') { renderUnavailable(target, 'Archons only. Review data is not visible at your rank.'); return; }
@@ -712,13 +766,13 @@
712
766
  const meanAttempts = at && at.graded_tasks > 0
713
767
  ? Math.round((at.total_attempts / at.graded_tasks) * 10) / 10
714
768
  : null;
715
- stats.innerHTML = statCards([
716
- { num: (rolling.avg_score ?? '—') === '—' ? '—' : escapeHtml(String(rolling.avg_score)), label: 'Avg score' },
769
+ stats.innerHTML = figs([
770
+ { num: (rolling.avg_score ?? '—') === '—' ? '—' : String(rolling.avg_score), label: 'Avg score' },
717
771
  { num: fmtPct(passRate), label: 'Pass rate' },
718
772
  { num: fmtInt(n), label: 'Sample size' },
719
- { num: data.threshold !== undefined && data.threshold !== null ? escapeHtml(String(data.threshold)) : '—', label: 'Threshold' },
773
+ { num: data.threshold !== undefined && data.threshold !== null ? String(data.threshold) : '—', label: 'Threshold' },
720
774
  ...(at ? [
721
- { num: meanAttempts === null ? '—' : escapeHtml(String(meanAttempts)), label: 'Attempts/task' },
775
+ { num: meanAttempts === null ? '—' : String(meanAttempts), label: 'Attempts/task' },
722
776
  { num: at.remediation_rate === null ? '—' : fmtPct(at.remediation_rate), label: 'Remediation' },
723
777
  ] : []),
724
778
  ]);
@@ -745,7 +799,7 @@
745
799
  })
746
800
  .join('') +
747
801
  '</div>' +
748
- '<p class="watch-note">Per-day: ships graded · avg score.</p>';
802
+ foot('Per day: ships graded · avg score.');
749
803
  }
750
804
 
751
805
  function loadMasonMarks() {
@@ -762,26 +816,25 @@
762
816
 
763
817
  function renderSurveyor(data, stats) {
764
818
  const body = $('#surveyor-body');
765
- stats.innerHTML = statCards([
766
- { num: data.overall_median !== undefined && data.overall_median !== null ? escapeHtml(String(data.overall_median)) : '—', label: 'Overall median ratio' },
819
+ stats.innerHTML = figs([
820
+ { num: data.overall_median !== undefined && data.overall_median !== null ? String(data.overall_median) : '—', label: 'Overall median ratio' },
767
821
  { num: fmtInt(data.sample_size), label: 'Sample size' },
768
822
  ]);
769
823
  const byVersion = Array.isArray(data.by_version) ? data.by_version : [];
824
+ body.innerHTML = '';
770
825
  if (!byVersion.length) {
771
826
  body.innerHTML = emptyStateHtml('No drift recorded by version yet.');
772
827
  return;
773
828
  }
774
- const rows = byVersion
775
- .map(
776
- (v) =>
777
- '<tr>' +
778
- `<td><span class="watch-mono">${escapeHtml(v.version_id)}</span></td>` +
779
- `<td class="num">${v.median_ratio !== undefined && v.median_ratio !== null ? escapeHtml(String(v.median_ratio)) : '—'}</td>` +
780
- `<td class="num">${fmtInt(v.task_count)}</td>` +
781
- '</tr>'
782
- )
783
- .join('');
784
- body.innerHTML = renderTable(['Version', 'Median ratio', 'Tasks'], rows);
829
+ ledger(body, {
830
+ columns: ['Version', { label: 'Median ratio', class: 'num' }, { label: 'Tasks', class: 'num' }],
831
+ rowHtml: (v) =>
832
+ '<tr>' +
833
+ `<td><span class="ov-mono">${escapeHtml(v.version_id)}</span></td>` +
834
+ `<td class="num">${v.median_ratio !== undefined && v.median_ratio !== null ? escapeHtml(String(v.median_ratio)) : '—'}</td>` +
835
+ `<td class="num">${fmtInt(v.task_count)}</td>` +
836
+ '</tr>',
837
+ }).render(byVersion);
785
838
  }
786
839
 
787
840
  function loadSurveyor() {
@@ -796,7 +849,7 @@
796
849
  // 7. REPO HEALTH — codebase shape + weight
797
850
  // =========================================================================
798
851
 
799
- // Known scalar fields we render as their own stat cards if present. Anything
852
+ // Known scalar fields we render as their own figures if present. Anything
800
853
  // else numeric on `latest` is rendered defensively (label-cased key).
801
854
  function labelize(key) {
802
855
  return String(key)
@@ -805,60 +858,45 @@
805
858
  }
806
859
 
807
860
  function renderLedger(data, stats) {
808
- const locEl = $('#ledger-loc');
809
- const filesEl = $('#ledger-files');
810
- const latest = data.latest || {};
811
-
812
- // Core stat cards, plus any extra scalar fields (dead exports, stale
813
- // branches, etc.) that the snapshot happens to carry — render defensively.
814
- const cards = [
815
- { num: fmtCompact(latest.total_loc), label: 'Total lines' },
816
- { num: escapeHtml(fmtDate(latest.snapshot_date)), label: 'Snapshot' },
817
- ];
818
- const SKIP = new Set(['total_loc', 'snapshot_date', 'loc_by_dir', 'largest_files']);
819
- for (const [k, v] of Object.entries(latest)) {
820
- if (SKIP.has(k)) continue;
821
- if (typeof v === 'number') {
822
- cards.push({ num: fmtInt(v), label: labelize(k) });
823
- }
824
- }
825
- stats.innerHTML = statCards(cards);
826
-
827
- // loc_by_dir as a small list.
828
- const locByDir = latest.loc_by_dir && typeof latest.loc_by_dir === 'object' ? latest.loc_by_dir : {};
829
- const dirEntries = Object.entries(locByDir).sort((a, b) => num(b[1]) - num(a[1]));
830
- if (dirEntries.length) {
831
- locEl.innerHTML =
832
- '<h3 class="watch-sub-h">Lines by directory</h3><ul class="watch-list">' +
833
- dirEntries
834
- .map(
835
- ([dir, loc]) =>
836
- `<li><span class="k watch-mono">${escapeHtml(dir)}</span><span class="v">${fmtInt(loc)}</span></li>`
837
- )
838
- .join('') +
839
- '</ul>';
840
- } else {
841
- locEl.innerHTML = '';
861
+ const locEl = $('#ledger-loc');
862
+ const filesEl = $('#ledger-files');
863
+ const latest = data.latest || {};
864
+
865
+ // Core figures, plus any extra scalar fields (dead exports, stale branches,
866
+ // etc.) that the snapshot happens to carry — render defensively.
867
+ const cards = [
868
+ { num: fmtCompact(latest.total_loc), label: 'Total lines' },
869
+ { num: fmtDate(latest.snapshot_date), label: 'Snapshot' },
870
+ ];
871
+ const SKIP = new Set(['total_loc', 'snapshot_date', 'loc_by_dir', 'largest_files']);
872
+ for (const [k, v] of Object.entries(latest)) {
873
+ if (SKIP.has(k)) continue;
874
+ if (typeof v === 'number') {
875
+ cards.push({ num: fmtInt(v), label: labelize(k) });
842
876
  }
877
+ }
878
+ stats.innerHTML = figs(cards);
879
+
880
+ // loc_by_dir as a two-column ledger under its own head.
881
+ const locByDir = latest.loc_by_dir && typeof latest.loc_by_dir === 'object' ? latest.loc_by_dir : {};
882
+ const dirEntries = Object.entries(locByDir).sort((a, b) => num(b[1]) - num(a[1]));
883
+ locEl.innerHTML = '';
884
+ if (dirEntries.length) {
885
+ ledger(group(locEl, 'Lines by directory', dirEntries.length), {
886
+ columns: ['Directory', { label: 'Lines', class: 'num' }],
887
+ rowHtml: ([dir, loc]) => `<tr><td><span class="ov-mono">${escapeHtml(dir)}</span></td><td class="num">${fmtInt(loc)}</td></tr>`,
888
+ }).render(dirEntries);
889
+ }
843
890
 
844
- // Top ~10 largest files.
845
- const largest = Array.isArray(latest.largest_files) ? latest.largest_files.slice(0, 10) : [];
846
- if (largest.length) {
847
- const rows = largest
848
- .map(
849
- (f) =>
850
- '<tr>' +
851
- `<td><span class="watch-mono">${escapeHtml(f.path)}</span></td>` +
852
- `<td class="num">${fmtInt(f.lines)}</td>` +
853
- '</tr>'
854
- )
855
- .join('');
856
- filesEl.innerHTML =
857
- '<h3 class="watch-sub-h">Largest files</h3>' +
858
- renderTable(['File', 'Lines'], rows);
859
- } else {
860
- filesEl.innerHTML = '';
861
- }
891
+ // Top ~10 largest files.
892
+ const largest = Array.isArray(latest.largest_files) ? latest.largest_files.slice(0, 10) : [];
893
+ filesEl.innerHTML = '';
894
+ if (largest.length) {
895
+ ledger(group(filesEl, 'Largest files', largest.length), {
896
+ columns: ['File', { label: 'Lines', class: 'num' }],
897
+ rowHtml: (f) => `<tr><td><span class="ov-mono">${escapeHtml(f.path)}</span></td><td class="num">${fmtInt(f.lines)}</td></tr>`,
898
+ }).render(largest);
899
+ }
862
900
  }
863
901
 
864
902
  function loadLedger() {
@@ -870,18 +908,45 @@
870
908
  }
871
909
 
872
910
  // =========================================================================
873
- // 8. SESSIONS — session tally + link
911
+ // 8. SESSIONS — session tally + the latest five
874
912
  // =========================================================================
875
913
 
914
+ // The tally as figures, then the five most recent sessions the request has
915
+ // always carried (limit=5) and the page used to drop on the floor (task
916
+ // 1003469): builder, tasks, what it earned, tokens, how long, when.
876
917
  function renderSessions(data, stats) {
877
918
  const t = data.totals || {};
878
919
  setAttention('att-sessions', t.total);
879
- stats.innerHTML = statCards([
920
+ stats.innerHTML = figs([
880
921
  { num: fmtInt(t.total), label: 'Sessions' },
881
922
  { num: fmtInt(t.builders), label: 'Builders' },
882
923
  { num: fmtCompact(t.tokens), label: 'Tokens' },
883
924
  { num: fmtInt(t.tool_errors), label: 'Tool errors' },
884
925
  ]);
926
+ const recentEl = $('#sessions-recent');
927
+ if (!recentEl) return;
928
+ const sessions = Array.isArray(data.sessions) ? data.sessions : [];
929
+ recentEl.innerHTML = '';
930
+ if (!sessions.length) return;
931
+ ledger(group(recentEl, 'Latest sessions', sessions.length), {
932
+ columns: ['Builder', 'Tasks', { label: 'Earned', class: 'num' }, { label: 'Tokens', class: 'num' }, { label: 'Duration', class: 'num' }, 'Uploaded'],
933
+ rowHtml: (s) => {
934
+ const who = s.github_login ? `@${escapeHtml(s.github_login)}` : `#${escapeHtml(s.builder_id ?? '?')}`;
935
+ const tasks = Array.isArray(s.task_ids) && s.task_ids.length
936
+ ? s.task_ids.map((id) => `<a href="/#/task/${encodeURIComponent(id)}">task ${escapeHtml(String(id))}</a>`).join(', ')
937
+ : '—';
938
+ return (
939
+ '<tr>' +
940
+ `<td>${s.builder_id != null ? `<a class="lb-name" href="/profile?id=${encodeURIComponent(s.builder_id)}">${who}</a>` : who}</td>` +
941
+ `<td>${tasks}</td>` +
942
+ `<td class="num">${fmtInt(s.drachmae_earned)}</td>` +
943
+ `<td class="num">${fmtCompact(s.total_tokens)}</td>` +
944
+ `<td class="num">${escapeHtml(fmtDuration(s.duration_seconds))}</td>` +
945
+ `<td>${escapeHtml(fmtDate(s.uploaded_at))}</td>` +
946
+ '</tr>'
947
+ );
948
+ },
949
+ }).render(sessions);
885
950
  }
886
951
 
887
952
  function loadSessions() {
@@ -897,7 +962,7 @@
897
962
  // =========================================================================
898
963
 
899
964
  function showSealed(message) {
900
- $('#watch-sub').textContent = 'Archons only.';
965
+ $('#watch-sub').textContent = 'Restricted.';
901
966
  const gate = $('#sealed-scroll');
902
967
  if (message) $('#sealed-body').textContent = message;
903
968
  gate.hidden = false;
@@ -951,12 +1016,12 @@
951
1016
  // it reads (roster, security reports, audit log, sessions, box costs) delegated to
952
1017
  // metic with it. Cosmetic — requireGovernmentPage + the route gates are the wall.
953
1018
  if (!builder || (builder.rank !== 'metic' && builder.rank !== 'archon')) {
954
- showSealed('This page is available from the Metic rank up. Your current rank does not grant access.');
1019
+ showSealed(`Available from the ${rankLabel('metic')} rank up. Your current rank does not grant access.`);
955
1020
  return;
956
1021
  }
957
1022
 
958
1023
  revealSections();
959
- $('#watch-sub').textContent = 'System oversight. Every panel below is Archon-only.';
1024
+ $('#watch-sub').textContent = 'System oversight: people, operations and security.';
960
1025
 
961
1026
  // Each section loads independently — one failure must not break the rest.
962
1027
  loadGate();