@bongos/core 1.19.512 → 1.19.513

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 (35) hide show
  1. package/.bongos-core.json +62 -37
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +40 -3
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +36 -35
  12. package/docs/copy-registry.json +93 -74
  13. package/docs/module-api-changelog.md +2 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  15. package/modules/hall-ui/public/hall-kit.js +309 -0
  16. package/modules/hall-ui/public/hall-render.js +10 -38
  17. package/modules/hall-ui/public/profile-charts.js +58 -65
  18. package/modules/hall-ui/public/profile.css +9 -18
  19. package/modules/hall-ui/public/style.css +101 -15
  20. package/modules/hall-ui/public/watch.css +6 -14
  21. package/modules/hall-ui/public/watch.html +1 -1
  22. package/modules/hall-ui/public/watch.js +139 -54
  23. package/modules/provisioning/recommendations.js +260 -0
  24. package/modules/provisioning/routes/provisioning.js +29 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/src/module-api.js +1 -1
  28. package/tests/hall_audit.mjs +84 -0
  29. package/tests/hall_kit_charts.mjs +328 -0
  30. package/tests/hall_profile_world.mjs +27 -6
  31. package/tests/helpers.mjs +161 -0
  32. package/tests/joinability_invite_only.mjs +10 -2
  33. package/tests/provisioning_recommendations.mjs +448 -0
  34. package/tests/task_detail_ui.mjs +1 -153
  35. package/tests/watch_applications_queue.mjs +381 -0
@@ -1944,24 +1944,110 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
1944
1944
  grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
1945
1945
  gap: 12px;
1946
1946
  }
