@bongos/core 1.19.533 → 1.19.535

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 (62) hide show
  1. package/.bongos-core.json +121 -56
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/config/branding.neutral.json +3 -2
  4. package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
  5. package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
  6. package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
  7. package/docs/adr/README.md +4 -0
  8. package/docs/api/openapi.json +8 -1
  9. package/docs/api-reference.md +1 -1
  10. package/docs/copy-inventory.md +162 -157
  11. package/docs/copy-registry.json +252 -196
  12. package/docs/file-map.md +1 -0
  13. package/docs/module-api-changelog.md +16 -0
  14. package/modules/hall-ui/public/hall-kit.js +93 -7
  15. package/modules/hall-ui/public/roadmap-timeline.js +105 -0
  16. package/modules/hall-ui/public/roadmap.html +7 -1
  17. package/modules/hall-ui/public/roadmap.js +16 -0
  18. package/modules/hall-ui/public/style.css +4 -2
  19. package/modules/hall-ui/public/watch.html +4 -0
  20. package/modules/hall-ui/public/watch.js +116 -0
  21. package/modules/onboarding/module.json +1 -1
  22. package/modules/onboarding/routes/access-requests.js +21 -6
  23. package/modules/platform-identity/module.json +1 -1
  24. package/modules/platform-identity/project-catalog.js +202 -0
  25. package/modules/platform-identity/routes/my-projects.js +7 -2
  26. package/modules/platform-identity/routes/projects.js +6 -0
  27. package/modules/platform-identity/routes/sso.js +10 -0
  28. package/modules/provisioning/catalog-bridge.js +105 -0
  29. package/modules/provisioning/provisioning.js +28 -2
  30. package/modules/provisioning/routes/provisioning.js +45 -11
  31. package/modules/provisioning/tests/provisioning.mjs +11 -8
  32. package/modules/public-landing/public/assets/cosmos.css +15 -0
  33. package/modules/public-landing/public/projects.html +162 -3
  34. package/modules/ui-design/kit/serve.js +1 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/fitness.js +9 -0
  38. package/scripts/gds/provision-net.js +7 -0
  39. package/scripts/hall-preview/refresh-fixtures.js +6 -0
  40. package/src/bongos/project-door.js +138 -0
  41. package/src/branding.js +8 -0
  42. package/src/module-api.js +12 -1
  43. package/tests/access_requests_invite.mjs +2 -2
  44. package/tests/application_lifecycle.mjs +1 -0
  45. package/tests/catalog_only_client.mjs +242 -0
  46. package/tests/currency_label.mjs +8 -5
  47. package/tests/hall_kit_charts.mjs +173 -0
  48. package/tests/hall_oversight_world.mjs +159 -0
  49. package/tests/hall_reading_world.mjs +153 -0
  50. package/tests/module_api.mjs +1 -0
  51. package/tests/project_catalog_bridge.mjs +355 -0
  52. package/tests/project_catalog_routes.mjs +213 -0
  53. package/tests/project_join_door.mjs +166 -0
  54. package/tests/project_join_door_route.mjs +206 -0
  55. package/tests/project_modules_ui.mjs +318 -0
  56. package/tests/projects_hub.mjs +6 -1
  57. package/tests/projects_hub_pre_uat.mjs +2 -2
  58. package/tests/provision_settings_apply.mjs +6 -5
  59. package/tests/provisioning_module_selection.mjs +164 -3
  60. package/tests/provisioning_settings.mjs +10 -7
  61. package/tests/provisioning_settings_apply.mjs +3 -3
  62. package/tests/provisioning_settings_env.mjs +7 -4
package/docs/file-map.md CHANGED
@@ -245,6 +245,7 @@
245
245
  │ │ ├── secret-box.js / sensitive-surfaces.js ← at-rest secret encryption + the sensitive-file classifier
246
246
  │ │ ├── cascade-dispatch.js ← (dormant System 3, ADR 0079) the model-tier escalation ladder for autonomous dispatch
247
247
  │ │ ├── goal-scope-check.js / hierarchy-config.js / module-scope-map.js ← goal-scope protected-path checks, the configurable work-hierarchy tiers, and the module→file-glob map
248
+ │ │ ├── project-door.js ← the project's COMPOSED join/apply door — visibility × join_grant × joinability → open | apply | invite_only | locked (BV1.R12 / task 1002331, ADR 0182 D6 + ADR 0245). PURE + kernel; published as api.projectJoinDoor() so the public POST /access-requests, the hall landing form, the CLI and the hub's join relay cannot answer one stranger differently
248
249
  │ │ ├── path-match.js / repo-info.js / metrics.js / uptime-poller.js / app-pair.js ← touches[] overlap matcher, git-remote parser, Prometheus metrics endpoint, uptime poller, Claude-Desktop pairing store
249
250
  │ └── world/
