@bongos/core 1.19.850 → 1.19.852

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.
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1874,
7
- "unique_ids": 1627,
8
- "certain": 1874,
6
+ "strings": 1875,
7
+ "unique_ids": 1628,
8
+ "certain": 1875,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 171
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 89,
16
- "strings": 1384
16
+ "strings": 1385
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -2371,7 +2371,7 @@
2371
2371
  "surface": "builders-hall",
2372
2372
  "text": "Achievements could not be loaded.",
2373
2373
  "file": "modules/hall-ui/public/builders.js",
2374
- "line": 1158,
2374
+ "line": 1204,
2375
2375
  "origin": "js-markup",
2376
2376
  "confidence": "certain"
2377
2377
  },
@@ -2488,7 +2488,7 @@
2488
2488
  "surface": "builders-hall",
2489
2489
  "text": "All builders",
2490
2490
  "file": "modules/hall-ui/public/hall-render.js",
2491
- "line": 1145,
2491
+ "line": 1208,
2492
2492
  "origin": "js-markup",
2493
2493
  "confidence": "certain"
2494
2494
  },
@@ -2569,7 +2569,7 @@
2569
2569
  "surface": "builders-hall",
2570
2570
  "text": "Analytics scope",
2571
2571
  "file": "modules/hall-ui/public/hall-render.js",
2572
- "line": 1143,
2572
+ "line": 1206,
2573
2573
  "origin": "js-markup",
2574
2574
  "confidence": "certain"
2575
2575
  },
@@ -4792,7 +4792,7 @@
4792
4792
  "surface": "builders-hall",
4793
4793
  "text": "Downvote (−1)",
4794
4794
  "file": "modules/hall-ui/public/builders.js",
4795
- "line": 739,
4795
+ "line": 785,
4796
4796
  "origin": "js-markup",
4797
4797
  "confidence": "certain"
4798
4798
  },
@@ -5899,7 +5899,7 @@
5899
5899
  "surface": "builders-hall",
5900
5900
  "text": "Ideas awaiting triage",
5901
5901
  "file": "modules/hall-ui/public/builders.js",
5902
- "line": 859,
5902
+ "line": 905,
5903
5903
  "origin": "js-markup",
5904
5904
  "confidence": "certain"
5905
5905
  },
@@ -6227,12 +6227,21 @@
6227
6227
  "origin": "js-markup",
6228
6228
  "confidence": "certain"
6229
6229
  },
6230
+ {
6231
+ "id": "e3b473af2f69",
6232
+ "surface": "builders-hall",
6233
+ "text": "Kind of work",
6234
+ "file": "modules/hall-ui/public/hall-render.js",
6235
+ "line": 1173,
6236
+ "origin": "js-markup",
6237
+ "confidence": "certain"
6238
+ },
6230
6239
  {
6231
6240
  "id": "7d6eaa8d4514",
6232
6241
  "surface": "builders-hall",
6233
6242
  "text": "Kinds of work shipped",
6234
6243
  "file": "modules/hall-ui/public/hall-render.js",
6235
- "line": 1194,
6244
+ "line": 1286,
6236
6245
  "origin": "js-markup",
6237
6246
  "confidence": "certain"
6238
6247
  },
@@ -6691,7 +6700,7 @@
6691
6700
  "surface": "builders-hall",
6692
6701
  "text": "Metic rank and above may vote",
6693
6702
  "file": "modules/hall-ui/public/builders.js",
6694
- "line": 743,
6703
+ "line": 789,
6695
6704
  "origin": "js-markup",
6696
6705
  "confidence": "certain"
6697
6706
  },
@@ -10165,7 +10174,7 @@
10165
10174
  "surface": "builders-hall",
10166
10175
  "text": "The page could not be loaded. Try again.",
10167
10176
  "file": "modules/hall-ui/public/builders.js",
10168
- "line": 1425,
10177
+ "line": 1484,
10169
10178
  "origin": "js-notice",
10170
10179
  "confidence": "certain"
10171
10180
  },
@@ -10876,7 +10885,7 @@
10876
10885
  "surface": "builders-hall",
10877
10886
  "text": "Upvote (+1)",
10878
10887
  "file": "modules/hall-ui/public/builders.js",
10879
- "line": 739,
10888
+ "line": 785,
10880
10889
  "origin": "js-markup",
10881
10890
  "confidence": "certain"
10882
10891
  },
@@ -10957,7 +10966,7 @@
10957
10966
  "surface": "builders-hall",
10958
10967
  "text": "Vote on this work",
10959
10968
  "file": "modules/hall-ui/public/builders.js",
10960
- "line": 739,
10969
+ "line": 785,
10961
10970
  "origin": "js-markup",
10962
10971
  "confidence": "certain"
10963
10972
  },
@@ -12685,7 +12694,7 @@
12685
12694
  "surface": "builders-hall",
12686
12695
  "text": "no data",
12687
12696
  "file": "modules/hall-ui/public/hall-kit.js",
12688
- "line": 705,
12697
+ "line": 735,
12689
12698
  "origin": "js-markup",
12690
12699
  "confidence": "certain"
12691
12700
  },
@@ -13972,7 +13981,7 @@
13972
13981
  "surface": "builders-hall",
13973
13982
  "text": "— all builders —",
13974
13983
  "file": "modules/hall-ui/public/builders.js",