1947
- .curve__chart {
1948
- background: var(--surface-2);
1949
- border-radius: var(--radius-sm);
1950
- padding: 10px 14px 12px;
1947
+ /* ---------- hall-kit charts (task 1003445) ----------
1948
+ The hall's ONE chart vocabulary. Before this the same horizontal bar was
1949
+ written three times — the Curve's kinds, Watch's Review-quality trend, and
1950
+ the profile's craft mix — each with its own class family, geometry and tone,
1951
+ and the line chart a fourth family beside them.
1952
+
1953
+ Three deliberate changes came with the merge, all of them the kit's defaults
1954
+ winning over a copy that had drifted: every row now holds the hall's 24px
1955
+ FLOOR (only the profile's copy remembered it, so the Curve's kind rows grow
1956
+ a few px), the hover dim applies to all three rather than the profile alone,
1957
+ and the value cell right-aligns (a no-op where its column is `auto`).
1958
+ Everything else is declaration-for-declaration what the three families
1959
+ said. */
1960
+
1961
+ .kit-chart { background: var(--surface-2); border-radius: var(--radius-sm); padding: 10px 14px 12px; }
1962
+ .kit-chart__title { font-size: 12px; font-weight: 600; color: var(--ink-soft); margin: 0 0 6px; }
1963
+ .kit-chart__empty { font-size: 12px; color: var(--ink-faint); }
1964
+ .kit-chart__foot { font-size: 11.5px; color: var(--ink-faint); margin-top: 4px; }
1965
+
1966
+ .kit-spark { width: 100%; height: 44px; display: block; }
1967
+ /* The stroke/fill here are the FALLBACK: sparklineHtml writes the caller's
1968
+ colour inline, because the Curve spends a different accent per chart. */
1969
+ .kit-spark__line { fill: none; stroke: var(--accent); stroke-width: 2; }
1970
+ .kit-spark__area { fill: color-mix(in srgb, var(--accent) 14%, transparent); stroke: none; }
1971
+
1972
+ /* Every difference between the three call sites is one of these six custom
1973
+ properties, which is what makes the fifth chart free: pass barSeriesHtml's
1974
+ labelWidth / valueWidth / barHeight / radius / track options, never a new
1975
+ class. */
1976
+ .kit-bars {
1977
+ --kit-bars-label-w: 110px;
1978
+ --kit-bars-val-w: auto;
1979
+ --kit-bars-h: 8px;
1980
+ --kit-bars-radius: 4px;
1981
+ --kit-bars-track: var(--surface-2);
1982
+ --kit-bars-fill: var(--accent);
1983
+ display: flex;
1984
+ flex-direction: column;
1985
+ gap: 6px;
1986
+ }
1987
+ .kit-bars__row {
1988
+ display: grid;
1989
+ grid-template-columns: var(--kit-bars-label-w) minmax(0, 1fr) var(--kit-bars-val-w);
1990
+ align-items: center;
1991
+ gap: 10px;
1992
+ /* The hall's 24px floor, held here so no call site has to remember it —
1993
+ the profile's mix was the only one of the three that did. */
1994
+ min-height: 24px;
1995
+ font-size: 12.5px;
1996
+ font-variant-numeric: tabular-nums;
1951
1997
  }
1952
- .curve__chart-title { font-size: 12px; font-weight: 600; color: var(--ink-soft); margin: 0 0 6px; }
1953
- .curve__chart-empty { font-size: 12px; color: var(--ink-faint); }
1954
- .curve__chart-foot { font-size: 11.5px; color: var(--ink-faint); margin-top: 4px; }
1955
- .spark { width: 100%; height: 44px; display: block; }
1956
- .spark__line { fill: none; stroke: var(--accent); stroke-width: 2; }
1957
- .spark__area { fill: color-mix(in srgb, var(--accent) 14%, transparent); stroke: none; }
1998
+ .kit-bars__label { color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1999
+ .kit-bars__track {
2000
+ display: block;
2001
+ height: var(--kit-bars-h);
2002
+ background: var(--kit-bars-track);
2003
+ border-radius: var(--kit-bars-radius);
2004
+ overflow: hidden;
2005
+ }
2006
+ .kit-bars__fill {
2007
+ display: block;
2008
+ height: 100%;
2009
+ background: var(--kit-bars-fill);
2010
+ border-radius: var(--kit-bars-radius);
2011
+ /* A re-render (an SSE nudge, a lookback change) grows the bar to its new
2012
+ width rather than jumping. First paint does not animate: a freshly
2013
+ inserted element has no previous width to transition from. The hall's ONE
2014
+ reduced-motion kill switch (§the kill switch, at the end of this sheet)
2015
+ already zeroes this with !important — do NOT add a second @media block
2016
+ here, which is what `tests/hall_tokens.mjs` forbids and why the rule
2017
+ cannot rot in two places. */
2018
+ transition: width 200ms ease;
2019
+ }
2020
+ .kit-bars__row:hover .kit-bars__fill { opacity: .8; }
2021
+ .kit-bars__val { text-align: right; color: var(--ink-faint); }
2022
+ /* The neutral tone: a series read as data rather than as the page's own
2023
+ accent — Watch's trend, where the accent belongs to the pressable. */
2024
+ .kit-bars--neutral {
2025
+ --kit-bars-track: var(--rule-soft);
2026
+ --kit-bars-fill: var(--ink-soft);
2027
+ }
2028
+ .kit-bars--neutral .kit-bars__val { color: var(--ink); }
2029
+
2030
+ /* The PLOT: a step line or a column series on a time axis. Moved verbatim from
2031
+ profile.css's .pchart__* (task 1003445), where the same frame was drawn twice
2032
+ by profile-charts.js. The dataviz spec here is measured, not chosen — a 2px
2033
+ accent line, a 10% wash, a hairline solid axis, an r=4 end dot with a 2px
2034
+ card-coloured ring, and the ONE labelled value in ink rather than the series
2035
+ colour. tests/hall_kit_charts.mjs pins all five; do not hand-tune one.
2036
+ `tabindex` is on the svg because the chart is walkable by arrow key, so the
2037
+ focus ring is a real control's, not decoration. */
2038
+ .kit-plot { display: block; width: 100%; height: auto; overflow: visible; border-radius: var(--radius-sm); }
2039
+ .kit-plot:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
2040
+ .kit-plot__line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
2041
+ .kit-plot__area { fill: color-mix(in srgb, var(--accent) 10%, transparent); stroke: none; }
2042
+ .kit-plot__axis, .kit-plot__hair { stroke: var(--rule-soft); stroke-width: 1; }
2043
+ .kit-plot__hair { stroke: var(--ink-faint); }
2044
+ .kit-plot__dot { fill: var(--accent); stroke: var(--bg-card); stroke-width: 2; }
2045
+ .kit-plot__col { fill: var(--accent); }
2046
+ .kit-plot__col--empty { fill: var(--rule-soft); }
2047
+ .kit-plot__end { font-family: var(--font-body); font-size: 11.5px; font-weight: 500; fill: var(--ink); }
2048
+ .kit-plot__tick { font-family: var(--label-face); font-size: 10px; letter-spacing: var(--label-space); text-transform: var(--label-case); fill: var(--ink-faint); }
1958
2049
 
1959
2050
  .curve__kinds { display: flex; flex-direction: column; gap: 6px; margin-top: 16px; }
1960
- .curve__kind { display: grid; grid-template-columns: 110px minmax(0, 1fr) auto; align-items: center; gap: 10px; font-size: 12.5px; }
1961
- .curve__kind-label { color: var(--ink-soft); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1962
- .curve__kind-bar { height: 8px; background: var(--surface-2); border-radius: 4px; overflow: hidden; }
1963
- .curve__kind-fill { height: 100%; background: var(--accent); border-radius: 4px; }
1964
- .curve__kind-n { color: var(--ink-faint); font-variant-numeric: tabular-nums; }
1965
2051
 
1966
2052
  /* ---------- onboarding checklist ---------- */
1967
2053
 
@@ -55,20 +55,12 @@
55
55
  /* The 14-day grade trend: a day, a bar, then the count and score. The bar is a
56
56
  measurement, not a control, so it is ink on a hairline track and never the
57
57
  accent. */
58
- .watch-trend { display: flex; flex-direction: column; margin-top: 4px; }
59
- .watch-trend__row {
60
- display: grid;
61
- grid-template-columns: 64px minmax(0, 1fr) 96px;
62
- gap: 12px;
63
- align-items: center;
64
- min-height: 28px;
65
- font-size: 13px;
66
- color: var(--ink-soft);
67
- font-variant-numeric: tabular-nums;
68
- }
69
- .watch-trend__bar-track { height: 6px; overflow: hidden; border-radius: var(--radius-pill); background: var(--rule-soft); }
70
- .watch-trend__bar-fill { display: block; height: 100%; background: var(--ink-soft); }
71
- .watch-trend__val { text-align: right; color: var(--ink); }
58
+ /* The Review-quality trend is OTBKit.barSeriesHtml in its neutral tone (task
59
+ 1003445) — the .watch-trend family was the hall's second copy of one
60
+ horizontal bar. What stays here is this page's own density: a taller row and
61
+ more air between the three columns than the kit's default. */
62
+ #mason-marks-trend .kit-bars { margin-top: 4px; gap: 0; }
63
+ #mason-marks-trend .kit-bars__row { min-height: 28px; gap: 12px; font-size: 13px; }
72
64
 
73
65
  /* A link inside a ledger cell (a session's tasks) is a control at a 13.5px line
74
66
  box: the padding buys the 24px floor and the negative margin hands it back. */
@@ -59,7 +59,7 @@
59
59
  <!-- 1. ACCESS REQUESTS -->
60
60
  <section class="scroll" id="gate-scroll" aria-labelledby="gate-h" hidden>
61
61
  <h2 id="gate-h" class="scroll__h">Access requests</h2>
62
- <p class="scroll__lede">People asking to join. Approve or dismiss each request.</p>
62
+ <p class="scroll__lede">People asking to join. Approve or dismiss each request, re-read the ones you already decided, and take back an approval before that person has signed in.</p>
63
63
  <div id="gate-body"><span class="kit-skel"></span></div>
64
64
  </section>
65
65
 
@@ -26,6 +26,14 @@
26
26
  // family's .ov-figs, every sub-section is an .ov-group head, the access requests
27
27
  // are oversight rows, and the five most recent sessions the page always fetched
28
28
  // and threw away are now rendered under the tally.
29
+ //
30
+ // TASK 1002332 made the access-requests section a REVIEW queue rather than a
31
+ // decision button: every row says whether the platform vouched for the name on
32
+ // it (ADR 0201 — the public unauthenticated write means anyone may apply as
33
+ // anyone, so vouched/unvouched is load-bearing, not decoration), the three
34
+ // lifecycle states sit behind one switch, and an approval can be RESCINDED
35
+ // before its holder ever signs in — a transition the route has always taken and
36
+ // nothing on this page offered (ADR 0208).
29
37
 
30
38
  (() => {
31
39
  'use strict';
@@ -43,7 +51,7 @@
43
51
  const { escapeHtml, $, num, fmtInt, fmtCompact } = window.OTB;
44
52
  // Shared component kit (hall-kit.js, task 2166): tabs, ledger, pager, empty
45
53
  // states — adopted here by task 2173, and by every list since task 1003469.
46
- const { makeTabs, makeLedger, makePager, emptyStateHtml } = window.OTBKit;
54
+ const { makeTabs, makeLedger, makePager, makeFilterBar, emptyStateHtml } = window.OTBKit;
47
55
  const fmtDate = (iso) => window.OTB.fmtDate(iso, { time: true });
48
56
  const rankLabel = (rank) => window.OTB.rankLabel(rank, { empty: '' });
49
57
  const toast = (msg) => window.OTB.toast(msg);
@@ -291,50 +299,131 @@
291
299
  });
292
300
  }
293
301
 
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.
302
+ // The three lists the queue is, behind one switch. Reviewing is not only
303
+ // deciding: an owner has to re-read what they already decided, and RESCIND an
304
+ // approval given by mistake before the applicant ever signed in — the route
305
+ // has always taken that transition (ADR 0201 D4.2) and nothing offered it,
306
+ // because this page only ever fetched `?status=pending` (ADR 0208).
307
+ const GATE_VIEWS = [
308
+ { value: 'pending', label: 'Waiting', empty: 'No pending access requests.' },
309
+ { value: 'invited', label: 'Admitted', empty: 'Nobody has been admitted yet.' },
310
+ { value: 'dismissed', label: 'Declined', empty: 'No declined requests.' },
311
+ ];
312
+ let gateView = 'pending';
313
+
314
+ function gateEmptyMsg() {
315
+ return (GATE_VIEWS.find((v) => v.value === gateView) || GATE_VIEWS[0]).empty;
316
+ }
317
+
318
+ // The switch itself, rebuilt on every render because `section` replaces the
319
+ // whole body — the same lifecycle wireInviteForm already lives with.
320
+ function wireGateView(target) {
321
+ const mount = target.querySelector('#gate-view');
322
+ if (!mount) return;
323
+ makeFilterBar({
324
+ mount,
325
+ primary: [{
326
+ id: 'gate-status',
327
+ label: 'Show',
328
+ value: gateView,
329
+ options: GATE_VIEWS.map((v) => ({ value: v.value, label: v.label })),
330
+ }],
331
+ onChange: (vals) => {
332
+ const next = vals['gate-status'];
333
+ if (next === gateView) return;
334
+ gateView = next;
335
+ loadGate();
336
+ },
337
+ });
338
+ }
339
+
340
+ // Whether the platform vouched for this applicant, said on the row — and the
341
+ // single thing that makes the queue safe to read (ADR 0201, Consequences).
342
+ // POST /access-requests is the one PUBLIC unauthenticated write, so anyone may
343
+ // file a request under anyone's name; a row that merely LOOKED vouched is the
344
+ // whole impersonation surface the vouch exists to close. It is never authority
345
+ // (ADR 0016) — an unvouched applicant is admitted by the identical statement —
346
+ // it only says whether anyone confirmed who is asking.
347
+ function vouchPill(vouched) {
348
+ return vouched
349
+ ? '<span class="fact-pill fact-pill--ok" title="The platform confirmed this request came from the account it names.">Identity vouched</span>'
350
+ : '<span class="fact-pill fact-pill--warn" title="Nobody vouched for this name — anyone can file a request naming anyone. Confirm who is asking before you admit them.">Name unvouched</span>';
351
+ }
352
+
353
+ // What this row still offers. A waiting request has two decisions; an admitted
354
+ // one can still be rescinded (until they sign in, after which the server
355
+ // refuses and says so); a declined one is terminal here — re-admitting is the
356
+ // invite form above, which is what the server's own refusal names.
357
+ function gateRowActions(r) {
358
+ const id = escapeHtml(r.id);
359
+ if (r.status === 'invited') {
360
+ return ghost(`data-gate-action="dismissed" data-gate-from="invited" data-id="${id}"`, 'Rescind', true);
361
+ }
362
+ if (r.status === 'dismissed') return '';
363
+ return ghost(`data-gate-action="invited" data-id="${id}"`, 'Invite')
364
+ + ghost(`data-gate-action="dismissed" data-id="${id}"`, 'Dismiss', true);
365
+ }
366
+
367
+ // One request, one oversight row: who, whether anyone vouched for them, their
368
+ // note as the sub line, when they asked (and who decided, once someone has) as
369
+ // the facts, and whatever decisions are still open on the right.
370
+ function gateRowHtml(r) {
371
+ const who = r.display_name
372
+ ? `${escapeHtml(r.display_name)}<span class="ov-mono">@${escapeHtml(r.github_login)}</span>`
373
+ : `<span class="ov-mono">@${escapeHtml(r.github_login)}</span>`;
374
+ const note = r.note ? `<p class="ov-row__sub">“${escapeHtml(r.note)}”</p>` : '';
375
+ // A rescind overwrites the approver with the rescinder (ADR 0208), so this
376
+ // names who put the row in the state it is in now — which is what it says.
377
+ const by = r.resolved_by_login ? ` by <span class="ov-mono">@${escapeHtml(r.resolved_by_login)}</span>` : '';
378
+ const decided = r.resolved_at
379
+ ? `<span class="ov-row__when">${r.status === 'invited' ? 'Admitted' : 'Declined'} ${escapeHtml(fmtDate(r.resolved_at))}${by}</span>`
380
+ : '';
381
+ const actions = gateRowActions(r);
382
+ return (
383
+ `<li class="ov-row" data-req-id="${escapeHtml(r.id)}">` +
384
+ '<div class="ov-row__main">' +
385
+ `<span class="ov-row__title">${who}</span>` +
386
+ note +
387
+ '<div class="ov-row__facts">' +
388
+ vouchPill(!!r.vouched) +
389
+ `<span class="ov-row__when">Asked ${escapeHtml(fmtDate(r.created_at))}</span>` +
390
+ decided +
391
+ '</div>' +
392
+ '</div>' +
393
+ (actions ? `<div class="ov-row__right"><div class="ov-row__actions">${actions}</div></div>` : '') +
394
+ '</li>'
395
+ );
396
+ }
397
+
296
398
  function renderGate(data, target) {
297
399
  const requests = Array.isArray(data.requests) ? data.requests : [];
298
- setAttention('att-access', requests.length); // tile count = table content
299
- if (!requests.length) {
300
- target.innerHTML = inviteFormHtml() + emptyStateHtml('No pending access requests.');
301
- wireInviteForm(target);
302
- return;
400
+ // The attention tile counts WAITING work, never whatever list is on screen:
401
+ // reading the decided history must not repaint the board with a number that
402
+ // means something else. Its own sub line says "pending".
403
+ if (gateView === 'pending') setAttention('att-access', requests.length);
404
+ const head = inviteFormHtml() + '<div id="gate-view"></div>';
405
+ target.innerHTML = requests.length
406
+ ? head + '<ul class="ov-rows">' + requests.map(gateRowHtml).join('') + '</ul>'
407
+ : head + emptyStateHtml(gateEmptyMsg());
408
+
409
+ if (requests.length) {
410
+ wireActions(target, '[data-gate-action]', async (btn) => {
411
+ const id = btn.getAttribute('data-id');
412
+ const status = btn.getAttribute('data-gate-action');
413
+ const rescinding = btn.getAttribute('data-gate-from') === 'invited';
414
+ await patchJson(`/access-requests/${encodeURIComponent(id)}`, { status });
415
+ toast(status === 'invited' ? 'Builder invited.'
416
+ : rescinding ? 'Approval rescinded — that sign-in no longer gets in.'
417
+ : 'Request dismissed.');
418
+ await loadGate();
419
+ }, 'Could not resolve');
303
420
  }
304
- target.innerHTML = inviteFormHtml() + '<ul class="ov-rows">' + requests
305
- .map((r) => {
306
- const who = r.display_name
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>` : '';
310
- return (
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>` +
314
- note +
315
- `<div class="ov-row__facts"><span class="ov-row__when">Asked ${escapeHtml(fmtDate(r.created_at))}</span></div>` +
316
- '</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>'
322
- );
323
- })
324
- .join('') + '</ul>';
325
-
326
- wireActions(target, '[data-gate-action]', async (btn) => {
327
- const id = btn.getAttribute('data-id');
328
- const status = btn.getAttribute('data-gate-action');
329
- await patchJson(`/access-requests/${encodeURIComponent(id)}`, { status });
330
- toast(status === 'invited' ? 'Builder invited.' : 'Request dismissed.');
331
- await loadGate();
332
- }, 'Could not resolve');
333
421
  wireInviteForm(target);
422
+ wireGateView(target);
334
423
  }
335
424
 
336
425
  function loadGate() {
337
- return section($('#gate-body'), () => fetchJson('/access-requests?status=pending'), renderGate, {
426
+ return section($('#gate-body'), () => fetchJson(`/access-requests?status=${encodeURIComponent(gateView)}`), renderGate, {
338
427
  sealedMsg: 'Archons only. Access requests are not visible at your rank.',
339
428
  errMsg: 'Could not load access requests.',
340
429
  name: 'gate',
@@ -782,23 +871,19 @@
782
871
  trendEl.innerHTML = emptyStateHtml('No trend to show yet.');
783
872
  return;
784
873
  }
785
- const maxN = Math.max(1, ...trend.map((t) => num(t.n)));
874
+ // task 1003445: this was the hall's third copy of the same horizontal bar.
875
+ // It draws through OTBKit.barSeriesHtml now, in the NEUTRAL tone — on an
876
+ // oversight page the accent belongs to the pressable, not to the data —
877
+ // with its own geometry passed as options rather than a second class family.
786
878
  trendEl.innerHTML =
787
- '<div class="watch-trend">' +
788
- trend
789
- .map((t) => {
790
- const w = Math.round((num(t.n) / maxN) * 100);
791
- const avg = (t.avg_score ?? '—') === '—' ? '—' : escapeHtml(String(t.avg_score));
792
- return (
793
- '<div class="watch-trend__row">' +
794
- `<span>${escapeHtml(fmtDay(t.day))}</span>` +
795
- `<span class="watch-trend__bar-track"><span class="watch-trend__bar-fill" style="width:${w}%"></span></span>` +
796
- `<span class="watch-trend__val">${fmtInt(t.n)} · ${avg}</span>` +
797
- '</div>'
798
- );
799
- })
800
- .join('') +
801
- '</div>' +
879
+ window.OTBKit.barSeriesHtml(
880
+ trend.map((t) => ({
881
+ label: fmtDay(t.day),
882
+ value: num(t.n),
883
+ valueLabel: `${fmtInt(t.n)} · ${(t.avg_score ?? '—') === '—' ? '—' : String(t.avg_score)}`,
884
+ })),
885
+ { tone: 'neutral', labelWidth: '64px', valueWidth: '96px', barHeight: '6px', pill: true },
886
+ ) +
802
887
  foot('Per day: ships graded · avg score.');
803
888
  }
804
889
 
@@ -0,0 +1,260 @@
1
+ // modules/provisioning/recommendations.js — the RULE ENGINE that turns what an
2
+ // owner said during project creation into a recommended starter bundle, a
3
+ // recommended governance preset, and an invite suggestion (BV1.R17, task
4
+ // 1002336; goal 1000046 criteria `proj-module-select`, `proj-governance-reco`,
5
+ // `proj-invite-builders`).
6
+ //
7
+ // WHY. Goal 1000046 pins the near term as RULE-BASED and defers the semantic
8
+ // layer: "near-term recommendations are rule-based and module search is lexical;
9
+ // the AI/embeddings layer … is a deferred follow-up captured in the idea inbox"
10
+ // (idea 1000552, task 1003500). So this file is a set of small declarative
11
+ // TABLES — condition -> effect -> a one-line `reason` in the owner's own words —
12
+ // and a resolver over them. Not a model, not a scoring function, and nothing
13
+ // that reads the free-text description: a rule an owner cannot be told the
14
+ // reason for is a recommendation they cannot audit.
15
+ //
16
+ // TWO INPUTS, AND ONLY TWO. `type` is the DECLARED project type (a column, the
17
+ // closed set task 1002772 fixed) and `team_shape` is the one closed-answer key
18
+ // the R15 interview collects (`PROJECT_DETAIL_VOCAB` in provisioning.js beside
19
+ // this file). The interview deliberately does not re-ask the type (ADR 0237 §2),
20
+ // so the two arrive separately and this is the file that puts them together.
21
+ //
22
+ // SKIPPING IS A SUPPORTED ANSWER, NOT A GAP. The interview is optional (ADR 0182
23
+ // D4), so every table has a stated DEFAULT for "nobody said" and the defaults are
24
+ // deliberately the narrowest option — the type's own bundle unadjusted, the
25
+ // governance preset that grants nobody anything, and no invites. An off-vocabulary
26
+ // team shape reads as no answer for the same reason `normalizeProjectDetail` drops
27
+ // one: inventing an answer nobody gave feeds the recommendation a lie. R18 (task
28
+ // 1002337) is the live proof of exactly that split.
29
+ //
30
+ // PURE — no I/O, no core import, no sibling-module import; requires only its own
31
+ // module's `starter-bundles.js` and `provisioning.js`, both of which are pure
32
+ // themselves. That is what lets a route, a script and a test read one answer.
33
+ // It is also why `invites.suggested` is always empty here: ranking builders means
34
+ // querying the leaderboard, which lives in a sibling module (`economy`) this one
35
+ // may not import (the one-way rule, ADR 0083). The engine states WHAT to look for
36
+ // in `invites.seeking`; R28 (task 1002347) owns the lookup that fills it.
37
+ //
38
+ // Decision record: ADR 0239.
39
+
40
+ 'use strict';
41
+
42
+ const { bundleForProjectType, ALWAYS_ON_CORE } = require('./starter-bundles');
43
+ const { PROJECT_DETAIL_VOCAB } = require('./provisioning');
44
+
45
+ // How the team shape adjusts the type's starter bundle, in application order.
46
+ // Each entry is one condition (`teamShape`) and one effect on ONE opt-in module,
47
+ // carrying the sentence a picker shows when it fires. Deliberately tiny: ADR 0237
48
+ // already made the per-type preset the opinion, and today's opt-in catalog is
49
+ // three modules wide (`agents`, `dev-box`, `discord`) — so this table's job is to
50
+ // correct a preset the owner's answer contradicts, not to re-derive it.
51
+ //
52
+ // A rule may only ever name an OPT-IN module. Adding or removing an always-on one
53
+ // would be a toggle that toggles nothing (ADR 0237 §3's argument, and
54
+ // `tests/provisioning_recommendations.mjs` fails the build over it).
55
+ const TEAM_SHAPE_RULES = Object.freeze([
56
+ Object.freeze({
57
+ id: 'a-community-needs-somewhere-to-gather',
58
+ teamShape: 'community',
59
+ effect: 'add',
60
+ module: 'discord',
61
+ reason: 'You said an open community is building this, so somewhere for it to gather is switched on.',
62
+ }),
63
+ Object.freeze({
64
+ id: 'a-team-shares-one-environment',
65
+ teamShape: 'small-team',
66
+ effect: 'add',
67
+ module: 'dev-box',
68
+ reason: 'A small team gets one shared environment to build in, so nobody is debugging their own laptop.',
69
+ }),
70
+ Object.freeze({
71
+ id: 'building-alone-needs-no-shared-environment',
72
+ teamShape: 'solo',
73
+ effect: 'remove',
74
+ module: 'dev-box',
75
+ reason: 'You are building this alone, so the shared cloud environment is off — your own machine is the setup.',
76
+ }),
77
+ ]);
78
+
79
+ // The governance presets, by key. A preset is a NAME plus the one sentence that
80
+ // says what living under it feels like — never a set of roles or permissions,
81
+ // because there is nowhere to put them: the governance module is planned (goal
82
+ // 1000030) and unbuilt, which criterion `proj-governance-reco` says out loud.
83
+ // R25 (task 1002344) is the spike that fits these to the real module when it
84
+ // exists; until then every recommendation carries `applied: false` so a form can
85
+ // label it a preview without hardcoding that fact in four places.
86
+ const GOVERNANCE_PRESETS = Object.freeze({
87
+ 'owner-only': Object.freeze({
88
+ label: 'You decide',
89
+ preview: 'You hold every role. Nothing waits on anyone else, and nothing changes without you.',
90
+ }),
91
+ 'trusted-team': Object.freeze({
92
+ label: 'A trusted team',
93
+ preview: 'Everyone you invite can build and ship. You keep the decisions that spend money or change the shape of the project.',
94
+ }),
95
+ 'open-contribution': Object.freeze({
96
+ label: 'Open contribution',
97
+ preview: 'Anyone can turn up and propose work. A small circle you choose reviews what actually lands.',
98
+ }),
99
+ });
100
+
101
+ // Team shape -> governance preset. One row per shape the interview can return;
102
+ // the integrity test fails the build if a shape in `PROJECT_DETAIL_VOCAB` has no
103
+ // row here, so a fourth answer can never be added and silently unhandled.
104
+ const GOVERNANCE_RULES = Object.freeze([
105
+ Object.freeze({
106
+ teamShape: 'solo',
107
+ preset: 'owner-only',
108
+ reason: 'You said you are building this alone.',
109
+ }),
110
+ Object.freeze({
111
+ teamShape: 'small-team',
112
+ preset: 'trusted-team',
113
+ reason: 'You said a small team is building this, so the people you invite can get on with it without asking first.',
114
+ }),
115
+ Object.freeze({
116
+ teamShape: 'community',
117
+ preset: 'open-contribution',
118
+ reason: 'You said an open community is building this, so people can arrive and propose work without an invite.',
119
+ }),
120
+ ]);
121
+
122
+ // What a skipped interview gets. The narrowest preset on purpose: widening later
123
+ // costs one setting, while a project that quietly granted strangers a role it was
124
+ // never asked to grant is not recoverable by changing the setting back.
125
+ const GOVERNANCE_DEFAULT = Object.freeze({
126
+ preset: 'owner-only',
127
+ reason: 'Nobody has said who is building this yet, so this is the narrowest setup — widen it whenever you like.',
128
+ });
129
+
130
+ // Team shape -> who is worth suggesting, as a SEEK — the kind of builder to look
131
+ // for and how many. This engine is pure and cannot run the lookup (the
132
+ // leaderboard lives in the `economy` module, which this one may not import), so
133
+ // `seeking` is the seam R28 (task 1002347) reads and `suggested` stays empty.
134
+ // `seeking: null` means "suggest nobody", which is a real answer and not a
135
+ // missing one.
136
+ const INVITE_RULES = Object.freeze([
137
+ Object.freeze({
138
+ teamShape: 'solo',
139
+ seeking: null,
140
+ reason: 'You said you are building alone, so nobody is suggested. You can invite anyone later from the project settings.',
141
+ }),
142
+ Object.freeze({
143
+ teamShape: 'small-team',
144
+ seeking: Object.freeze({ kind: 'top-builders', limit: 3 }),
145
+ reason: 'A small team is the shape that fills fastest, so here is a short list of the builders with the strongest recent record.',
146
+ }),
147
+ Object.freeze({
148
+ teamShape: 'community',
149
+ seeking: Object.freeze({ kind: 'top-builders', limit: 5 }),
150
+ reason: 'An open community wants a wider first circle, so the list runs longer. Guilds join it once guilds can be invited.',
151
+ }),
152
+ ]);
153
+
154
+ // What a skipped interview gets: nobody. Same reasoning as the governance
155
+ // default — an invite suggested on an answer nobody gave is a stranger the owner
156
+ // never asked for.
157
+ const INVITE_DEFAULT = Object.freeze({
158
+ seeking: null,
159
+ reason: 'Nobody has said who is building this yet, so nothing is suggested. Invites can be sent any time from the project settings.',
160
+ });
161
+
162
+ // The interview's answer, or null. Anything outside `PROJECT_DETAIL_VOCAB` —
163
+ // absent, misspelled, or a value that was valid when it was stored and has since
164
+ // left the vocabulary — reads as NO ANSWER, exactly as `normalizeProjectDetail`
165
+ // drops it on the way in. PURE.
166
+ function normalizeTeamShape(value) {
167
+ return PROJECT_DETAIL_VOCAB.team_shape.includes(value) ? value : null;
168
+ }
169
+
170
+ // Apply the team-shape rules to one type's opt-in bundle. Returns the adjusted
171
+ // list plus the adjustments that ACTUALLY changed it — a rule whose module the
172
+ // bundle already carries (or already lacks) records nothing, because a stated
173
+ // reason for a change that did not happen is a lie the owner cannot check.
174
+ // Never touches the always-on core. PURE.
175
+ function adjustBundle(bundle, teamShape) {
176
+ const modules = [...bundle];
177
+ const adjustments = [];
178
+ for (const rule of TEAM_SHAPE_RULES) {
179
+ if (rule.teamShape !== teamShape) continue;
180
+ const at = modules.indexOf(rule.module);
181
+ if (rule.effect === 'add' && at === -1) modules.push(rule.module);
182
+ else if (rule.effect === 'remove' && at !== -1) modules.splice(at, 1);
183
+ else continue;
184
+ adjustments.push({ rule: rule.id, effect: rule.effect, module: rule.module, reason: rule.reason });
185
+ }
186
+ return { modules, adjustments };
187
+ }
188
+
189
+ // The whole recommendation for one set of creation answers:
190
+ //
191
+ // {
192
+ // type, teamShape, // what was actually declared/answered (null = not)
193
+ // tailored, // did a DETAIL answer shape any of this?
194
+ // bundle: { type, label, summary, core, bundle, modules, adjustments },
195
+ // governance: { preset, label, preview, reason, applied },
196
+ // invites: { suggested, seeking, reason },
197
+ // }
198
+ //
199
+ // `bundle` is ADR 0237 §5's shape with one key added, so R20's picker and this
200
+ // engine can never name different modules; `adjustments` is the delta the rules
201
+ // made to it and is empty when nothing fired. `tailored` is false when the
202
+ // interview was skipped — the machine-readable half of criterion
203
+ // `proj-detail-interview`'s "completing it produces tailored recommendations;
204
+ // skipping produces sensible defaults", and what R18 (task 1002337) proves.
205
+ //
206
+ // Accepts a flat object because the two answers live in two places: `type` is a
207
+ // column and `team_shape` is a key of the `detail` jsonb, so a caller holding a
208
+ // row passes `{ type: row.type, ...projectDetail(row) }`. Never throws and never
209
+ // invents an answer. PURE — result arrays and objects are fresh, so a caller may
210
+ // sort or splice them without corrupting the frozen tables.
211
+ function recommendationsForDetails(answers) {
212
+ const given = answers && typeof answers === 'object' && !Array.isArray(answers) ? answers : {};
213
+ const teamShape = normalizeTeamShape(given.team_shape);
214
+ const preset = bundleForProjectType(given.type);
215
+ const { modules, adjustments } = adjustBundle(preset.bundle, teamShape);
216
+
217
+ const gov = GOVERNANCE_RULES.find((r) => r.teamShape === teamShape) || GOVERNANCE_DEFAULT;
218
+ const invite = INVITE_RULES.find((r) => r.teamShape === teamShape) || INVITE_DEFAULT;
219
+
220
+ return {
221
+ type: preset.type,
222
+ teamShape,
223
+ tailored: teamShape !== null,
224
+ bundle: {
225
+ type: preset.type,
226
+ label: preset.label,
227
+ summary: preset.summary,
228
+ core: [...ALWAYS_ON_CORE],
229
+ bundle: modules,
230
+ modules: [...ALWAYS_ON_CORE, ...modules],
231
+ adjustments,
232
+ },
233
+ governance: {
234
+ preset: gov.preset,
235
+ label: GOVERNANCE_PRESETS[gov.preset].label,
236
+ preview: GOVERNANCE_PRESETS[gov.preset].preview,
237
+ reason: gov.reason,
238
+ // Always false until the governance module ships (goal 1000030). A named
239
+ // recommendation, never an applied setting — criterion `proj-governance-reco`.
240
+ applied: false,
241
+ },
242
+ invites: {
243
+ // The seam, not an omission: ranking builders is a leaderboard query this
244
+ // pure file may not run. R28 (task 1002347) fills this from `seeking`.
245
+ suggested: [],
246
+ seeking: invite.seeking ? { ...invite.seeking } : null,
247
+ reason: invite.reason,
248
+ },
249
+ };
250
+ }
251
+
252
+ module.exports = {
253
+ TEAM_SHAPE_RULES,
254
+ GOVERNANCE_PRESETS,
255
+ GOVERNANCE_RULES,
256
+ GOVERNANCE_DEFAULT,
257
+ INVITE_RULES,
258
+ INVITE_DEFAULT,
259
+ recommendationsForDetails,
260
+ };