250
251
  │ ├── terrain.js ← (Phase D + ADR 0012, CJS): TERRAIN + OBJECT enums + BLOCKING_TERRAINS + BLOCKING_OBJECTS (server twin of public/game/world/terrain.js)
@@ -1503,5 +1503,21 @@ is load-bearing: the script throws rather than guess if it is missing, and
1503
1503
  landed since 1.19.531 with no explicit bump. run 33880032943. (task 1002620)
1504
1504
  1.19.533 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1505
1505
  landed since 1.19.532 with no explicit bump. run 33881330388. (task 1002620)
1506
+ (next carrier) — projectJoinDoor re-exported (task 1002331, goal 1000046; ADR
1507
+ 0182 D6, ADR 0247). joinabilityMode above answers ONE knob; a project's
1508
+ join/apply door is the composition of three — where it stands on the
1509
+ platform's map (visibility), what a public join grants (joinGrant), and how
1510
+ someone becomes a member (joinability) — and ADR 0182 D6's rule is that the
1511
+ stricter always wins and none may widen another. That is only true if ONE
1512
+ function says so, so the composed answer moves onto the doorway beside
1513
+ joinabilityMode, and the onboarding module's public POST /access-requests
1514
+ calls it instead of reading a single knob. Additive — a MINOR by the 1.19.0
1515
+ precedent, but a hand-bumped version cannot pass the publish lane's LIVE
1516
+ test (tests/bump_version.mjs), so it rides the next CI patch, exactly as
1517
+ joinabilityMode did at 1.19.374.
1518
+ 1.19.534 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1519
+ landed since 1.19.533 with no explicit bump. run 33885355024. (task 1002620)
1520
+ 1.19.535 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1521
+ landed since 1.19.534 with no explicit bump. run 33886686350. (task 1002620)
1506
1522
  ---------------------------------------------------------------------------