13975
- "line": 675,
13984
+ "line": 721,
13976
13985
  "origin": "js-markup",
13977
13986
  "confidence": "certain"
13978
13987
  },
@@ -14107,7 +14116,7 @@
14107
14116
  "surface": "builders-hall",
14108
14117
  "text": "…{…} more open ideas — triage clears them one at a time.",
14109
14118
  "file": "modules/hall-ui/public/builders.js",
14110
- "line": 857,
14119
+ "line": 903,
14111
14120
  "origin": "js-markup",
14112
14121
  "confidence": "certain"
14113
14122
  },
@@ -2174,5 +2174,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2174
2174
  landed since 1.19.848 with no explicit bump. run 35269098215. (task 1002620)
2175
2175
  1.19.850 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2176
2176
  landed since 1.19.849 with no explicit bump. run 35271402682. (task 1002620)
2177
+ 1.19.851 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2178
+ landed since 1.19.850 with no explicit bump. run 35274042996. (task 1002620)
2179
+ 1.19.852 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2180
+ landed since 1.19.851 with no explicit bump. run 35274827497. (task 1002620)
2177
2181
  ---------------------------------------------------------------------------
2178
2182
  ```
@@ -95,7 +95,7 @@ node scripts/gds/package-core.js --ref main --self-verify
95
95
 
96
96
  `package-core` archives the **committed** tree at `--ref`, never your working tree, and stamps the version from `CORE_VERSION` *at that ref*. Full mechanics — the fail-closed no-leak gate, the `tree_sha256` integrity pin, the pruned lockfile — in [packaging-the-core](packaging-the-core.md).
97
97
 
98
- > ⚠️ **Packaging a mid-flight `main` produces a stale version number.** If `CORE_VERSION` at your ref is still the previous value, your tarball is stamped with a version the fleet may already have. `bongos upgrade` refuses only the **equal** case (`already on <v>`, and `--force` bypasses even that) — it has **no downgrade guard**, so a target *below* what is installed proceeds and silently installs older code. Confirm the bump is actually in your ref before packaging.
98
+ > ⚠️ **Packaging a mid-flight `main` produces a stale version number.** If `CORE_VERSION` at your ref is still the previous value, your tarball is stamped with a version the fleet may already have. `bongos upgrade` refuses the **equal** case (`already on <v>`; `--force` bypasses that) and a target **below** what is installed or pinned (task [#1004009](https://cloudbongos.com/builders#/task/1004009); `--allow-downgrade` is the only way through — `--force` is not). That second refusal lives in the **installed** core, so an instance still on a core that predates it has no such guard: there a lower target proceeds and silently installs older code. Confirm the bump is actually in your ref before packaging.
99
99
 
100
100
  ### 2c. Publish — automatic on merge
101
101
 
@@ -246,12 +246,21 @@ git tag --list 'core-v*' --contains <your-merge-sha> | sort -V | head -1
246
246
 
247
247
  This paste is the **owner’s**: the permission classifier’s treatment of a mutating `sudo` over ssh **varies by session** — some sessions permit it, others block every ssh including read-only probes and `backup.js --trigger`. Don’t retry into a block; hand the owner the line. The `!` prefix also keeps ssh honest: a bare `ssh host` is non-interactive here — it connects, prints the MOTD, and exits — so the command must ride as an argument.
248
248
 
249
+ **Re-read `coreVersion` on `/version` immediately before running — a prepared `--to` goes stale within the hour.** Live moves by other paths (another session's bump, the subscription sweep), so the version you picked in Step 1 may already be behind what is serving:
250
+
251
+ ```
252
+ curl -s https://cloudbongos.com/version
253
+ ```
254
+
255
+ If live is **at or past** `<VER>`, stop and pick again. On 2026-09-17 a paste prepared against 1.19.838 ran an hour later against 1.19.841, and `--to 1.19.840` took two merged PRs off live while printing `✓ upgrade complete`. `bongos upgrade` now refuses a `--to` older than the installed core (task [#1004009](https://cloudbongos.com/builders#/task/1004009); `--allow-downgrade` is the deliberate way through) — but the refusal only exists once live is on a core that carries it, and it cannot catch a `--to` that is merely *not the newest*. The re-read is still the step.
256
+
249
257
  ```
