@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.
Files changed (42) hide show
  1. package/.bongos-core.json +77 -42
  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/0182-project-visibility-two-axes.md +2 -0
  8. package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +41 -4
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +36 -35
  13. package/docs/copy-registry.json +93 -74
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  16. package/modules/hall-ui/public/hall-kit.js +309 -0
  17. package/modules/hall-ui/public/hall-render.js +10 -38
  18. package/modules/hall-ui/public/profile-charts.js +58 -65
  19. package/modules/hall-ui/public/profile.css +9 -18
  20. package/modules/hall-ui/public/style.css +101 -15
  21. package/modules/hall-ui/public/watch.css +6 -14
  22. package/modules/hall-ui/public/watch.html +1 -1
  23. package/modules/hall-ui/public/watch.js +139 -54
  24. package/modules/platform-identity/platform-identity.js +24 -20
  25. package/modules/platform-identity/projects-feed.js +142 -0
  26. package/modules/platform-identity/routes/projects.js +27 -8
  27. package/modules/platform-identity/tests/platform-identity.mjs +9 -5
  28. package/modules/provisioning/recommendations.js +260 -0
  29. package/modules/provisioning/routes/provisioning.js +29 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/src/module-api.js +1 -1
  33. package/tests/hall_audit.mjs +84 -0
  34. package/tests/hall_kit_charts.mjs +328 -0
  35. package/tests/hall_profile_world.mjs +27 -6
  36. package/tests/helpers.mjs +161 -0
  37. package/tests/joinability_invite_only.mjs +10 -2
  38. package/tests/projects_hub.mjs +5 -1
  39. package/tests/provisioning_recommendations.mjs +448 -0
  40. package/tests/task_detail_ui.mjs +1 -153
  41. package/tests/visible_projects_feed.mjs +320 -0
  42. 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
 
@@ -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 Explore listing — featured + active projects only, public projection
364
- // (no owner_github_id, no status). Newest first. created_at is the LISTED date —
365
- // when the hub first catalogued the row, not when the project began — and the
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 — the
783
- // exact set listFeaturedProjects publishes; goal 1000046's visibility states
784
- // will widen this predicate when they land). Everything else is counted in
785
- // more_projects, never named ("the aggregate is not the leak, the name is").
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
- listFeaturedProjects,
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
+ };