1507
1523
  ```
@@ -737,13 +737,25 @@
737
737
  + '</svg>';
738
738
  }
739
739
 
740
- // Horizontal labelled bars. rows: [{ label, value, valueLabel }].
740
+ // Horizontal labelled bars. rows: [{ label, value, start, valueLabel }].
741
741
  // opts: { max, tone: 'accent'|'neutral', labelWidth, valueWidth, barHeight,
742
742
  // pill, ariaLabel, list }
743
743
  //
744
744
  // `max` is the caller's when several series must share one scale; otherwise
745
745
  // the largest value here. It floors at 1 so an all-zero set draws empty
746
746
  // tracks instead of dividing by zero.
747
+ //
748
+ // A row's optional `start` OFFSETS its bar instead of beginning it at the
749
+ // track's left edge, which turns the same primitive into a SPAN series — a
750
+ // timeline. It arrived with the roadmap's versions (task 1003447): each one
751
+ // holds a `started_at` and a `shipped_at`, and the series those two dates
752
+ // support is "when did each version run", every bar on one shared axis. That
753
+ // is a bar with a left offset and nothing else, so it is an OPTION here
754
+ // rather than a second class family — the same seam every other difference
755
+ // between call sites rides. `start` is in the SAME units as `value` and
756
+ // measured from the axis origin, so the kit still never learns what a date
757
+ // is: a caller passes `max` as the axis span and `start` as the offset into
758
+ // it, exactly as it already passes x0/x1/yMax to the plot.
747
759
  function barSeriesHtml(rows, opts = {}) {
748
760
  const list = (rows || []).filter((r) => r && r.label !== undefined && r.label !== null);
749
761
  if (!list.length) return '';
@@ -775,14 +787,34 @@
775
787
  + (opts.ariaLabel ? ` aria-label="${esc(opts.ariaLabel)}"` : '')
776
788
  + '>'
777
789
  + list.map((r, i) => {
778
- const w = Math.round((vals[i] / max) * 100);
790
+ // A POSITIVE value always draws at least a sliver. Rounding alone sends
791
+ // anything under half a percent of the max to `width:0%`, which reads as
792
+ // "none" — the one thing it is not. Zero stays zero: an empty track is
793
+ // the honest picture of nothing, and the column series makes the
794
+ // opposite choice for the opposite reason (a zero bucket there is a week
795
+ // that happened). Load-bearing for a span: a version that started and
796
+ // shipped inside one day is a real span half a pixel wide.
797
+ const raw = Math.round((vals[i] / max) * 100);
798
+ const w = vals[i] > 0 ? Math.max(1, raw) : raw;
799
+ // The offset takes the SAME floor, and for the same reason: a span that
800
+ // begins just after the axis origin but rounds to 0% draws flush against
801
+ // the track edge, which is the picture of "started first" — and on a
802
+ // timeline that is a claim about ordering, not a rounding artefact.
803
+ const start = Number(r.start);
804
+ const rawOff = Number.isFinite(start) && start > 0 ? Math.round((start / max) * 100) : 0;
805
+ const off = Number.isFinite(start) && start > 0 ? Math.max(1, rawOff) : 0;
806
+ // Offset by MARGIN, not `left`: the fill is an ordinary block inside the
807
+ // track, so absolute positioning would need a positioned ancestor the
808
+ // track does not declare. Omitted entirely at zero, so every existing
809
+ // caller's markup is byte-identical to what it was.
810
+ const geom = off > 0 ? `margin-left:${off}%;width:${w}%` : `width:${w}%`;
779
811
  const val = r.valueLabel === undefined || r.valueLabel === null
780
812
  ? String(vals[i])
781
813
  : String(r.valueLabel);
782
814
  return `<div class="kit-bars__row"${rowRole}>`
783
815
  + `<span class="kit-bars__label">${esc(r.label)}</span>`
784
816
  + `<span class="kit-bars__track" aria-hidden="true">`
785
- + `<span class="kit-bars__fill" style="width:${w}%"></span></span>`
817
+ + `<span class="kit-bars__fill" style="${geom}"></span></span>`
786
818
  + `<span class="kit-bars__val">${esc(val)}</span>`
787
819
  + '</div>';
788
820
  }).join('')
@@ -818,10 +850,60 @@
818
850
  return { width, height, pad, base: height - pad.b };
819
851
  }
820
852
 
853
+ // The VERTICAL window a series should be drawn in — the companion to
854
+ // timeStepGeometry's yMin/yMax, and geometry rather than date semantics, which
855
+ // is why it lives here rather than at the call site (task 1003447).
856
+ //
857
+ // `zeroBased` (the default) is right whenever zero is a state the series can
858
+ // actually reach: a count of dead exports sitting at zero for a week is the
859
+ // reading, and lifting the floor off it would invent a change. Pass false for
860
+ // a LEVEL — repo-health's total_loc — where zero is unreachable and a zero
861
+ // floor squashes a month of real movement into a flat band across the top.
862
+ // A fitted floor magnifies every wiggle, so a caller that passes false owes
863
+ // the reader the range in words somewhere on the page.
864
+ //
865
+ // `air` is the fraction of the range left as breathing room (12% by default)
866
+ // so the line neither rides the top edge nor welds itself to the axis. A FLAT
867
+ // series has no range to take a fraction of, so the air falls back to the
868
+ // value's own magnitude — and, for a series flat at zero, to 1, which leaves
869
+ // it drawn honestly along the axis.
870
+ //
871
+ // A MISSING entry is dropped, not read as zero: a null from a snapshot that
872
+ // predates a column would otherwise drag `lo` to zero and draw a cliff nothing
873
+ // recorded. Note this is the one place in the kit that must NOT go through
874
+ // `chartNums` — that maps a null to 0 on purpose, because a gap-filled DAILY
875
+ // feed really does mean "nothing happened that day". Here a null means "not
876
+ // measured", and the two are opposite readings of the same byte. `Number(null)`
877
+ // and `Number('')` are both a finite 0, so the emptiness has to be tested for
878
+ // before the coercion, never after it.
879
+ function plotWindow(values, opts = {}) {
880
+ const nums = (values || [])
881
+ .filter((v) => v !== null && v !== undefined && v !== '')
882
+ .map(Number)
883
+ .filter((n) => Number.isFinite(n));
884
+ if (!nums.length) return { lo: 0, hi: 0, yMin: 0, yMax: 1, n: 0 };
885
+ const lo = Math.min(...nums);
886
+ const hi = Math.max(...nums);
887
+ const frac = Number.isFinite(Number(opts.air)) ? Number(opts.air) : 0.12;
888
+ const air = ((hi - lo) || Math.abs(hi) || 1) * frac;
889
+ const zeroBased = opts.zeroBased !== false;
890
+ return { lo, hi, yMin: zeroBased ? 0 : lo - air, yMax: hi + air, n: nums.length };
891
+ }
892
+
821
893
  // A STEP line over a time axis: the value holds until the next recorded
822
894
  // point, then rises — which is what a cumulative "how much by now" series
823
895
  // means, and why this is not a straight polyline between samples.
824
896
  // points: [{ x, value }] in data space, sorted. Returns { d, area, xs }.
897
+ //
898
+ // `yMin` (default 0) is the value the AXIS sits at, and it exists because the
899
+ // second kind of series this plot draws is not cumulative. A cumulative line
900
+ // starts at nothing and every reading is "how much by now", so zero is the
901
+ // only honest floor. A LEVEL — repo-health's total_loc, task 1003447 — is a
902
+ // large number that moves a little: on a zero floor its whole month of change
903
+ // is a flat band across the top, and the chart says nothing the headline
904
+ // figure did not. A caller that fits the floor to the data MUST say so on the
905
+ // page (watch.js puts the range in the figure's foot), because a non-zero
906
+ // baseline silently multiplies how big every wiggle looks.
825
907
  function timeStepGeometry(points, opts = {}) {
826
908
  const { width, height, pad, base } = plotBox(opts);
827
909
  const list = points || [];
@@ -829,11 +911,14 @@
829
911
  const x0 = Number(opts.x0 ?? list[0].x);
830
912
  const x1 = Number(opts.x1 ?? list[list.length - 1].x);
831
913
  const last = list[list.length - 1];
832
- // A span of 0 (one point, no window) would divide by zero; yMax 0 likewise.
914
+ // A span of 0 (one point, no window) would divide by zero; a flat series
915
+ // whose yMin and yMax are equal likewise.
833
916
  const spanX = (x1 - x0) || 1;
834
- const yMax = Number(opts.yMax ?? last.value) || 1;
917
+ const yMin = Number.isFinite(Number(opts.yMin)) ? Number(opts.yMin) : 0;
918
+ const yMax = Number(opts.yMax ?? last.value);
919
+ const spanY = ((Number.isFinite(yMax) ? yMax : 0) - yMin) || 1;
835
920
  const px = (t) => pad.l + ((t - x0) / spanX) * (width - pad.l - pad.r);
836
- const py = (v) => height - pad.b - (v / yMax) * (height - pad.t - pad.b);
921
+ const py = (v) => height - pad.b - ((v - yMin) / spanY) * (height - pad.t - pad.b);
837
922
  let d = `M${px(list[0].x).toFixed(1)},${py(list[0].value).toFixed(1)}`;
838
923
  list.forEach((p, i) => {
839
924
  if (i === 0) return;
@@ -961,7 +1046,8 @@
961
1046
  plotSvgHtml, plotValueLabelHtml, timeStepSeriesHtml, timeStepGeometry, columnSeriesHtml,
962
1047
  // plotBox is exported so a caller mapping pointer x back into viewBox
963
1048
  // space reads the plot's width instead of re-declaring the number.
964
- plotBox,
1049
+ // plotWindow fits the vertical axis to a level series (task 1003447).
1050
+ plotBox, plotWindow,
965
1051
  // states
966
1052
  emptyStateHtml, skeletonRows, collapsibleCard,
967
1053
  // ship-time task visual (task 1003109)
@@ -0,0 +1,105 @@
1
+ // roadmap-timeline.js — the program in time (task 1003447, criterion
2
+ // `legibility-series-are-drawn`).
3
+ //
4
+ // /public/versions has always carried `started_at` and `shipped_at` per version,
5
+ // and /roadmap drew NEITHER: both dates reached the browser and came out as
6
+ // "Sep 2026" on a shipped pill. The series two dates support is a SPAN — when
7
+ // each version ran — and every span on ONE shared axis is the reading the page
8
+ // exists for: whether the build is speeding up, and how much of the in-flight
9
+ // version's run is already behind it.
10
+ //
11
+ // Its own file rather than another sixty lines of roadmap.js, for the reason
12
+ // profile-charts.js is its own file: the builders are PURE, so a test imports
13
+ // this and runs the real arithmetic over the real fixture instead of agreeing
14
+ // with a copy of it. Exposed as window.OTBRoadmapTimeline; node-require-safe
15
+ // (the hall-kit.js / dom-utils.js pattern — assignment is the only eval-time
16
+ // side effect, and only when a window exists).
17
+ //
18
+ // The DRAWING is the kit's: OTBKit.barSeriesHtml with a per-row `start`, the
19
+ // span option added for exactly this. Date arithmetic stays here — the kit takes
20
+ // `max` as the axis span and `start` as an offset into it, and never learns what
21
+ // a month is. That is the same seam every other chart in the hall rides.
22
+ (() => {
23
+ 'use strict';
24
+
25
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
26
+
27
+ // "May ’26" — short enough that a whole span label fits the value column at
28
+ // phone width, which "May 2026 – September 2026" does not.
29
+ function ymShort(t) {
30
+ const d = new Date(t);
31
+ return Number.isNaN(d.getTime()) ? '' : `${MON[d.getMonth()]} ’${String(d.getFullYear()).slice(-2)}`;
32
+ }
33
+
34
+ // One row per version that actually started, oldest first.
35
+ //
36
+ // A version with no parsable `start` is NOT on the timeline, because it has
37
+ // not happened. Defaulting it to the axis origin would draw it as having begun
38
+ // at the start of the project, which is the opposite of true; the caller says
39
+ // how many were left out instead.
40
+ //
41
+ // `open` is a version still running (or parked mid-run): its span reaches
42
+ // today and its label says "now", rather than naming an end date nothing
43
+ // recorded. `to` is floored at `from` so a clock skew or a bad shipped_at
44
+ // cannot produce a negative span the kit would draw backwards.
45
+ function timelineRows(all, now) {
46
+ return (all || [])
47
+ .map((v) => {
48
+ const from = Date.parse(v && v.start);
49
+ if (!Number.isFinite(from)) return null;
50
+ const shipped = Date.parse(v.ship);
51
+ const open = !Number.isFinite(shipped);
52
+ return { v, from, to: Math.max(open ? now : shipped, from), open };
53
+ })
54
+ .filter(Boolean)
55
+ .sort((a, b) => a.from - b.from);
56
+ }
57
+
58
+ // The chart card: the spans, and the foot that says what the axis covers and
59
+ // what is missing from it. Returns { count, html }, or NULL when there is
60
+ // nothing worth drawing — so the caller renders no section at all rather than
61
+ // an empty panel. One return shape either way, never a string sometimes and an
62
+ // object other times.
63
+ //
64
+ // `kit` is a parameter (defaulting to window.OTBKit) so this module is pure
65
+ // enough to import in node and run against the real kit in a test.
66
+ function timelineCardHtml(all, now, kit) {
67
+ const k = kit || (typeof window !== 'undefined' ? window.OTBKit : null);
68
+ if (!k) return null;
69
+ const rows = timelineRows(all, now);
70
+ // One version is not a timeline: a single bar filling the whole axis says
71
+ // nothing that version's own card does not already say.
72
+ if (rows.length < 2) return null;
73
+
74
+ const t0 = rows[0].from;
75
+ const t1 = Math.max(now, ...rows.map((r) => r.to));
76
+ const span = (t1 - t0) || 1;
77
+ const bars = k.barSeriesHtml(
78
+ rows.map((r) => ({
79
+ label: r.v.id,
80
+ value: r.to - r.from,
81
+ start: r.from - t0,
82
+ valueLabel: `${ymShort(r.from)} → ${r.open ? 'now' : ymShort(r.to)}`,
83
+ })),
84
+ {
85
+ max: span,
86
+ list: true,
87
+ ariaLabel: 'When each version ran',
88
+ labelWidth: '92px',
89
+ valueWidth: '108px',
90
+ barHeight: '10px',
91
+ pill: true,
92
+ },
93
+ );
94
+ const missing = (all || []).length - rows.length;
95
+ const foot = `${ymShort(t0)} to today, one bar per version.`
96
+ + (missing > 0
97
+ ? ` ${missing} version${missing === 1 ? '' : 's'} with no start date ${missing === 1 ? 'is' : 'are'} not shown.`
98
+ : '');
99
+ return { count: rows.length, html: k.chartCardHtml('When each version ran', bars, foot) };
100
+ }
101
+
102
+ const api = { timelineRows, timelineCardHtml, ymShort };
103
+ if (typeof window !== 'undefined') window.OTBRoadmapTimeline = api;
104
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
105
+ })();
@@ -45,6 +45,12 @@
45
45
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
46
46
  <script src="/builders/palette.js" defer></script>
47
47
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
48
- <script src="/builders/roadmap.js?v=2026-07-24-deck1"></script>
48
+ <!-- The chart kit and the timeline's series builders (task 1003447). Both MUST
49
+ precede roadmap.js: the page reads window.OTBKit / window.OTBRoadmapTimeline
50
+ at render time, so loading either after would be a blank section in
51
+ production and a green unit suite. tests/hall_kit_charts.mjs pins the order. -->
52
+ <script src="/builders/hall-kit.js"></script>
53
+ <script src="/builders/roadmap-timeline.js"></script>
54
+ <script src="/builders/roadmap.js?v=2026-09-03-timeline"></script>
49
55
  </body>
50
56
  </html>
@@ -241,6 +241,18 @@
241
241
  </article>`;