250
- ! ssh -i ~/.ssh/otb_builder jaxri@REDACTED_IP "sudo -n bash -c 'set -a; . /etc/cloudbongos/npm.env; set +a; cd /home/lars/cloudbongos-instance && sudo -n -u lars --preserve-env=NPM_TOKEN env PGDATABASE=cloudbongos CLOUDBONGOS_INSTANCE_ROOT=/home/lars/cloudbongos-instance ./node_modules/.bin/bongos upgrade --to <VER> --registry --commit-pin --restart-cmd \"sudo systemctl restart cloudbongos.service\" --health-url http://127.0.0.1:3002/healthz'"
258
+ ! ssh -i ~/.ssh/otb_builder jaxri@REDACTED_IP "sudo -n bash -c 'set -a; . /etc/cloudbongos/npm.env; set +a; cd /home/lars/cloudbongos-instance && sudo -n -u lars --preserve-env=NPM_TOKEN env PGDATABASE=cloudbongos BONGOS_INSTANCE_ROOT=/home/lars/cloudbongos-instance ./node_modules/.bin/bongos upgrade --to <VER> --registry --commit-pin --restart-cmd \"sudo systemctl restart cloudbongos.service\" --health-url http://127.0.0.1:3002/healthz'"
251
259
  ```
252
260
 
253
261
  What each piece is for — each guards a failure that actually happened:
254
262
 
263
+ - **`BONGOS_INSTANCE_ROOT`, not `CLOUDBONGOS_INSTANCE_ROOT`.** The legacy spelling still works but prints a deprecation warning three times per run and stops being read in core 1.21; the new spelling is verified in this exact line (2026-09-17, twice).
255
264
  - **The token lives in root-only `/etc/cloudbongos/npm.env`, and is handed DOWN to `lars`.** This is the step that used to be documented wrongly, so read it before improvising. `NPM_TOKEN` is **not** in lars’s environment, **not** in the interactive profile, and **not** in `/home/lars/.npmrc` — all three probed empty (2026-08-17). An earlier version of this runbook grepped `~/.npmrc` and therefore aborted on its own `[ -n "$NPM_TOKEN" ]` guard every time. Worse, it could not have worked even if that file were populated: the project `.npmrc` says `_authToken=<redacted>, which **overrides** lars’s user `~/.npmrc`, so the variable is the only channel. Hence the shape above: `set -a; . /etc/cloudbongos/npm.env; set +a` reads it as **root** (mode 600), and `--preserve-env=NPM_TOKEN` hands it to the unprivileged `lars` — exactly what systemd does for the sweep unit (`User=lars` + `EnvironmentFile=`). Without it npm falls back to the **public** registry, 404s on the private `@bongos/core`, and rolls back cleanly having taught you nothing.
256
265
  - **Never put the token in the command line — `sudo` logs the full argv (task 1003218).** The shape above is safe *because* the token is read INSIDE the quoted script, so only the `grep` reaches argv. The tempting variant is not:
257
266
  ```
@@ -8,6 +8,7 @@
8
8
 
9
9
  `bongos upgrade --to <version> --from <tgz>` runs, from a consumer instance root:
10
10
 
11
+ 0. **downgrade guard** (task [#1004009](https://example.com/builders#/task/1004009)) — a `--to` **older** than this instance's core is refused before anything moves, in one line naming both versions and the newest published release. "This instance's core" is the **newer** of what is installed and what is pinned, because a half-applied bump leaves the two apart and the pin is the thing about to be overwritten. `--force` does not waive it; `--allow-downgrade` does, and the run then says `DOWNGRADE` in its first line, its last line and its `core_upgrades` ledger note. Migrations are never reverted, so going backwards across one leaves the DB ahead of the code; and an instance subscribed to an update channel is taken forward again by the next sweep, so set it to `pinned` first if the downgrade is meant to hold. Auto-rollback (step 8) restores its own pin and is unaffected.
11
12
  1. **pre-flight** — module-compat vs the target core + a clean git tree (skip with `--force`).
12
13
  2. **bump the pin** — vendor the new `bongos-core-<version>.tgz` and rewrite the `"@bongos/core": "file:vendor/…"` dependency.
13
14
  3. **`npm install`** — `node_modules/@bongos/core` becomes the new version.
@@ -214,7 +214,7 @@ caught a real bug during task 1003548, so read its assertion before blaming the
214
214
 
215
215
  - Several hall files (`people.css`, `people.html`, `style.css`, the privacy spec) and every `.claude/skills/*/SKILL.md` are CRLF; `scripts/gds/*.js` and the tests are LF. An exact-string patch must detect the file's EOL before joining lines (`text.includes('\r\n') ? '\r\n' : '\n'`), or the find matches nothing and the edit is a silent no-op (session log 2026-08-24).
216
216
  - A `git diff --stat` that touches every line of a file you barely edited is an EOL flip — check with `git diff --stat --ignore-cr-at-eol`, and restore the original EOL before committing.
217
- - **`copy-inventory.js --check` no longer reds on line endings alone** (task 1003619). It used to: the generator emits LF and the check byte-compared that against a CRLF worktree file, so `/ship-check` showed one permanent red line on every Windows checkout while the committed content was already correct — and a builder who "fixed" it committed a whole-file CRLF diff. `docs/copy-inventory.md` is now pinned `text eol=lf` in `.gitattributes` (its `.json` sibling always was, via `*.json`), and the check compares EOL-blind so an already-CRLF worktree passes without a re-clone. If it reports STALE now, the content really is stale — regenerate, don't reach for `--ignore-cr-at-eol`. Note `*.md` is deliberately NOT pinned repo-wide; only that one generated artifact is.
217
+ - **`copy-inventory.js --check` no longer reds on line endings alone** (task 1003619). It used to: the generator emits LF and the check byte-compared that against a CRLF worktree file, so `/ship-check` showed one permanent red line on every Windows checkout while the committed content was already correct — and a builder who "fixed" it committed a whole-file CRLF diff. `docs/copy-inventory.md` is now pinned `text eol=lf` in `.gitattributes` (its `.json` sibling always was, via `*.json`), and the check compares EOL-blind so an already-CRLF worktree passes without a re-clone. If it reports STALE now, the content really is stale — regenerate, don't reach for `--ignore-cr-at-eol`. Since task 1003981 `*.md` IS pinned repo-wide (`*.md text eol=lf`), so a fresh clone gets LF markdown everywhere and a markdown reader no longer has to be CRLF-aware to be correct. An EXISTING Windows checkout keeps its CRLF copies until git rewrites them — the clean filter still normalises to LF on add, so `git status` stays quiet and nothing is broken; to convert in place, `git rm --cached -r . && git reset --hard`.
218
218
  - **A freshness check compares CONTENT, so route it through `sameContent()`** (`scripts/gds/gen-freshness.js`) rather than `===` on file bytes. A pin is the wrong lever on its own: regenerating never clears a byte-wise red, because the write lands LF and git converts it back on the next checkout. `tests/gen_freshness.mjs` enforces this for the four gated generators; `tests/api_client.mjs` was outside that guard and reported the client STALE forever on Windows until task 1003529 (`index.d.ts` and `README.md` are the two generated client files with no `eol=lf` pin).
219
219
 
220
220
  ## Four Windows traps that make a test lie — all four found in one pass (task 1003529)
@@ -625,27 +625,73 @@
625
625
  const scroll = $('#analytics-scroll');
626
626
  if (!scroll || !builder?.id) return;
627
627
  analyticsState.builderId = builder.id;
628
- analyticsState.isArchon = (builder.rank || '').toLowerCase() === 'archon';
629
628
  analyticsState.scope = 'self';
629
+ analyticsState.kind = null;
630
630
  try {
631
631
  const self = await getJSON(`${API}/analytics/builder/${builder.id}?days=90`);
632
632
  analyticsState.self = self;
633
+ // The picker's options come from THIS first, unfiltered load and are then
634
+ // left alone — see kindFilterHtml. A later filtered payload's top_kinds is
635
+ // deliberately not allowed to overwrite them.
636
+ analyticsState.kinds = Array.isArray(self?.top_kinds) ? self.top_kinds : [];
633
637
  // A gated newcomer with nothing shipped: keep the section hidden.
634
638
  if (!self || (self.headline?.total_shipped || 0) === 0) { scroll.hidden = true; return; }
635
639
  scroll.hidden = false;
636
640
  renderCurveBody();
637
641
  renderNav(builder, lastMeState?.onboarding || null); // re-show the Curve chip now that the section is visible
638
- if (analyticsState.isArchon) {
639
- getJSON(`${API}/public/analytics/ecosystem?days=90`)
640
- .then((eco) => { analyticsState.eco = eco; if (analyticsState.scope === 'eco') renderCurveBody(); })
641
- .catch(() => { /* ecosystem optional */ });
642
- }
642
+ // THE BENCHMARK IS FOR EVERY BUILDER, NOT JUST THE ARCHON (task 1004005).
643
+ // This fetch used to sit behind `if (analyticsState.isArchon)`, so the
644
+ // ecosystem average a builder is meant to measure themselves against was
645
+ // fetched for exactly one of them. The route is `rank: public` — no auth
646
+ // middleware, CORS '*', per-builder identity stripped server-side — and the
647
+ // unauthenticated status page already reads it, so the gate protected
648
+ // nothing and only withheld the comparison. Still fire-and-forget: the
649
+ // ecosystem feed is optional context, never a reason to hold up the curve.
650
+ getJSON(`${API}/public/analytics/ecosystem?days=90`)
651
+ // ALWAYS re-render, not only in the 'eco' scope. That condition was
652
+ // correct while the two scopes merely SWAPPED which single series was
653
+ // drawn; now the ecosystem series is also the benchmark overlaid on the
654
+ // 'self' view, so a self-scoped reader who arrived before this resolved
655
+ // would sit with no overlay until they touched an unrelated control.
656
+ .then((eco) => { analyticsState.eco = eco; renderCurveBody(); })
657
+ .catch(() => { /* ecosystem optional */ });
643
658
  } catch (err) {
644
659
  console.error('[builders] analytics load failed', err);
645
660
  scroll.hidden = true;
646
661
  }
647
662
  }
648
663
 
664
+ // Re-fetch BOTH series under a kind filter (task 1004005). Both, always: an
665
+ // overlay that narrowed one side and not the other would compare one kind of
666
+ // work against all work and still look like a like-for-like benchmark — the
667
+ // failure this task's own text calls worse than no benchmark at all.
668
+ //
669
+ // `kinds` is deliberately not refreshed here; see kindFilterHtml.
670
+ let curveKindSeq = 0;
671
+ async function reloadCurveForKind(kind) {
672
+ const id = analyticsState.builderId;
673
+ if (!id) return;
674
+ analyticsState.kind = kind;
675
+ // A dropdown can be changed faster than a round trip returns, and the two
676
+ // fetches can land out of order. Only the newest request may write state.
677
+ const seq = ++curveKindSeq;
678
+ const q = kind ? `&kind=${encodeURIComponent(kind)}` : '';
679
+ renderCurveBody(); // reflect the new selection immediately
680
+ try {
681
+ const [self, eco] = await Promise.all([
682
+ getJSON(`${API}/analytics/builder/${id}?days=90${q}`),
683
+ getJSON(`${API}/public/analytics/ecosystem?days=90${q}`).catch(() => null),
684
+ ]);
685
+ if (seq !== curveKindSeq) return;
686
+ analyticsState.self = self;
687
+ if (eco) analyticsState.eco = eco;
688
+ renderCurveBody();
689
+ } catch (err) {
690
+ if (seq !== curveKindSeq) return;
691
+ console.error('[builders] curve kind filter failed', err);
692
+ }
693
+ }
694
+
649
695
  // ---- karma feed (V3.R26 #246) -------------------------------------------
