@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.
- package/.bongos-core.json +121 -56
- package/clients/bongos-client/index.d.ts +1 -1
- package/config/branding.neutral.json +3 -2
- package/docs/adr/0245-the-way-back-into-an-optional-step-is-that-step-s-own-answer-route.md +144 -0
- package/docs/adr/0246-catalog-membership-is-a-projection-not-a-federation-side-effect.md +96 -0
- package/docs/adr/0247-the-join-door-is-one-composed-answer-and-dark-matter-has-no-live-verdict-yet.md +202 -0
- package/docs/adr/README.md +4 -0
- package/docs/api/openapi.json +8 -1
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +162 -157
- package/docs/copy-registry.json +252 -196
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +16 -0
- package/modules/hall-ui/public/hall-kit.js +93 -7
- package/modules/hall-ui/public/roadmap-timeline.js +105 -0
- package/modules/hall-ui/public/roadmap.html +7 -1
- package/modules/hall-ui/public/roadmap.js +16 -0
- package/modules/hall-ui/public/style.css +4 -2
- package/modules/hall-ui/public/watch.html +4 -0
- package/modules/hall-ui/public/watch.js +116 -0
- package/modules/onboarding/module.json +1 -1
- package/modules/onboarding/routes/access-requests.js +21 -6
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-catalog.js +202 -0
- package/modules/platform-identity/routes/my-projects.js +7 -2
- package/modules/platform-identity/routes/projects.js +6 -0
- package/modules/platform-identity/routes/sso.js +10 -0
- package/modules/provisioning/catalog-bridge.js +105 -0
- package/modules/provisioning/provisioning.js +28 -2
- package/modules/provisioning/routes/provisioning.js +45 -11
- package/modules/provisioning/tests/provisioning.mjs +11 -8
- package/modules/public-landing/public/assets/cosmos.css +15 -0
- package/modules/public-landing/public/projects.html +162 -3
- package/modules/ui-design/kit/serve.js +1 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +9 -0
- package/scripts/gds/provision-net.js +7 -0
- package/scripts/hall-preview/refresh-fixtures.js +6 -0
- package/src/bongos/project-door.js +138 -0
- package/src/branding.js +8 -0
- package/src/module-api.js +12 -1
- package/tests/access_requests_invite.mjs +2 -2
- package/tests/application_lifecycle.mjs +1 -0
- package/tests/catalog_only_client.mjs +242 -0
- package/tests/currency_label.mjs +8 -5
- package/tests/hall_kit_charts.mjs +173 -0
- package/tests/hall_oversight_world.mjs +159 -0
- package/tests/hall_reading_world.mjs +153 -0
- package/tests/module_api.mjs +1 -0
- package/tests/project_catalog_bridge.mjs +355 -0
- package/tests/project_catalog_routes.mjs +213 -0
- package/tests/project_join_door.mjs +166 -0
- package/tests/project_join_door_route.mjs +206 -0
- package/tests/project_modules_ui.mjs +318 -0
- package/tests/projects_hub.mjs +6 -1
- package/tests/projects_hub_pre_uat.mjs +2 -2
- package/tests/provision_settings_apply.mjs +6 -5
- package/tests/provisioning_module_selection.mjs +164 -3
- package/tests/provisioning_settings.mjs +10 -7
- package/tests/provisioning_settings_apply.mjs +3 -3
- 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
|
-
|
|
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="
|
|
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;
|
|
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
|
|
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 /
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
// the
|
|
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.
|
|
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
|
}
|