@bongos/core 1.19.851 → 1.19.853
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 +41 -31
- package/docs/adr/0125-control-plane-scaffolds-standalone-instance-repo.md +3 -1
- package/docs/adr/0305-a-recipe-lives-with-what-it-documents.md +157 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +17 -16
- package/docs/copy-registry.json +26 -17
- package/docs/module-api-changelog.md +5 -1
- package/docs/recipes/core-release-pipeline.md +11 -2
- package/docs/recipes/upgrading-the-core.md +1 -0
- package/modules/hall-ui/public/builders.js +65 -6
- package/modules/hall-ui/public/hall-kit.js +72 -6
- package/modules/hall-ui/public/hall-render.js +103 -11
- package/modules/hall-ui/public/style.css +20 -0
- package/modules/lifecycle/db-analytics.js +79 -20
- package/modules/lifecycle/routes/analytics.js +22 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-repo-auth.js +51 -8
- package/scripts/gds/update-channel.js +27 -7
- package/scripts/gds/upgrade.js +20 -7
- package/src/module-api.js +1 -1
- package/tests/curve_kind_overlay.mjs +298 -0
- package/tests/provision.mjs +46 -3
- package/tests/rank_gate.mjs +35 -7
- package/tests/update_channel.mjs +31 -0
- package/tests/upgrade.mjs +142 -1
package/docs/copy-registry.json
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
"generator": "scripts/gds/copy-inventory.js",
|
|
4
4
|
"goal": 1000074,
|
|
5
5
|
"counts": {
|
|
6
|
-
"strings":
|
|
7
|
-
"unique_ids":
|
|
8
|
-
"certain":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
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":
|
|
14119
|
+
"line": 903,
|
|
14111
14120
|
"origin": "js-markup",
|
|
14112
14121
|
"confidence": "certain"
|
|
14113
14122
|
},
|
|
@@ -2175,6 +2175,10 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
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
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
|
|
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)
|
|
2181
|
+
1.19.853 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
2182
|
+
landed since 1.19.852 with no explicit bump. run 35275456041. (task 1002620)
|
|
2179
2183
|
---------------------------------------------------------------------------
|
|
2180
2184
|
```
|
|
@@ -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
|
|
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
|
|
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.
|
|
@@ -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
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
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 =
|
|
690
|
-
const span = (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
1141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1182
|
-
|
|
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)',
|
|
@@ -1995,6 +1995,16 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
1995
1995
|
}
|
|
1996
1996
|
.curve__scope-btn + .curve__scope-btn { border-left: 1px solid var(--border-strong); }
|
|
1997
1997
|
.curve__scope-btn.is-active { background: var(--accent-weak); color: var(--accent-text); }
|
|
1998
|
+
/* The kind filter (task 1004005). Sits beside the scope toggle in the lede, so
|
|
1999
|
+
it inherits that line's size rather than setting its own. inline-flex + the
|
|
2000
|
+
gap keeps the label and the select together when the lede wraps. */
|
|
2001
|
+
.curve__filter { display: inline-flex; align-items: center; gap: 6px; margin-left: 10px; }
|
|
2002
|
+
.curve__filter-label { color: var(--ink-soft); }
|
|
2003
|
+
.curve__filter-select {
|
|
2004
|
+
font: inherit; color: inherit; background: var(--surface-2);
|
|
2005
|
+
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
|
|
2006
|
+
padding: 2px 6px; max-width: 16ch;
|
|
2007
|
+
}
|
|
1998
2008
|
|
|
1999
2009
|
.curve__headline {
|
|
2000
2010
|
display: grid;
|
|
@@ -2034,6 +2044,11 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
2034
2044
|
.kit-chart__title { font-size: 12px; font-weight: 600; color: var(--ink-soft); margin: 0 0 6px; }
|
|
2035
2045
|
.kit-chart__empty { font-size: 12px; color: var(--ink-faint); }
|
|
2036
2046
|
.kit-chart__foot { font-size: 11.5px; color: var(--ink-faint); margin-top: 4px; }
|
|
2047
|
+
/* The two-series key — OTBKit.chartLegendHtml (task 1004005). Only rendered when
|
|
2048
|
+
a chart actually carries an overlay, so a single-series card is unchanged. */
|
|
2049
|
+
.kit-chart__legend { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px; font-size: 11.5px; color: var(--ink-faint); }
|
|
2050
|
+
.kit-chart__legend-item { display: inline-flex; align-items: center; gap: 5px; }
|
|
2051
|
+
.kit-chart__legend-swatch { flex: none; }
|
|
2037
2052
|
|
|
2038
2053
|
/* The contribution calendar — OTBKit.calendarGridHtml (task 1003572), the hall's
|
|
2039
2054
|
THIRD chart family. A year grid of small cells, banded by ship count 0..5. Theme-
|
|
@@ -2071,6 +2086,11 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
|
|
|
2071
2086
|
colour inline, because the Curve spends a different accent per chart. */
|
|
2072
2087
|
.kit-spark__line { fill: none; stroke: var(--accent); stroke-width: 2; }
|
|
2073
2088
|
.kit-spark__area { fill: color-mix(in srgb, var(--accent) 14%, transparent); stroke: none; }
|
|
2089
|
+
/* The ecosystem benchmark line (task 1004005). THINNER and dashed as well as
|
|
2090
|
+
grey: on a 44px-tall chart colour alone is a weak signal, and the dash says
|
|
2091
|
+
"reference" at a glance — so the subject's own solid line stays the figure
|
|
2092
|
+
even where the two cross. No area fill; see sparklineHtml. */
|
|
2093
|
+
.kit-spark__overlay { fill: none; stroke: var(--ink-soft); stroke-width: 1.25; stroke-dasharray: 3 2; }
|
|
2074
2094
|
|
|
2075
2095
|
/* Every difference between the three call sites is one of these six custom
|
|
2076
2096
|
properties, which is what makes the fifth chart free: pass barSeriesHtml's
|