650
696
  //
651
697
  // The recent karma_log entries for the current builder. The /me payload
@@ -1204,6 +1250,19 @@
1204
1250
  renderCurveBody();
1205
1251
  });
1206
1252
 
1253
+ // The kind filter (task 1004005). Delegated like the scope toggle, and for
1254
+ // the same reason: renderCurveBody replaces the control's innerHTML, so a
1255
+ // listener bound to the element itself would not survive the first render.
1256
+ // 'change', not 'input' — a keyboard user arrowing through a native select
1257
+ // fires input per option, and each one would be a pair of round trips.
1258
+ $('#curve-body')?.addEventListener('change', (e) => {
1259
+ const sel = e.target.closest('#curve-kind');
1260
+ if (!sel) return;
1261
+ const kind = sel.value ? sel.value : null;
1262
+ if (kind === analyticsState.kind) return;
1263
+ reloadCurveForKind(kind);
1264
+ });
1265
+
1207
1266
  // task 1723: the Home "show the setup guide again" link re-opens a dismissed
1208
1267
  // welcome banner (the old welcome chip's job) — clears the dismissal flag,
1209
1268
  // re-renders the panel, refreshes the nav, then scrolls to it.
@@ -614,6 +614,12 @@
614
614
  const CHART_W = 260;
615
615
  const CHART_H = 46;
616
616
  const CHART_PAD = 3;
617
+ // The benchmark line's colour, fixed here and NOT a caller option (task
618
+ // 1004005). --ink-soft is the hall's existing muted tone, so the overlay is
619
+ // recessive in both themes without a new token. Keeping it out of `opts` is
620
+ // the point: if the benchmark could take any colour it would eventually take
621
+ // an accent one, and then the chart has two subjects and no ground.
622
+ const OVERLAY_COLOR = 'var(--ink-soft)';
617
623
 
618
624
  // A CSS length, for a value that lands in a style attribute rather than in
619
625
  // text — so it is WHITELISTED, not escaped: escaping would leave a ';' free
@@ -679,15 +685,24 @@
679
685
  // There is deliberately no `cumulative` option: `cumulate` is exported, so a
680
686
  // running-total line is `sparklineHtml(OTBKit.cumulate(values))` — one way to
681
687
  // do it rather than two, and no option whose only exercise is its own test.
688
+ // `scaleWith` is the ONE knob a two-series plot needs (task 1004005): extra
689
+ // numbers folded into the min/max so an overlaid series shares this one's
690
+ // vertical scale. Two lines each fitted to their own range is not a
691
+ // comparison — it is two unrelated shapes that LOOK like one, which is worse
692
+ // than no benchmark at all. Omit it and every number below is unchanged, so a
693
+ // single-series caller's output is byte-for-byte what it was.
682
694
  function linePoints(values, opts = {}) {
683
695
  const W = opts.width || CHART_W;
684
696
  const H = opts.height || CHART_H;
685
697
  const pad = opts.pad === undefined ? CHART_PAD : opts.pad;
686
698
  const nums = chartNums(values);
687
699
  const n = nums.length;
700
+ // The numbers the SCALE must cover — this series plus anything overlaid on
701
+ // it. `nums` alone still drives the point COUNT and the x positions.
702
+ const scaleNums = opts.scaleWith === undefined ? nums : nums.concat(chartNums(opts.scaleWith));
688
703
  // A flat series has span 0 and would divide by zero; 1 puts it mid-box.
689
- const min = n ? Math.min(...nums) : 0;
690
- const span = (n ? Math.max(...nums) : 0) - min || 1;
704
+ const min = scaleNums.length ? Math.min(...scaleNums) : 0;
705
+ const span = (scaleNums.length ? Math.max(...scaleNums) : 0) - min || 1;
691
706
  const x = (i) => (n === 1 ? W / 2 : pad + (i * (W - 2 * pad)) / (n - 1));
692
707
  const y = (v) => H - pad - ((v - min) / span) * (H - 2 * pad);
693
708
  return {
@@ -696,9 +711,24 @@
696
711
  };
697
712
  }
698
713
 
