@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.
- package/.bongos-core.json +62 -37
- 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 +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +40 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +36 -35
- package/docs/copy-registry.json +93 -74
- package/docs/module-api-changelog.md +2 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/hall-kit.js +309 -0
- package/modules/hall-ui/public/hall-render.js +10 -38
- package/modules/hall-ui/public/profile-charts.js +58 -65
- package/modules/hall-ui/public/profile.css +9 -18
- package/modules/hall-ui/public/style.css +101 -15
- package/modules/hall-ui/public/watch.css +6 -14
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/watch.js +139 -54
- package/modules/provisioning/recommendations.js +260 -0
- package/modules/provisioning/routes/provisioning.js +29 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/hall_audit.mjs +84 -0
- package/tests/hall_kit_charts.mjs +328 -0
- package/tests/hall_profile_world.mjs +27 -6
- package/tests/helpers.mjs +161 -0
- package/tests/joinability_invite_only.mjs +10 -2
- package/tests/provisioning_recommendations.mjs +448 -0
- package/tests/task_detail_ui.mjs +1 -153
- 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
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
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
|
-
.
|
|
1953
|
-
.
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
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
|
-
|
|
59
|
-
.watch-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
//
|
|
295
|
-
//
|
|
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
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
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
|
+
};
|