242
242
  }
243
243
 
244
+ // The program in time (task 1003447). The SERIES is roadmap-timeline.js's —
245
+ // pure builders in their own file so a test runs the real arithmetic over the
246
+ // real fixture — and the SECTION chrome is this page's, which is the same seam
247
+ // the chart kit keeps everywhere: the module draws, the page frames. Missing
248
+ // module or fewer than two started versions renders nothing at all, so the
249
+ // deck reads exactly as it did before rather than growing an empty panel.
250
+ function timelineHtml(all, now) {
251
+ const mod = window.OTBRoadmapTimeline;
252
+ const card = mod ? mod.timelineCardHtml(all, now) : null;
253
+ return card ? sectionHtml('flight', 'In time', card.count, '', card.html) : '';
254
+ }
255
+
244
256
  function sectionHtml(dotCls, title, count, note, inner) {
245
257
  return `<section class="rm-sec">
246
258
  <div class="rm-sec__h"><span class="rm-sec__dot rm-sec__dot--${dotCls}"></span><span class="rm-sec__t">${title}</span><span class="rm-sec__n">${count}</span><span class="rm-sec__rule"></span></div>
@@ -380,6 +392,10 @@
380
392
  html += `<div class="rm-hero"><div class="rm-lab" style="max-width:none">No version is in flight right now.</div></div>`;
381
393
  }
382
394
 
395
+ // Directly under the hero: both are program-level readings, and this is the
396
+ // one that answers "how fast" rather than "how much".
397
+ html += timelineHtml(all, Date.now());
398
+
383
399
  if (staged.length) {
384
400
  html += sectionHtml('staged', 'Staged', staged.length, 'Seeded with goals, not yet started.', `<div class="rm-grid">${staged.map(stagedCard).join('')}</div>`);
385
401
  }
@@ -2039,8 +2039,10 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
2039
2039
  reduced-motion kill switch (§the kill switch, at the end of this sheet)
2040
2040
  already zeroes this with !important — do NOT add a second @media block
2041
2041
  here, which is what `tests/hall_tokens.mjs` forbids and why the rule
2042
- cannot rot in two places. */
2043
- transition: width 200ms ease;
2042
+ cannot rot in two places. `margin-left` rides along because a SPAN bar
2043
+ (barSeriesHtml's per-row `start`) moves as well as grows, and a bar that
2044
+ slides while its neighbour eases reads as two different animations. */
2045
+ transition: width 200ms ease, margin-left 200ms ease;
2044
2046
  }
2045
2047
  .kit-bars__row:hover .kit-bars__fill { opacity: .8; }
2046
2048
  .kit-bars__val { text-align: right; color: var(--ink-faint); }
@@ -111,6 +111,10 @@
111
111
  <h2 id="ledger-h" class="scroll__h">Repo health</h2>
112
112
  <p class="scroll__lede">The shape and health of the codebase.</p>
113
113
  <div id="ledger-stats" class="ov-figs"><span class="kit-skel"></span></div>
114
+ <!-- The trend the request has always carried and the page dropped
115
+ on the floor (task 1003447) — the same shape as the sessions
116
+ fix above it. -->
117
+ <div id="ledger-trend"></div>
114
118
  <div id="ledger-loc"></div>
115
119
  <div id="ledger-files"></div>
116
120
  </section>
@@ -942,6 +942,120 @@
942
942
  .replace(/\b\w/g, (c) => c.toUpperCase());
943
943
  }
944
944
 
945
+ // The four series the snapshot table keeps, in the order a reader wants them:
946
+ // how big the codebase is, then the three kinds of rot it accumulates.
947
+ //
948
+ // `zeroBased` is the axis decision, made per metric because the metrics are
949
+ // not alike. A count of dead exports belongs on a zero floor: zero is a real,
950
+ // reachable state and the distance from it IS the reading. `total_loc` never
951
+ // goes near zero — on a zero floor a month of real movement is a flat band
952
+ // across the top of the box, which is a picture of nothing. That one fits its
953
+ // floor to the data, and `trendFigure` puts the range in the foot so the
954
+ // magnified wiggle is never silent about what it is.
955
+ const TREND_METRICS = [
956
+ {
957
+ key: 'total_loc', title: 'Total lines', fmt: fmtCompact, zeroBased: false,
958
+ foot: 'Every tracked line of the codebase, snapshot by snapshot.',
959
+ },
960
+ {
961
+ key: 'dead_exports', title: 'Dead exports', fmt: fmtInt, zeroBased: true,
962
+ foot: 'Exported symbols nothing imports — the knip scan, run daily.',
963
+ },
964
+ {
965
+ key: 'stale_branches', title: 'Stale branches', fmt: fmtInt, zeroBased: true,
966
+ foot: 'Branches with no movement inside the staleness window.',
967
+ },
968
+ {
969
+ key: 'confirmed_unshipped', title: 'Confirmed, unshipped', fmt: fmtInt, zeroBased: true,
970
+ foot: 'Work verified but not yet landed — the strand backlog.',
971
+ },
972
+ ];
973
+
974
+ // One repo-health metric as a figure: the step plot, the value it ends on, and
975
+ // the table twin that keeps it out of picture-only territory.
976
+ //
977
+ // A STEP line, not a polyline, and that is the honest shape here: a snapshot's
978
+ // value is what the repo measured that morning and it holds until the next run
979
+ // measures again. The scan does not run every single day, so a straight line
980
+ // between two samples a week apart would draw movement nobody recorded.
981
+ //
982
+ // No readout is wired, so the plot passes `interactive: false` and takes
983
+ // neither a focus stop nor a crosshair — the /drachmae contract. The reading
984
+ // is the aria-label, the labelled endpoint and the twin.
985
+ function trendFigure({ key, title, fmt, foot: footText, zeroBased }, trend) {
986
+ const kit = window.OTBKit;
987
+ // Only rows that actually carry this metric. An older snapshot predates a
988
+ // column and sends null; counting that as 0 would draw a cliff to the floor
989
+ // that never happened.
990
+ //
991
+ // NOT `Number(v)` — that is the trap, and the trap is silent: Number(null),
992
+ // Number(undefined ?? '') and Number('') are 0, 0 and 0, every one of them
993
+ // finite, so a plain isFinite filter keeps a missing reading as a real zero.
994
+ // The page's own `num()` coerces the same way for the same reason. Mapping
995
+ // an absent value to NaN is what makes the filter below mean "carried".
996
+ const carried = (v) => (v === null || v === undefined || v === '' ? NaN : Number(v));
997
+ const points = trend
998
+ .map((r) => ({ x: Date.parse(r.snapshot_date), value: carried(r[key]) }))
999
+ .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.value));
1000
+ // One sample is a reading, not a trend, and it draws a single dot on an
1001
+ // axis with nothing to compare it to.
1002
+ if (points.length < 2) return '';
1003
+
1004
+ const last = points[points.length - 1];
1005
+ // The axis window is the kit's arithmetic, not this page's.
1006
+ const { lo, hi, yMin, yMax } = kit.plotWindow(points.map((p) => p.value), { zeroBased });
1007
+
1008
+ const series = kit.timeStepSeriesHtml(points, {
1009
+ x0: points[0].x, x1: last.x, yMin, yMax,
1010
+ });
1011
+ const { endX, endY } = series.geometry;
1012
+ const day = (t) => new Date(t).toISOString().slice(0, 10);
1013
+ // At most ~21 rows however wide the window is (?days goes to 90), and the
1014
+ // last row is always present so the twin ends where the plot's label does.
1015
+ const step = Math.max(1, Math.ceil(points.length / 20));
1016
+ const twinRows = points.filter((p, i) => i % step === 0 || i === points.length - 1);
1017
+ const range = `${fmt(lo)} – ${fmt(hi)} across ${fmtInt(points.length)} snapshots.`;
1018
+ // A fitted floor multiplies how big every move looks, so the figure says so
1019
+ // in words rather than letting the picture imply a collapse to nothing.
1020
+ const axisNote = zeroBased ? '' : ` The axis starts at ${fmt(Math.round(yMin))}, not zero.`;
1021
+
1022
+ return `<figure class="pchart" id="ledger-trend-${escapeHtml(key)}">
1023
+ <figcaption class="pchart__title">${escapeHtml(title)}</figcaption>
1024
+ ${kit.plotSvgHtml({
1025
+ series: series.body,
1026
+ interactive: false,
1027
+ ariaLabel: `${title}: ${fmt(last.value)} on ${day(last.x)}, ranging ${fmt(lo)} to ${fmt(hi)} over the window.`,
1028
+ label: kit.plotValueLabelHtml(endX, endY - 8, fmt(last.value), 'end'),
1029
+ leftTick: day(points[0].x),
1030
+ rightTick: day(last.x),
1031
+ })}
1032
+ <details class="pchart__twin">
1033
+ <summary>Read it as a table</summary>
1034
+ <table class="ledger pchart__table"><thead><tr><th>Snapshot</th><th>${escapeHtml(title)}</th></tr></thead><tbody>
1035
+ ${twinRows.map((p) => `<tr><td>${escapeHtml(day(p.x))}</td><td>${escapeHtml(fmt(p.value))}</td></tr>`).join('')}
1036
+ </tbody></table>
1037
+ </details>
1038
+ <p class="pchart__foot">${escapeHtml(`${footText} ${range}${axisNote}`)}</p>
1039
+ </figure>`;
1040
+ }
1041
+
1042
+ // The trend the route has always sent and renderLedger discarded (task
1043
+ // 1003447). It is requested WITH ?days=30 — the page was paying for the window
1044
+ // and reading one snapshot off it.
1045
+ function renderTrend(data) {
1046
+ const el = $('#ledger-trend');
1047
+ if (!el) return;
1048
+ const trend = Array.isArray(data.trend) ? data.trend : [];
1049
+ const figures = TREND_METRICS.map((m) => trendFigure(m, trend)).filter(Boolean).join('');
1050
+ // Nothing plottable yet (a fresh instance, or one metrics run) renders
1051
+ // nothing at all — the figures above already say what today's numbers are,
1052
+ // and an empty panel under them would be worse than no panel.
1053
+ el.innerHTML = figures
1054
+ ? `<section class="ov-group"><div class="ov-group__head"><span class="ov-group__name">Over the last ${escapeHtml(String(data.window_days || 30))} days</span></div>`
1055
+ + `<div class="pcharts">${figures}</div></section>`
1056
+ : '';
1057
+ }
1058
+
945
1059
  function renderLedger(data, stats) {
946
1060
  const locEl = $('#ledger-loc');
947
1061
  const filesEl = $('#ledger-files');
@@ -962,6 +1076,8 @@
962
1076
  }
963
1077
  stats.innerHTML = figs(cards);
964
1078
 
1079
+ renderTrend(data);
1080
+
965
1081
  // loc_by_dir as a two-column ledger under its own head.
966
1082
  const locByDir = latest.loc_by_dir && typeof latest.loc_by_dir === 'object' ? latest.loc_by_dir : {};
967
1083
  const dirEntries = Object.entries(locByDir).sort((a, b) => num(b[1]) - num(a[1]));
@@ -2,7 +2,7 @@
2
2
  "key": "onboarding",
3
3
  "title": "Onboarding (admission · setup · newcomer floor)",
4
4
  "description": "The newcomer ADMISSION + ORIENTATION domain — the public access-request → Archon-approval gate, the server-ticked onboarding state machine (the 'Path into the City' checklist that graduates a Xenos to Thetes), the friction analyzer (feedback-without-asking on consented onboarding transcripts), and the evergreen newcomer-chore restock (a fresh first-builder task is always waiting). A core-domain module carved out of the GDS core in BV1.R81 (ADR 0093 §1); default-on because every instance onboards its own builders. The access-requests admission routes mount here; the core lifecycle (db.js claim/ship), auth callbacks, and the me/builders profile readers resolve the `onboarding` kernel PORT for the state-machine + restock surface they need — never importing the module.",
5
- "coreVersion": "^1.19.374",
5
+ "coreVersion": "^1.19.532",
6
6
  "default": true,
7
7
  "maintenance": { "status": "core-maintained" },
8
8
  "contributes": { "routes": ["onboarding"] },
@@ -26,10 +26,19 @@
26
26
  // username before any request exists:
27
27
  // the way in on an invite-only project.
28
28
  //
29
- // Which door a project runs — open / apply / invite_only — is the owner's
30
- // joinability setting, read through the doorway's joinabilityMode() (task
31
- // 1003044, ADR 0194): the same reader the sign-in gate uses, so this route and
32
- // the gate never disagree about whether a stranger may ask.
29
+ // Which door a project runs — open / apply / invite_only / locked — is the
30
+ // COMPOSITION of three owner-set knobs, read through the doorway's
31
+ // projectJoinDoor() (BV1.R12, task 1002331; ADR 0182 D6, ADR 0247): its place on
32
+ // the platform's map (`visibility`), what a public join grants (`joinGrant`), and
33
+ // how someone becomes a member (`joinability`, ADR 0194 — the same reader the
34
+ // sign-in gate uses). The stricter always wins and none of them can widen another,
35
+ // so this route and the gate never disagree about whether a stranger may ask.
36
+ //
37
+ // THE REFUSAL NEVER NAMES WHICH KNOB CLOSED THE DOOR. A stealth project, a public
38
+ // one whose join grants only a look, and an explicitly invite-only one all answer
39
+ // with the same sentence — otherwise the refusal is a read-out of a state ADR 0182
40
+ // D2a exists to keep anonymous. Nothing is stored on a refusal either, so a closed
41
+ // project accumulates no queue to leak (D6).
33
42
  //
34
43
  // The global IP rate-limiter (mounted in routes.js before every router) guards
35
44
  // the public POST against spam. A partial unique index on lower(github_login)
@@ -51,10 +60,16 @@ const approvalBroadcast = require('../approval-broadcast');
51
60
  const log = api.logger('onboarding');
52
61
 
53
62
  // The public refusal each door gives a would-be applicant — plain sentences,
54
- // shown as written by the hall, the CLI, and the hub's join relay.
63
+ // shown as written by the hall, the CLI, and the hub's join relay. `apply` is
64
+ // absent because it is the one door that does not refuse: it reaches the queue.
55
65
  const DOOR_REFUSAL = {
56
66
  invite_only: { code: 'invite_only', status: 403, message: 'This project is invite-only — it doesn’t take applications. If you’ve been invited, or already have an account, sign in with GitHub to enter.' },
57
67
  open: { code: 'open_enrollment', status: 409, message: 'This project is open — no request needed. Sign in with GitHub and you’re in.' },
68
+ // Dark matter (ADR 0182 D1/D4): the project has not published enough about
69
+ // itself to be on the platform's map, so there is no door to knock on yet. It
70
+ // says the project's own state, not the applicant's — nothing they can do fixes
71
+ // it, and a refusal that implied otherwise would send them back to retry.
72
+ locked: { code: 'not_published', status: 403, message: 'This project isn’t published yet, so it isn’t taking anyone on. Its owner has to finish setting it up first.' },
58
73
  };
59
74
 
60
75
  // GitHub usernames: 1–39 chars, alphanumeric or single hyphens, no leading/
@@ -74,7 +89,7 @@ module.exports = function buildAccessRequestsRouter() {
74
89
  router.post('/access-requests', async (req, res) => {
75
90
  // The door answers first — before validation and before any lookup — so a
76
91
  // refusal costs no query and reveals nothing about who already exists.
77
- const refusal = DOOR_REFUSAL[api.joinabilityMode()];
92
+ const refusal = DOOR_REFUSAL[api.projectJoinDoor()];
78
93
  if (refusal) {
79
94
  return res.fail(refusal.code, { status: refusal.status, message: refusal.message });
80
95
  }
@@ -14,6 +14,6 @@
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep"]
16
16
  },
17
- "provides": ["platform-identity.hub", "project.publishGate"],
17
+ "provides": ["platform-identity.hub", "project.publishGate", "project.catalog"],
18
18
  "consumes": []
19
19
  }