@bongos/core 1.19.512 → 1.19.514
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 +77 -42
- 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/0182-project-visibility-two-axes.md +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 +41 -4
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +36 -35
- package/docs/copy-registry.json +93 -74
- package/docs/module-api-changelog.md +4 -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/platform-identity/platform-identity.js +24 -20
- package/modules/platform-identity/projects-feed.js +142 -0
- package/modules/platform-identity/routes/projects.js +27 -8
- package/modules/platform-identity/tests/platform-identity.mjs +9 -5
- 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/projects_hub.mjs +5 -1
- package/tests/provisioning_recommendations.mjs +448 -0
- package/tests/task_detail_ui.mjs +1 -153
- package/tests/visible_projects_feed.mjs +320 -0
- 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
|
|
|
@@ -313,6 +313,13 @@ const {
|
|
|
313
313
|
publishGatePort,
|
|
314
314
|
} = require('./publish-gate');
|
|
315
315
|
|
|
316
|
+
// The public projects feed — who is on the map, and what each row may say (ADR 0182
|
|
317
|
+
// D1/D2a/D3; R06, task 1002325). Its own file for the publish-gate.js reason: this
|
|
318
|
+
// file is at the size ratchet. Re-exported below, so the surface is unchanged.
|
|
319
|
+
const {
|
|
320
|
+
ORB_BY_STATE, orbForState, isStealthProject, feedShapeFor, listPublicProjects,
|
|
321
|
+
} = require('./projects-feed');
|
|
322
|
+
|
|
316
323
|
// Upsert a catalog row (keyed by origin). Called when a client registers (so every
|
|
317
324
|
// federated project is catalogued, unfeatured) and by the Archon create/curate route.
|
|
318
325
|
// Never touches `featured` on update — that is Archon-only via setProjectFeatured.
|
|
@@ -360,21 +367,9 @@ async function getProject(id, { pool = defaultPool } = {}) {
|
|
|
360
367
|
return { ...rows[0], ...projectPublishability(rows[0]) };
|
|
361
368
|
}
|
|
362
369
|
|
|
363
|
-
// The PUBLIC
|
|
364
|
-
//
|
|
365
|
-
//
|
|
366
|
-
// card labels it "Listed" for exactly that reason (task 1002778). updated_at may
|
|
367
|
-
// sort but is never projected: feature toggles and client re-registration bump
|
|
368
|
-
// it, so as a public "last updated" it would lie.
|
|
369
|
-
async function listFeaturedProjects({ pool = defaultPool } = {}) {
|
|
370
|
-
const { rows } = await pool.query(
|
|
371
|
-
`SELECT origin, name, tagline, art_url, type, template, created_at
|
|
372
|
-
FROM platform_identity_projects
|
|
373
|
-
WHERE featured = true AND status = 'active'
|
|
374
|
-
ORDER BY updated_at DESC`,
|
|
375
|
-
);
|
|
376
|
-
return rows;
|
|
377
|
-
}
|
|
370
|
+
// The PUBLIC projects feed lives in projects-feed.js (R06, task 1002325) — the
|
|
371
|
+
// predicate, the orb mapping and the stealth redaction are one concern, and each is
|
|
372
|
+
// only safe given the other two.
|
|
378
373
|
|
|
379
374
|
// The curation list — every active project, its featured flag, its stored door, and the
|
|
380
375
|
// DERIVED publish verdict. `publishable` is computed in SQL from publishableSql() so this
|
|
@@ -779,10 +774,15 @@ function isNumericGithubId(githubId) {
|
|
|
779
774
|
// table, the disciplines union computed separately (never a pre-GROUP-BY unnest
|
|
780
775
|
// fan-out — bug task 1002911). Per-project rows join the module-internal
|
|
781
776
|
// client_id → sso_clients.origin → projects.origin two-hop; a project is NAMED
|
|
782
|
-
// only when its catalog row is publicly listed today (featured + active
|
|
783
|
-
//
|
|
784
|
-
//
|
|
785
|
-
//
|
|
777
|
+
// only when its catalog row is publicly listed today (featured + active).
|
|
778
|
+
// Everything else is counted in more_projects, never named ("the aggregate is
|
|
779
|
+
// not the leak, the name is").
|
|
780
|
+
//
|
|
781
|
+
// Deliberately NARROWER than the public feed since R06 (task 1002325), and left so
|
|
782
|
+
// rather than widened here: a STEALTH project is on the map ANONYMOUSLY (ADR 0182
|
|
783
|
+
// D2a), so naming it in a builder's profile would be the leak the feed exists to
|
|
784
|
+
// prevent, and how much of a record may be NAMED is ADR 0171 D2's question, not
|
|
785
|
+
// R06's. Narrower under-names, which is safe. The widening is its own task.
|
|
786
786
|
// client_id never appears in the returned shape.
|
|
787
787
|
async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
|
|
788
788
|
if (!isNumericGithubId(githubId)) return null;
|
|
@@ -1436,9 +1436,13 @@ module.exports = {
|
|
|
1436
1436
|
PROJECT_MATERIALS,
|
|
1437
1437
|
PROJECT_SHAPES,
|
|
1438
1438
|
getProject,
|
|
1439
|
-
|
|
1439
|
+
listPublicProjects,
|
|
1440
1440
|
listAllProjects,
|
|
1441
1441
|
setProjectFeatured,
|
|
1442
|
+
ORB_BY_STATE,
|
|
1443
|
+
orbForState,
|
|
1444
|
+
isStealthProject,
|
|
1445
|
+
feedShapeFor,
|
|
1442
1446
|
// memberships
|
|
1443
1447
|
MEMBERSHIP_KINDS,
|
|
1444
1448
|
membershipKindFromLegacy,
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// modules/platform-identity/projects-feed.js — THE public projects feed: WHO is on
|
|
2
|
+
// the map, and WHAT each row is allowed to say (R06, task 1002325; ADR 0182 D1/D2a/D3).
|
|
3
|
+
// The in-repo lever the orbs page reads. The publish-gate.js precedent — one question,
|
|
4
|
+
// one file, so no two surfaces can answer it differently.
|
|
5
|
+
//
|
|
6
|
+
// THREE ANSWERS, and they belong together because each is only safe given the others:
|
|
7
|
+
//
|
|
8
|
+
// • WHO — `listPublicProjects`. A project is on the map iff `status = 'active' AND
|
|
9
|
+
// publishable`, the derived identity gate, taken from publishableSql() so this feed
|
|
10
|
+
// and the curation list read ONE definition. It used to be `featured = true`, which
|
|
11
|
+
// made Archon curation load-bearing for visibility at all: an owner could set their
|
|
12
|
+
// project public and see nothing happen (ADR 0182 D3). `featured` keeps the only job
|
|
13
|
+
// D3 leaves it — RANKING among rows already on the map. Dark matter is excluded HERE,
|
|
14
|
+
// in SQL, because a client-side filter ships the husk to anyone who opens a network tab.
|
|
15
|
+
//
|
|
16
|
+
// • WHAT IT DRAWS AS — `orbForState`, the ADR 0182 D1 mapping:
|
|
17
|
+
// public → planet (identified, anyone may join)
|
|
18
|
+
// private → star (identified, no self-join, applications accepted)
|
|
19
|
+
// stealth → black hole (ANONYMOUS, no self-join, applications refused)
|
|
20
|
+
// Three surfaces render it — this feed, Explore (R07, task 1002326) and the static
|
|
21
|
+
// orbs site (R09, task 1002328) — and the last is a different repo. A mapping copied
|
|
22
|
+
// into each is a mapping that drifts.
|
|
23
|
+
//
|
|
24
|
+
// • WHAT IT MAY SAY — `feedShapeFor`, the D2a privacy boundary in code: a stealth
|
|
25
|
+
// project exposes EXISTENCE, never IDENTITY. Redaction is server-side because the
|
|
26
|
+
// naive version sends the row and lets the orbs page hide it, which leaks the whole
|
|
27
|
+
// project to anyone who opens the network tab — and that page is a static site
|
|
28
|
+
// outside this repo, so we could not enforce it there even in principle.
|
|
29
|
+
//
|
|
30
|
+
// TWO DELIBERATE FAIL-CLOSED CHOICES, because this is a privacy boundary, not a
|
|
31
|
+
// rendering detail:
|
|
32
|
+
// 1. AN UNKNOWN STATE DRAWS AS A BLACK HOLE. A fourth enum value added later and not
|
|
33
|
+
// mapped here would otherwise ship its project's name as a planet.
|
|
34
|
+
// 2. THE STEALTH SHAPE IS AN ALLOW-LIST, NOT A DENY-LIST — everything is nulled except
|
|
35
|
+
// the two keys a black hole may carry, so a column added to the feed next year is
|
|
36
|
+
// anonymous without anyone remembering to come back here. A deny-list of "the
|
|
37
|
+
// identifying fields" is correct exactly until the feed grows.
|
|
38
|
+
//
|
|
39
|
+
// WHY ITS OWN FILE. The account-visibility.js / publish-gate.js reason: platform-identity.js
|
|
40
|
+
// sits at the file-size ratchet. The caller-facing symbols (listPublicProjects, feedShapeFor,
|
|
41
|
+
// orbForState, isStealthProject, ORB_BY_STATE) are re-exported there so the module's public
|
|
42
|
+
// surface is unchanged; orbStateOf, FALLBACK_PROJECT_STATE and STEALTH_ANONYMOUS_KEEPS are
|
|
43
|
+
// this file's own internals and are reached only from here and its test.
|
|
44
|
+
// Proof: ../../tests/visible_projects_feed.mjs
|
|
45
|
+
'use strict';
|
|
46
|
+
|
|
47
|
+
const { pool: defaultPool } = require('../../src/module-api');
|
|
48
|
+
const { publishableSql } = require('./publish-gate');
|
|
49
|
+
|
|
50
|
+
// ADR 0182 D1's mapping, and the only copy of it in the repo. The three words are locked
|
|
51
|
+
// by goal 1000046's `proj-visibility-states` criterion, not chosen here.
|
|
52
|
+
const ORB_BY_STATE = Object.freeze({
|
|
53
|
+
public: 'planet',
|
|
54
|
+
private: 'star',
|
|
55
|
+
stealth: 'black-hole',
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// What an unrecognised (or absent) stored value is treated as — the ANONYMOUS state,
|
|
59
|
+
// deliberately. A project whose door we cannot read is one whose name we must not publish.
|
|
60
|
+
const FALLBACK_PROJECT_STATE = 'stealth';
|
|
61
|
+
|
|
62
|
+
// The ONLY keys a black hole may carry; everything else on the row is nulled. No stable
|
|
63
|
+
// per-project key appears here on purpose: an id, a slug or a hash of the origin would
|
|
64
|
+
// each let an observer who kept yesterday's public feed name the project that went stealth
|
|
65
|
+
// today. Black holes are interchangeable, which is the whole point of the metaphor.
|
|
66
|
+
const STEALTH_ANONYMOUS_KEEPS = Object.freeze(['state', 'orb']);
|
|
67
|
+
|
|
68
|
+
// The stored join door, normalised to a state this file knows. Never echoes an
|
|
69
|
+
// unvalidated value onto the wire.
|
|
70
|
+
function orbStateOf(visibility) {
|
|
71
|
+
const v = String(visibility ?? '').trim();
|
|
72
|
+
return Object.prototype.hasOwnProperty.call(ORB_BY_STATE, v) ? v : FALLBACK_PROJECT_STATE;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The render type for a state — the one mapping R07 and R09 both read. PURE.
|
|
76
|
+
function orbForState(state) {
|
|
77
|
+
return ORB_BY_STATE[orbStateOf(state)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Is this row anonymous on the map? Exported so the feed route can keep a stealth
|
|
81
|
+
// project's origin out of the per-project enrichment fetches entirely — asking its own
|
|
82
|
+
// public API for its builders' avatars would both put identity on the wire and tell that
|
|
83
|
+
// project it was being enumerated.
|
|
84
|
+
function isStealthProject(project) {
|
|
85
|
+
return orbStateOf(project?.visibility) === 'stealth';
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ONE catalog row → the public wire row. Spends `visibility` deriving `state` + `orb` (it
|
|
89
|
+
// never doubles it on the wire), then either passes the identified row through or rebuilds
|
|
90
|
+
// it from the allow-list above. The redaction NULLS rather than DELETES: the feed's
|
|
91
|
+
// standing contract is that an unanswerable value is null and never absent, because
|
|
92
|
+
// JSON.stringify drops the absent one and a client would read `undefined` on some rows and
|
|
93
|
+
// null on others. A redacted row is a full-width row that says nothing. PURE.
|
|
94
|
+
function feedShapeFor(project) {
|
|
95
|
+
const { visibility, ...rest } = project || {};
|
|
96
|
+
const state = orbStateOf(visibility);
|
|
97
|
+
const shaped = { ...rest, state, orb: ORB_BY_STATE[state] };
|
|
98
|
+
if (state !== 'stealth') return shaped;
|
|
99
|
+
const anonymous = {};
|
|
100
|
+
for (const key of Object.keys(shaped)) {
|
|
101
|
+
anonymous[key] = STEALTH_ANONYMOUS_KEEPS.includes(key) ? shaped[key] : null;
|
|
102
|
+
}
|
|
103
|
+
return anonymous;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// The feed's hard ceiling. Until R06 the listing was bounded by an Archon choosing to
|
|
107
|
+
// feature a project; the swap to `publishable` replaced that human bound with self-service,
|
|
108
|
+
// on a route that is public, CORS-open, unauthenticated AND drives one outbound stats fetch
|
|
109
|
+
// per row. A cap is the cheap half of that trade — the honest half is pagination, which is
|
|
110
|
+
// its own task; this constant only stops the tail from being unbounded meanwhile. Set far
|
|
111
|
+
// above the live catalog, so it truncates nothing today.
|
|
112
|
+
const PUBLIC_FEED_MAX_PROJECTS = 500;
|
|
113
|
+
|
|
114
|
+
// Every project ON THE MAP, in the public projection (no owner_github_id, no status).
|
|
115
|
+
// `visibility` is projected because feedShapeFor() derives each row's state + orb from it;
|
|
116
|
+
// it is spent there and never reaches the wire. created_at is the LISTED date — when the
|
|
117
|
+
// hub first catalogued the row, not when the project began (task 1002778). updated_at may
|
|
118
|
+
// sort but never projects: feature toggles and client re-registration bump it, so as a
|
|
119
|
+
// public "last updated" it would lie. `description` is the publish BAR, spent in the
|
|
120
|
+
// predicate, never a card field.
|
|
121
|
+
async function listPublicProjects({ pool = defaultPool } = {}) {
|
|
122
|
+
const { rows } = await pool.query(
|
|
123
|
+
`SELECT origin, name, tagline, art_url, type, template, created_at, visibility
|
|
124
|
+
FROM platform_identity_projects
|
|
125
|
+
WHERE status = 'active' AND ${publishableSql()}
|
|
126
|
+
ORDER BY featured DESC, updated_at DESC
|
|
127
|
+
LIMIT ${PUBLIC_FEED_MAX_PROJECTS}`,
|
|
128
|
+
);
|
|
129
|
+
return rows;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = {
|
|
133
|
+
ORB_BY_STATE,
|
|
134
|
+
FALLBACK_PROJECT_STATE,
|
|
135
|
+
STEALTH_ANONYMOUS_KEEPS,
|
|
136
|
+
orbStateOf,
|
|
137
|
+
orbForState,
|
|
138
|
+
isStealthProject,
|
|
139
|
+
feedShapeFor,
|
|
140
|
+
PUBLIC_FEED_MAX_PROJECTS,
|
|
141
|
+
listPublicProjects,
|
|
142
|
+
};
|