699
- // A line + filled area. opts: { width, height, color, ariaLabel }
714
+ // A line + filled area. opts: { width, height, color, ariaLabel, overlay, overlayLabel }
715
+ //
716
+ // ONE SERIES IS STILL THE DEFAULT. modules/hall-ui/CLAUDE.md's rule — one
717
+ // series per chart, drawn in --accent, because a single series has no identity
718
+ // to encode — stands for every caller that passes no `overlay`. `overlay` is
719
+ // the owner-decided exception (task 1004005): a BENCHMARK behind the subject,
720
+ // not a second peer series. So the encoding is fixed here rather than left to
721
+ // the call site: the overlay is a muted grey line with NO area fill, drawn
722
+ // FIRST so the subject's own line and area sit on top of it. Grey is the
723
+ // meaning — it reads as background, so "you" stays the figure and "everyone"
724
+ // stays the ground. A caller wanting two equal series is asking for a
725
+ // different chart, and should not reach for this.
700
726
  function sparklineHtml(values, opts = {}) {
701
- const g = linePoints(values, opts);
727
+ const overlay = opts.overlay === undefined ? null : chartNums(opts.overlay);
728
+ const hasOverlay = !!(overlay && overlay.length);
729
+ // Both series are fitted to the COMBINED range, so the two lines can be read
730
+ // against each other. See linePoints' `scaleWith`.
731
+ const g = linePoints(values, hasOverlay ? { ...opts, scaleWith: overlay } : opts);
702
732
  const color = opts.color || 'var(--accent)';
703
733
  if (!g.nums.length) {
704
734
  return `<svg class="kit-spark" viewBox="0 0 ${g.width} ${g.height}" preserveAspectRatio="none"`
@@ -706,9 +736,21 @@
706
736
  }
707
737
  const last = g.nums[g.nums.length - 1];
708
738
  const area = `${g.pad},${g.height - g.pad} ${g.points} ${g.width - g.pad},${g.height - g.pad}`;
709
- const label = opts.ariaLabel || `trend, latest ${last}`;
739
+ // The overlay gets its own x spread (it may have a different number of
740
+ // points) but the SAME y scale, because each call folds the other series in.
741
+ const gOver = hasOverlay ? linePoints(overlay, { ...opts, scaleWith: g.nums }) : null;
742
+ const overLast = gOver ? gOver.nums[gOver.nums.length - 1] : null;
743
+ const label = opts.ariaLabel
744
+ || (hasOverlay
745
+ ? `trend, latest ${last}; ${opts.overlayLabel || 'benchmark'} ${overLast}`
746
+ : `trend, latest ${last}`);
710
747
  return `<svg class="kit-spark" viewBox="0 0 ${g.width} ${g.height}" preserveAspectRatio="none"`
711
748
  + ` role="img" aria-label="${esc(label)}">`
749
+ // Benchmark first = underneath. No area fill: two filled areas would
750
+ // muddy each other and neither would read as the subject.
751
+ + (gOver
752
+ ? `<polyline class="kit-spark__overlay" points="${gOver.points}" fill="none" stroke="${esc(OVERLAY_COLOR)}" />`
753
+ : '')
712
754
  + `<polyline class="kit-spark__area" points="${area}" fill="${esc(color)}" />`
713
755
  + `<polyline class="kit-spark__line" points="${g.points}" fill="none" stroke="${esc(color)}" />`
714
756
  // One point draws no line, so it gets a dot or the chart reads as empty.
@@ -1002,6 +1044,30 @@
1002
1044
  // Title + chart + optional foot — the frame all four sites drew by hand.
1003
1045
  // `inner` is already-built chart HTML, so the frame never needs to know
1004
1046
  // which shape it holds. That is what makes the fifth chart free.
1047
+ // The legend a two-series plot owes the reader (task 1004005). A single line
1048
+ // needs none — that is why the hall had no legend until the overlay existed —
1049
+ // but two lines and no key is a chart that cannot be read at all, so this
1050
+ // returns '' below two entries rather than drawing a pointless one.
1051
+ //
1052
+ // THE SWATCH IS AN SVG RECT, NOT A STYLED SPAN, and that is a security choice
1053
+ // rather than a stylistic one. A chart colour here is a `var(--token)`, and
1054
+ // CSS_LEN above deliberately REFUSES `var(` — so routing the colour through a
1055
+ // style attribute would blank every swatch. An SVG `fill` attribute is the
1056
+ // pattern sparklineHtml already uses for exactly these values, where esc() is
1057
+ // the correct and sufficient guard.
1058
+ function chartLegendHtml(entries) {
1059
+ const rows = (entries || []).filter((e) => e && e.label);
1060
+ if (rows.length < 2) return '';
1061
+ return '<div class="kit-chart__legend">'
1062
+ + rows.map((e) => '<span class="kit-chart__legend-item">'
1063
+ + '<svg class="kit-chart__legend-swatch" viewBox="0 0 10 10" width="10" height="10" aria-hidden="true">'
1064
+ + `<rect x="0" y="4" width="10" height="2" fill="${esc(e.color || 'var(--accent)')}" />`
1065
+ + '</svg>'
1066
+ + esc(e.label)
1067
+ + '</span>').join('')
1068
+ + '</div>';
1069
+ }
1070
+
1005
1071
  function chartCardHtml(title, inner, foot, emptyMsg) {
1006
1072
  const body = inner || `<div class="kit-chart__empty">${esc(emptyMsg || 'no data in window')}</div>`;
1007
1073
  return '<div class="kit-chart">'
@@ -1150,7 +1216,7 @@
1150
1216
  // charts (task 1003445) — the ONE place the hall draws a series.
1151
1217
  // linePoints/cumulate are the maths on their own, for a chart that wants
1152
1218
  // the geometry without this SVG (the profile's timeChart).
1153
- sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate, cumulateByDay,
1219
+ sparklineHtml, barSeriesHtml, chartCardHtml, chartLegendHtml, linePoints, cumulate, cumulateByDay,
1154
1220
  // the year-grid contribution calendar (task 1003572) — the hall's third chart family
1155
1221
  calendarGridHtml,
1156
1222
  calendarWindow,
@@ -1107,7 +1107,15 @@
1107
1107
  </tr>
1108
1108
  `).join('');
1109
1109
  }
1110
- const analyticsState = { builderId: null, isArchon: false, scope: 'self', self: null, eco: null };
1110
+ // No `isArchon` here: the curve's scope was rank-gated until task 1004005, and
1111
+ // the field is gone rather than left write-only — a state flag nothing reads is
1112
+ // how a removed gate quietly grows back.
1113
+ //
1114
+ // `kind` is the active filter (null = all work). `kinds` is the picker's source
1115
+ // — the UNFILTERED fingerprint, held separately because a filtered payload's
1116
+ // top_kinds would otherwise shrink to the one kind selected and strand the
1117
+ // reader there with no way back.
1118
+ const analyticsState = { builderId: null, scope: 'self', kind: null, kinds: [], self: null, eco: null };
1111
1119
  // The line drawing moved to OTBKit.sparklineHtml (task 1003445) — it was one
1112
1120
  // of four chart implementations in this surface. This wrapper keeps the
1113
1121
  // Curve's own default colour, which is NOT the kit's: these four charts each
@@ -1122,10 +1130,53 @@
1122
1130
  ${sub ? `<div class="curve__stat-sub">${escapeHtml(sub)}</div>` : ''}
1123
1131
  </div>`;
1124
1132
  }
1125
- function chartCard(title, values, color, footer) {
1133
+ // `overlay` is the ecosystem benchmark for this same chart, or null. When it is
1134
+ // present the card also carries a legend — two lines with no key cannot be
1135
+ // read. The legend is built here rather than inside sparklineHtml because the
1136
+ // NAMES are this page's ("You" / "All builders"), not the kit's.
1137
+ function chartCard(title, values, color, footer, overlay) {
1126
1138
  const has = Array.isArray(values) && values.length > 0;
1127
- return window.OTBKit.chartCardHtml(title, has ? sparkline(values, { color }) : '', footer);
1139
+ const hasOverlay = Array.isArray(overlay) && overlay.length > 0;
1140
+ const body = has
1141
+ ? sparkline(values, hasOverlay ? { color, overlay, overlayLabel: 'all builders' } : { color })
1142
+ : '';
1143
+ const legend = has && hasOverlay
1144
+ ? window.OTBKit.chartLegendHtml([
1145
+ { label: 'You', color },
1146
+ { label: 'All builders', color: 'var(--ink-soft)' },
1147
+ ])
1148
+ : '';
1149
+ return window.OTBKit.chartCardHtml(title, body ? body + legend : '', footer);
1150
+ }
1151
+ // The kind picker (task 1004005) — "how am I doing at THIS kind of work".
1152
+ //
1153
+ // Built from analyticsState.kinds, the UNFILTERED fingerprint captured on the
1154
+ // first load, NOT from the current payload's top_kinds. Under a filter the
1155
+ // payload would list only the selected kind, so a picker built from it would
1156
+ // lose every other option the moment one was chosen — the reader could narrow
1157
+ // once and never move again.
1158
+ //
1159
+ // A <select> rather than a row of chips: the kind list is open-ended (it is a
1160
+ // free-text column with an 'unspecified' bucket), so the control has to cope
1161
+ // with more options than fit on a line, and a native select is also the one
1162
+ // that arrives keyboard- and screen-reader-correct with no work.
1163
+ function kindFilterHtml() {
1164
+ const kinds = Array.isArray(analyticsState.kinds) ? analyticsState.kinds : [];
1165
+ if (kinds.length < 2) return ''; // one kind of work is not a choice
1166
+ const cur = analyticsState.kind;
1167
+ const opt = (value, label, n) => {
1168
+ const sel = (value === null ? cur === null : cur === value) ? ' selected' : '';
1169
+ const count = n === undefined ? '' : ` (${n})`;
1170
+ return `<option value="${escapeHtml(value === null ? '' : value)}"${sel}>${escapeHtml(label)}${escapeHtml(count)}</option>`;
1171
+ };
1172
+ return `<span class="curve__filter">`
1173
+ + `<label class="curve__filter-label" for="curve-kind">Kind of work</label>`
1174
+ + `<select id="curve-kind" class="curve__filter-select">`
1175
+ + opt(null, 'All work')
1176
+ + kinds.map((k) => opt(k.kind, k.kind, k.n)).join('')
1177
+ + `</select></span>`;
1128
1178
  }
1179
+
1129
1180
  function renderCurveBody() {
1130
1181
  const headlineEl = $('#curve-headline');
1131
1182
  const gridEl = $('#curve-grid');
@@ -1137,17 +1188,34 @@
1137
1188
  const isEco = analyticsState.scope === 'eco';
1138
1189
  const data = isEco ? analyticsState.eco : analyticsState.self;
1139
1190
 
1140
- // Scope toggle (Archon only).
1141
- if (analyticsState.isArchon && noteEl) {
1191
+ // The controls — scope toggle and kind filter.
1192
+ //
1193
+ // WRITTEN BEFORE EVERY EARLY RETURN BELOW, and that ordering is the whole
1194
+ // reason they are here. A kind with nothing shipped in the window is an
1195
+ // ordinary, reachable state, and it takes the `totalShipped === 0` branch —
1196
+ // which blanks the headline, the grid and the bars. If the filter that
1197
+ // caused the empty view were drawn after that return, the reader would be
1198
+ // left looking at "no data" with no control on screen to undo it, and the
1199
+ // only way out would be a page reload.
1200
+ //
1201
+ // The scope toggle is no longer Archon-gated (task 1004005): the feed behind
1202
+ // "All builders" is a public, identity-stripped aggregate, so the gate
1203
+ // withheld the comparison rather than protecting anything.
1204
+ if (noteEl) {
1142
1205
  noteEl.innerHTML =
1143
1206
  `<span class="curve__scope" role="group" aria-label="Analytics scope">` +
1144
1207
  `<button type="button" class="curve__scope-btn${!isEco ? ' is-active' : ''}" data-scope="self">You</button>` +
1145
1208
  `<button type="button" class="curve__scope-btn${isEco ? ' is-active' : ''}" data-scope="eco">All builders</button>` +
1146
- `</span>`;
1209
+ `</span>` +
1210
+ kindFilterHtml();
1147
1211
  }
1148
1212
 
1149
1213
  if (!data) { gridEl.innerHTML = ''; headlineEl.innerHTML = ''; kindsEl.innerHTML = ''; return; }
1150
1214
 
1215
+ // Under a kind filter the server cannot answer cost at all (cost_log has no
1216
+ // kind), and says so in `unavailable` rather than sending a misleading zero.
1217
+ const costUnavailable = Array.isArray(data.unavailable) && data.unavailable.includes('cost_trend');
1218
+
1151
1219
  const totalShipped = data.headline?.total_shipped || 0;
1152
1220
  if (totalShipped === 0) {
1153
1221
  if (emptyEl) emptyEl.style.display = '';
@@ -1164,7 +1232,11 @@
1164
1232
  cards.push(statCard('Active builders', h.active_builders ?? 0));
1165
1233
  cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
1166
1234
  } else {
1167
- cards.push(statCard('Cost / work', usd(h.cost_per_task)));
1235
+ // '—', never usd(null) → "$0.00". Zero is a measurement; this is the
1236
+ // absence of one, and printing a price here would be a false statement.
1237
+ cards.push(costUnavailable
1238
+ ? statCard('Cost / work', '—', 'not tracked per kind')
1239
+ : statCard('Cost / work', usd(h.cost_per_task)));
1168
1240
  cards.push(statCard('Avg time / work', mins(h.avg_duration_min)));
1169
1241
  cards.push(statCard('Confirmation rate', pct(h.confirmation_rate), `${h.resolved_claims || 0} claims`));
1170
1242
  cards.push(statCard('Grade pass rate', pct(h.grade_pass_rate), `${h.graded_n || 0} graded`));
@@ -1172,14 +1244,34 @@
1172
1244
  headlineEl.innerHTML = cards.join('');
1173
1245
 
1174
1246
  // Sparkline charts.
1247
+ //
1248
+ // THE OVERLAY ONLY EVER RIDES ON THE 'self' VIEW, and only where the
1249
+ // ecosystem publishes the SAME series. Overlaying the benchmark on the
1250
+ // benchmark would draw one line twice; and the ecosystem feed carries only
1251
+ // ship-rate and duration (grade and cost have their own public feeds and are
1252
+ // not part of that payload), so those two are the only charts that CAN
1253
+ // compare. A chart with no counterpart simply draws one line, as before.
1254
+ const eco = (!isEco && analyticsState.eco) ? analyticsState.eco : null;
1255
+ const ecoOf = (rows, pick) => (eco && Array.isArray(eco[rows]) ? eco[rows].map(pick) : null);
1175
1256
  const charts = [];
1176
1257
  charts.push(chartCard('Works shipped / day', (data.ship_rate || []).map((r) => r.n), 'var(--accent-deep)',
1177
- `${(data.ship_rate || []).reduce((s, r) => s + Number(r.n), 0)} over ${data.window_days}d`));
1258
+ `${(data.ship_rate || []).reduce((s, r) => s + Number(r.n), 0)} over ${data.window_days}d`,
1259
+ ecoOf('ship_rate', (r) => r.n)));
1178
1260
  charts.push(chartCard('Time / work (min)', (data.duration_trend || []).map((r) => r.avg_actual), 'var(--sea)',
1179
- 'actual minutes per shipped work'));
1261
+ 'actual minutes per shipped work',
1262
+ ecoOf('duration_trend', (r) => r.avg_actual)));
1180
1263
  if (!isEco) {
1181
- charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
1182
- `total ${usd(h.total_cost_usd)}`));
1264
+ // COST IS NOT ANSWERABLE PER KIND — cost_log has no kind and reaches tasks
1265
+ // only through a mostly-NULL task_id, so the server returns cost_trend: []
1266
+ // and names it in `unavailable` rather than plotting a near-zero line that
1267
+ // would read as "this kind of work is cheap". Say so where the chart was.
1268
+ if (costUnavailable) {
1269
+ charts.push(window.OTBKit.chartCardHtml('Cost / work ($)', '',
1270
+ '', 'not tracked per kind of work — clear the filter to see cost'));
1271
+ } else {
1272
+ charts.push(chartCard('Cost / work ($)', (data.cost_trend || []).map((r) => r.per_work), 'var(--terracotta)',
1273
+ `total ${usd(h.total_cost_usd)}`));
1274
+ }
1183
1275
  // n counts quality-graded rows only (task 1002652); a day whose grades
1184
1276
  // were all outages has n=0 and must not plot as a 0% dip.
1185
1277
  charts.push(chartCard('Grade pass rate', (data.grade_trend || []).filter((r) => Number(r.n) > 0).map((r) => r.n_passed / r.n), 'var(--accent)',