@bongos/core 1.19.534 → 1.19.536

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.bongos-core.json +74 -39
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +186 -3
  8. package/docs/api-reference.md +10 -2
  9. package/docs/copy-inventory.md +64 -43
  10. package/docs/copy-registry.json +293 -62
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
  13. package/modules/government/catalog.js +1 -0
  14. package/modules/government/migrations/government_010_project_settings_manage.sql +58 -0
  15. package/modules/hall-ui/public/hall-kit.js +93 -7
  16. package/modules/hall-ui/public/project-settings.css +90 -0
  17. package/modules/hall-ui/public/project-settings.html +68 -0
  18. package/modules/hall-ui/public/project-settings.js +249 -0
  19. package/modules/hall-ui/public/roadmap-timeline.js +105 -0
  20. package/modules/hall-ui/public/roadmap.html +7 -1
  21. package/modules/hall-ui/public/roadmap.js +16 -0
  22. package/modules/hall-ui/public/shell.js +3 -0
  23. package/modules/hall-ui/public/style.css +4 -2
  24. package/modules/hall-ui/public/watch.html +4 -0
  25. package/modules/hall-ui/public/watch.js +116 -0
  26. package/modules/lifecycle/goal-category.js +82 -0
  27. package/modules/lifecycle/module.json +2 -1
  28. package/modules/lifecycle/routes/goals.js +16 -0
  29. package/modules/lifecycle/routes/project-settings.js +263 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/scripts/hall-preview/refresh-fixtures.js +6 -0
  33. package/src/bongos/serve-internal.js +12 -1
  34. package/src/module-api.js +13 -1
  35. package/tests/hall_kit_charts.mjs +173 -0
  36. package/tests/hall_nav.mjs +3 -1
  37. package/tests/hall_oversight_world.mjs +159 -0
  38. package/tests/hall_page_gate_map.mjs +4 -0
  39. package/tests/hall_reading_world.mjs +153 -0
  40. package/tests/project_settings_page.mjs +337 -0
@@ -0,0 +1,249 @@
1
+ // project-settings.js — the five fundamentals of this project, each with the place
2
+ // its value came from (task 1003280, goal 1000072, criterion legibility-project-settings).
3
+ //
4
+ // WHAT THIS PAGE IS FOR. Goal 1000072's thesis: the project is legible to Claude and
5
+ // illegible to a person. Answering "how long before work is called stale", "what
6
+ // categories do we use", "how is a credit calculated", "is equity real" or "what
7
+ // share of upmarket is paid on credits" each meant reading source. This page is the
8
+ // translation layer — it invents no value and stores nothing of its own.
9
+ //
10
+ // THE RULE THAT SHAPES EVERY ROW: the SERVER decides what each row says and where it
11
+ // came from; this file only draws it. No formula, threshold, default or file path is
12
+ // re-typed here. That is deliberate and load-bearing — a settings page carrying its
13
+ // own copy of `round(cost × 1.20)` or of "1..365" is exactly the drift ADR 0226
14
+ // describes, one release later and on the screen the owner trusts most.
15
+ //
16
+ // A ROW THE READER CANNOT TYPE INTO MUST LOOK UNWRITABLE AND SAY WHY. Three of the
17
+ // five are read-only, and two of those are read-only for different reasons — equity
18
+ // is a settled owner decision that it is not real, upmarket is a knob that does not
19
+ // exist yet. Both render as themselves rather than as a plausible zero.
20
+ (() => {
21
+ 'use strict';
22
+
23
+ const API = '/api/bongos';
24
+ // The generated typed client (task 1996 / R09), as every other hall page uses it.
25
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
26
+ const { escapeHtml } = window.OTB;
27
+ const { emptyStateHtml } = window.OTBKit;
28
+ const toast = (msg) => window.OTB.toast(msg);
29
+
30
+ const $ = (id) => document.getElementById(id);
31
+
32
+ async function fetchJson(path) {
33
+ const res = await api.request('GET', `${API}${path}`);
34
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
35
+ if (res.status === 403) { throw { kind: '403' }; }
36
+ if (!res.ok) throw new Error(`${path} → ${res.status}`);
37
+ return res.data;
38
+ }
39
+
40
+ async function sendJson(method, path, body) {
41
+ const res = await api.request(method, `${API}${path}`, { body: body || {} });
42
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); throw { kind: '401' }; }
43
+ const data = res.data || {};
44
+ if (!res.ok) {
45
+ const err = data.error || {};
46
+ // The server's message when it wrote one (its range wording is the authority),
47
+ // the code as a last resort. Never a message invented here.
48
+ throw new Error(err.message && err.message !== err.code ? err.message : (err.code || `${path} → ${res.status}`));
49
+ }
50
+ return data;
51
+ }
52
+
53
+ // ---- row chrome ----------------------------------------------------------
54
+
55
+ // The provenance foot. `reads.file` is the artefact the value resolves from and is
56
+ // shown as code because it is a path or a table key the reader may go and open.
57
+ function provHtml(row) {
58
+ const reads = row.reads || {};
59
+ const bits = [];
60
+ if (row.source) bits.push(`source: ${escapeHtml(String(row.source))}`);
61
+ if (reads.file) bits.push(`reads <code>${escapeHtml(String(reads.file))}</code>`);
62
+ if (reads.adr) bits.push(escapeHtml(String(reads.adr)));
63
+ if (!bits.length) return '';
64
+ return `<p class="ps-prov">${bits.join(' · ')}</p>`;
65
+ }
66
+
67
+ const PILL = {
68
+ live: ['ps-pill--live', 'live'],
69
+ 'read-only': ['ps-pill--readonly', 'committed config'],
70
+ 'not-yet-real': ['ps-pill--readonly', 'not yet real'],
71
+ absent: ['ps-pill--absent', 'not configured'],
72
+ unavailable: ['ps-pill--absent', 'unavailable'],
73
+ };
74
+
75
+ function pillHtml(row) {
76
+ // An editable row is the only thing that gets the "live" pill; a read-only row
77
+ // whose state the server called 'live' is still committed config to the reader.
78
+ const key = row.editable ? 'live' : (row.state === 'live' ? 'read-only' : row.state);
79
+ const [cls, label] = PILL[key] || PILL['read-only'];
80
+ return `<span class="ps-pill ${cls}">${escapeHtml(label)}</span>`;
81
+ }
82
+
83
+ // One row's shell. `bodyHtml` is whatever that row needs beneath its sentence —
84
+ // controls for a knob, nothing for the three that are read.
85
+ function rowHtml(row, bodyHtml) {
86
+ return `
87
+ <li class="ov-row" data-row="${escapeHtml(row.key)}">
88
+ <div class="ov-row__main">
89
+ <span class="ov-row__title">${escapeHtml(row.title)} ${pillHtml(row)}</span>
90
+ <p class="ov-row__sub">${escapeHtml(row.sentence || '')}</p>
91
+ ${bodyHtml || ''}
92
+ ${provHtml(row)}
93
+ </div>
94
+ </li>`;
95
+ }
96
+
97
+ // ---- the two knobs -------------------------------------------------------
98
+
99
+ function rotBodyHtml(row) {
100
+ const v = row.value || {};
101
+ // The bounds come from the server's own resolver, never re-typed here.
102
+ const invalid = row.invalid
103
+ ? `<p class="ps-invalid">A configured value could not be used: <code>${escapeHtml(String(row.invalid.raw))}</code>
104
+ at ${escapeHtml(String(row.invalid.where))} — ${escapeHtml(String(row.invalid.reason))}.
105
+ The timer above is the fallback that is actually in force.</p>`
106
+ : '';
107
+ const sib = row.sibling
108
+ ? `<p class="ps-sibling"><strong>${escapeHtml(row.sibling.title)}</strong> —
109
+ ${escapeHtml(row.sibling.sentence)}
110
+ ${row.sibling.reads && row.sibling.reads.file
111
+ ? `<code>${escapeHtml(row.sibling.reads.file)}</code>` : ''}</p>`
112
+ : '';
113
+ return `
114
+ ${invalid}
115
+ <div class="ps-controls">
116
+ <label for="ps-rot-days">Raise after</label>
117
+ <input type="number" id="ps-rot-days" min="${escapeHtml(String(v.min))}" max="${escapeHtml(String(v.max))}"
118
+ step="1" value="${escapeHtml(String(v.days))}" aria-describedby="ps-rot-hint">
119
+ <span id="ps-rot-hint">days (${escapeHtml(String(v.min))}–${escapeHtml(String(v.max))})</span>
120
+ <button type="button" class="btn" id="ps-rot-save">Save</button>
121
+ </div>
122
+ ${sib}`;
123
+ }
124
+
125
+ function catsBodyHtml(row) {
126
+ const v = row.value || {};
127
+ const cats = Array.isArray(v.categories) ? v.categories : [];
128
+ const list = cats.length
129
+ ? `<ul class="ps-cats">${cats.map((c) => `
130
+ <li class="ps-cat ${c.active ? '' : 'ps-cat--off'}" data-cat="${escapeHtml(String(c.id))}">
131
+ <input type="text" class="ps-cat__label" value="${escapeHtml(String(c.label))}" aria-label="Label for ${escapeHtml(String(c.slug))}">
132
+ <span class="ps-cat__slug"><code>${escapeHtml(String(c.slug))}</code></span>
133
+ <input type="number" class="ps-cat__order" step="10" value="${escapeHtml(String(c.sort_order))}" aria-label="Order for ${escapeHtml(String(c.slug))}">
134
+ <label><input type="checkbox" class="ps-cat__active" ${c.active ? 'checked' : ''}> offered</label>
135
+ <button type="button" class="btn ps-cat__save">Save</button>
136
+ </li>`).join('')}</ul>`
137
+ : emptyStateHtml('This project has no categories yet.');
138
+ return `
139
+ ${list}
140
+ <div class="ps-controls">
141
+ <input type="text" id="ps-cat-new" placeholder="New category label" aria-label="New category label">
142
+ <button type="button" class="btn" id="ps-cat-add">Add</button>
143
+ </div>`;
144
+ }
145
+
146
+ // ---- render + wire -------------------------------------------------------
147
+
148
+ let rows = [];
149
+
150
+ function render() {
151
+ const mount = $('ps-rows');
152
+ mount.innerHTML = `<ul class="ov-rows">${rows.map((row) => {
153
+ if (row.key === 'rot_timer' && row.editable) return rowHtml(row, rotBodyHtml(row));
154
+ if (row.key === 'work_categories' && row.editable) return rowHtml(row, catsBodyHtml(row));
155
+ return rowHtml(row, '');
156
+ }).join('')}</ul>`;
157
+ wire();
158
+ }
159
+
160
+ // Every control dies while a write is in flight and comes back after — the
161
+ // sessions.js discipline. Without it a double-click sends two writes and the second
162
+ // reply, arriving first, can repaint a value nobody asked for.
163
+ function setBusy(on) {
164
+ const mount = $('ps-rows');
165
+ mount.querySelectorAll('input, button').forEach((el) => { el.disabled = on; });
166
+ }
167
+
168
+ function wire() {
169
+ const rotSave = $('ps-rot-save');
170
+ if (rotSave) {
171
+ rotSave.addEventListener('click', async () => {
172
+ const days = Number($('ps-rot-days').value);
173
+ setBusy(true);
174
+ try {
175
+ const out = await sendJson('PATCH', '/project-settings/rot-days', { days });
176
+ toast(`Stale timer is now ${out.rot_days} days.`);
177
+ await load(); // re-read, so the row shows what the SERVER now resolves
178
+ } catch (err) {
179
+ toast(err.message || 'Could not save the timer.');
180
+ setBusy(false); // load() re-renders on success; on failure put the row back
181
+ }
182
+ });
183
+ }
184
+
185
+ const addBtn = $('ps-cat-add');
186
+ if (addBtn) {
187
+ addBtn.addEventListener('click', async () => {
188
+ const label = ($('ps-cat-new').value || '').trim();
189
+ if (!label) { toast('Give the category a label.'); return; }
190
+ setBusy(true);
191
+ try {
192
+ const out = await sendJson('POST', '/work-categories', { label });
193
+ toast(out.created === false ? `“${out.category.label}” already existed.` : `Added “${out.category.label}”.`);
194
+ await load();
195
+ } catch (err) {
196
+ toast(err.message || 'Could not add the category.');
197
+ setBusy(false);
198
+ }
199
+ });
200
+ }
201
+
202
+ document.querySelectorAll('.ps-cat__save').forEach((btn) => {
203
+ btn.addEventListener('click', async () => {
204
+ const li = btn.closest('.ps-cat');
205
+ const id = li.getAttribute('data-cat');
206
+ const body = {
207
+ label: li.querySelector('.ps-cat__label').value.trim(),
208
+ sort_order: Number(li.querySelector('.ps-cat__order').value),
209
+ active: li.querySelector('.ps-cat__active').checked,
210
+ };
211
+ setBusy(true);
212
+ try {
213
+ await sendJson('PATCH', `/work-categories/${encodeURIComponent(id)}`, body);
214
+ toast('Category saved.');
215
+ await load();
216
+ } catch (err) {
217
+ toast(err.message || 'Could not save the category.');
218
+ setBusy(false);
219
+ }
220
+ });
221
+ });
222
+ }
223
+
224
+ async function load() {
225
+ try {
226
+ const data = await fetchJson('/project-settings');
227
+ rows = Array.isArray(data.rows) ? data.rows : [];
228
+ $('ps-sub').textContent = `${rows.length} fundamentals · ${rows.filter((r) => r.editable).length} you can change here`;
229
+ $('ps-body').hidden = false;
230
+ $('ps-sealed').hidden = true;
231
+ render();
232
+ } catch (err) {
233
+ if (err && err.kind === '401') return; // already redirecting to sign-in
234
+ if (err && err.kind === '403') {
235
+ $('ps-sub').textContent = 'Restricted';
236
+ $('ps-sealed').hidden = false;
237
+ $('ps-body').hidden = true;
238
+ return;
239
+ }
240
+ // A failed read is a NAMED failure, never a blank page that reads as "this
241
+ // project has no settings".
242
+ $('ps-sub').textContent = 'Could not load';
243
+ $('ps-body').hidden = false;
244
+ $('ps-rows').innerHTML = emptyStateHtml('Could not load the project settings just now.');
245
+ }
246
+ }
247
+
248
+ load();
249
+ })();
@@ -0,0 +1,105 @@
1
+ // roadmap-timeline.js — the program in time (task 1003447, criterion
2
+ // `legibility-series-are-drawn`).
3
+ //
4
+ // /public/versions has always carried `started_at` and `shipped_at` per version,
5
+ // and /roadmap drew NEITHER: both dates reached the browser and came out as
6
+ // "Sep 2026" on a shipped pill. The series two dates support is a SPAN — when
7
+ // each version ran — and every span on ONE shared axis is the reading the page
8
+ // exists for: whether the build is speeding up, and how much of the in-flight
9
+ // version's run is already behind it.
10
+ //
11
+ // Its own file rather than another sixty lines of roadmap.js, for the reason
12
+ // profile-charts.js is its own file: the builders are PURE, so a test imports
13
+ // this and runs the real arithmetic over the real fixture instead of agreeing
14
+ // with a copy of it. Exposed as window.OTBRoadmapTimeline; node-require-safe
15
+ // (the hall-kit.js / dom-utils.js pattern — assignment is the only eval-time
16
+ // side effect, and only when a window exists).
17
+ //
18
+ // The DRAWING is the kit's: OTBKit.barSeriesHtml with a per-row `start`, the
19
+ // span option added for exactly this. Date arithmetic stays here — the kit takes
20
+ // `max` as the axis span and `start` as an offset into it, and never learns what
21
+ // a month is. That is the same seam every other chart in the hall rides.
22
+ (() => {
23
+ 'use strict';
24
+
25
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
26
+
27
+ // "May ’26" — short enough that a whole span label fits the value column at
28
+ // phone width, which "May 2026 – September 2026" does not.
29
+ function ymShort(t) {
30
+ const d = new Date(t);
31
+ return Number.isNaN(d.getTime()) ? '' : `${MON[d.getMonth()]} ’${String(d.getFullYear()).slice(-2)}`;
32
+ }
33
+
34
+ // One row per version that actually started, oldest first.
35
+ //
36
+ // A version with no parsable `start` is NOT on the timeline, because it has
37
+ // not happened. Defaulting it to the axis origin would draw it as having begun
38
+ // at the start of the project, which is the opposite of true; the caller says
39
+ // how many were left out instead.
40
+ //
41
+ // `open` is a version still running (or parked mid-run): its span reaches
42
+ // today and its label says "now", rather than naming an end date nothing
43
+ // recorded. `to` is floored at `from` so a clock skew or a bad shipped_at
44
+ // cannot produce a negative span the kit would draw backwards.
45
+ function timelineRows(all, now) {
46
+ return (all || [])
47
+ .map((v) => {
48
+ const from = Date.parse(v && v.start);
49
+ if (!Number.isFinite(from)) return null;
50
+ const shipped = Date.parse(v.ship);
51
+ const open = !Number.isFinite(shipped);
52
+ return { v, from, to: Math.max(open ? now : shipped, from), open };
53
+ })
54
+ .filter(Boolean)
55
+ .sort((a, b) => a.from - b.from);
56
+ }
57
+
58
+ // The chart card: the spans, and the foot that says what the axis covers and
59
+ // what is missing from it. Returns { count, html }, or NULL when there is
60
+ // nothing worth drawing — so the caller renders no section at all rather than
61
+ // an empty panel. One return shape either way, never a string sometimes and an
62
+ // object other times.
63
+ //
64
+ // `kit` is a parameter (defaulting to window.OTBKit) so this module is pure
65
+ // enough to import in node and run against the real kit in a test.
66
+ function timelineCardHtml(all, now, kit) {
67
+ const k = kit || (typeof window !== 'undefined' ? window.OTBKit : null);
68
+ if (!k) return null;
69
+ const rows = timelineRows(all, now);
70
+ // One version is not a timeline: a single bar filling the whole axis says
71
+ // nothing that version's own card does not already say.
72
+ if (rows.length < 2) return null;
73
+
74
+ const t0 = rows[0].from;
75
+ const t1 = Math.max(now, ...rows.map((r) => r.to));
76
+ const span = (t1 - t0) || 1;
77
+ const bars = k.barSeriesHtml(
78
+ rows.map((r) => ({
79
+ label: r.v.id,
80
+ value: r.to - r.from,
81
+ start: r.from - t0,
82
+ valueLabel: `${ymShort(r.from)} → ${r.open ? 'now' : ymShort(r.to)}`,
83
+ })),
84
+ {
85
+ max: span,
86
+ list: true,
87
+ ariaLabel: 'When each version ran',
88
+ labelWidth: '92px',
89
+ valueWidth: '108px',
90
+ barHeight: '10px',
91
+ pill: true,
92
+ },
93
+ );
94
+ const missing = (all || []).length - rows.length;
95
+ const foot = `${ymShort(t0)} to today, one bar per version.`
96
+ + (missing > 0
97
+ ? ` ${missing} version${missing === 1 ? '' : 's'} with no start date ${missing === 1 ? 'is' : 'are'} not shown.`
98
+ : '');
99
+ return { count: rows.length, html: k.chartCardHtml('When each version ran', bars, foot) };
100
+ }
101
+
102
+ const api = { timelineRows, timelineCardHtml, ymShort };
103
+ if (typeof window !== 'undefined') window.OTBRoadmapTimeline = api;
104
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
105
+ })();
@@ -45,6 +45,12 @@
45
45
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
46
46
  <script src="/builders/palette.js" defer></script>
47
47
  <script src="/builders/dom-utils.js?v=2026-06-14-domutils"></script>
48
- <script src="/builders/roadmap.js?v=2026-07-24-deck1"></script>
48
+ <!-- The chart kit and the timeline's series builders (task 1003447). Both MUST
49
+ precede roadmap.js: the page reads window.OTBKit / window.OTBRoadmapTimeline
50
+ at render time, so loading either after would be a blank section in
51
+ production and a green unit suite. tests/hall_kit_charts.mjs pins the order. -->
52
+ <script src="/builders/hall-kit.js"></script>
53
+ <script src="/builders/roadmap-timeline.js"></script>
54
+ <script src="/builders/roadmap.js?v=2026-09-03-timeline"></script>
49
55
  </body>
50
56
  </html>
@@ -241,6 +241,18 @@
241
241
  </article>`;
242
242
  }
243
243
 
244
+ // The program in time (task 1003447). The SERIES is roadmap-timeline.js's —
245
+ // pure builders in their own file so a test runs the real arithmetic over the
246
+ // real fixture — and the SECTION chrome is this page's, which is the same seam
247
+ // the chart kit keeps everywhere: the module draws, the page frames. Missing
248
+ // module or fewer than two started versions renders nothing at all, so the
249
+ // deck reads exactly as it did before rather than growing an empty panel.
250
+ function timelineHtml(all, now) {
251
+ const mod = window.OTBRoadmapTimeline;
252
+ const card = mod ? mod.timelineCardHtml(all, now) : null;
253
+ return card ? sectionHtml('flight', 'In time', card.count, '', card.html) : '';
254
+ }
255
+
244
256
  function sectionHtml(dotCls, title, count, note, inner) {
245
257
  return `<section class="rm-sec">
246
258
  <div class="rm-sec__h"><span class="rm-sec__dot rm-sec__dot--${dotCls}"></span><span class="rm-sec__t">${title}</span><span class="rm-sec__n">${count}</span><span class="rm-sec__rule"></span></div>
@@ -380,6 +392,10 @@
380
392
  html += `<div class="rm-hero"><div class="rm-lab" style="max-width:none">No version is in flight right now.</div></div>`;
381
393
  }
382
394
 
395
+ // Directly under the hero: both are program-level readings, and this is the
396
+ // one that answers "how fast" rather than "how much".
397
+ html += timelineHtml(all, Date.now());
398
+
383
399
  if (staged.length) {
384
400
  html += sectionHtml('staged', 'Staged', staged.length, 'Seeded with goals, not yet started.', `<div class="rm-grid">${staged.map(stagedCard).join('')}</div>`);
385
401
  }
@@ -236,6 +236,9 @@
236
236
  { id: 'harbor', label: 'Harbor', icon: 'admin', href: '/harbor', gate: 'government', module: 'dev-box' },
237
237
  { id: 'gate', label: 'Gate', icon: 'admin', href: '/gate', gate: 'government' },
238
238
  { id: 'sessions', label: 'Sessions', icon: 'admin', href: '/sessions', gate: 'government' },
239
+ // The project's own fundamentals (task 1003280, goal 1000072). Mirrors the
240
+ // server's requireGovernmentPage floor; cosmetic only, as every item here is.
241
+ { id: 'project-settings', label: 'Project settings', icon: 'admin', href: '/project-settings', gate: 'government' },
239
242
  // BV1.R113: the roles-and-permissions tab (ADR 0151). The one genuinely
240
243
  // Archon-only item the 'government' gate comment anticipated — cosmetic
241
244
  // only; requireGovernmentManagePage (the government.manage atom) is the wall.
@@ -2039,8 +2039,10 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
2039
2039
  reduced-motion kill switch (§the kill switch, at the end of this sheet)
2040
2040
  already zeroes this with !important — do NOT add a second @media block
2041
2041
  here, which is what `tests/hall_tokens.mjs` forbids and why the rule
2042
- cannot rot in two places. */
2043
- transition: width 200ms ease;
2042
+ cannot rot in two places. `margin-left` rides along because a SPAN bar
2043
+ (barSeriesHtml's per-row `start`) moves as well as grows, and a bar that
2044
+ slides while its neighbour eases reads as two different animations. */
2045
+ transition: width 200ms ease, margin-left 200ms ease;
2044
2046
  }
2045
2047
  .kit-bars__row:hover .kit-bars__fill { opacity: .8; }
2046
2048
  .kit-bars__val { text-align: right; color: var(--ink-faint); }
@@ -111,6 +111,10 @@
111
111
  <h2 id="ledger-h" class="scroll__h">Repo health</h2>
112
112
  <p class="scroll__lede">The shape and health of the codebase.</p>
113
113
  <div id="ledger-stats" class="ov-figs"><span class="kit-skel"></span></div>
114
+ <!-- The trend the request has always carried and the page dropped
115
+ on the floor (task 1003447) — the same shape as the sessions
116
+ fix above it. -->
117
+ <div id="ledger-trend"></div>
114
118
  <div id="ledger-loc"></div>
115
119
  <div id="ledger-files"></div>
116
120
  </section>
@@ -942,6 +942,120 @@
942
942
  .replace(/\b\w/g, (c) => c.toUpperCase());
943
943
  }
944
944
 
945
+ // The four series the snapshot table keeps, in the order a reader wants them:
946
+ // how big the codebase is, then the three kinds of rot it accumulates.
947
+ //
948
+ // `zeroBased` is the axis decision, made per metric because the metrics are
949
+ // not alike. A count of dead exports belongs on a zero floor: zero is a real,
950
+ // reachable state and the distance from it IS the reading. `total_loc` never
951
+ // goes near zero — on a zero floor a month of real movement is a flat band
952
+ // across the top of the box, which is a picture of nothing. That one fits its
953
+ // floor to the data, and `trendFigure` puts the range in the foot so the
954
+ // magnified wiggle is never silent about what it is.
955
+ const TREND_METRICS = [
956
+ {
957
+ key: 'total_loc', title: 'Total lines', fmt: fmtCompact, zeroBased: false,
958
+ foot: 'Every tracked line of the codebase, snapshot by snapshot.',
959
+ },
960
+ {
961
+ key: 'dead_exports', title: 'Dead exports', fmt: fmtInt, zeroBased: true,
962
+ foot: 'Exported symbols nothing imports — the knip scan, run daily.',
963
+ },
964
+ {
965
+ key: 'stale_branches', title: 'Stale branches', fmt: fmtInt, zeroBased: true,
966
+ foot: 'Branches with no movement inside the staleness window.',
967
+ },
968
+ {
969
+ key: 'confirmed_unshipped', title: 'Confirmed, unshipped', fmt: fmtInt, zeroBased: true,
970
+ foot: 'Work verified but not yet landed — the strand backlog.',
971
+ },
972
+ ];
973
+
974
+ // One repo-health metric as a figure: the step plot, the value it ends on, and
975
+ // the table twin that keeps it out of picture-only territory.
976
+ //
977
+ // A STEP line, not a polyline, and that is the honest shape here: a snapshot's
978
+ // value is what the repo measured that morning and it holds until the next run
979
+ // measures again. The scan does not run every single day, so a straight line
980
+ // between two samples a week apart would draw movement nobody recorded.
981
+ //
982
+ // No readout is wired, so the plot passes `interactive: false` and takes
983
+ // neither a focus stop nor a crosshair — the /drachmae contract. The reading
984
+ // is the aria-label, the labelled endpoint and the twin.
985
+ function trendFigure({ key, title, fmt, foot: footText, zeroBased }, trend) {
986
+ const kit = window.OTBKit;
987
+ // Only rows that actually carry this metric. An older snapshot predates a
988
+ // column and sends null; counting that as 0 would draw a cliff to the floor
989
+ // that never happened.
990
+ //
991
+ // NOT `Number(v)` — that is the trap, and the trap is silent: Number(null),
992
+ // Number(undefined ?? '') and Number('') are 0, 0 and 0, every one of them
993
+ // finite, so a plain isFinite filter keeps a missing reading as a real zero.
994
+ // The page's own `num()` coerces the same way for the same reason. Mapping
995
+ // an absent value to NaN is what makes the filter below mean "carried".
996
+ const carried = (v) => (v === null || v === undefined || v === '' ? NaN : Number(v));
997
+ const points = trend
998
+ .map((r) => ({ x: Date.parse(r.snapshot_date), value: carried(r[key]) }))
999
+ .filter((p) => Number.isFinite(p.x) && Number.isFinite(p.value));
1000
+ // One sample is a reading, not a trend, and it draws a single dot on an
1001
+ // axis with nothing to compare it to.
1002
+ if (points.length < 2) return '';
1003
+
1004
+ const last = points[points.length - 1];
1005
+ // The axis window is the kit's arithmetic, not this page's.
1006
+ const { lo, hi, yMin, yMax } = kit.plotWindow(points.map((p) => p.value), { zeroBased });
1007
+
1008
+ const series = kit.timeStepSeriesHtml(points, {
1009
+ x0: points[0].x, x1: last.x, yMin, yMax,
1010
+ });
1011
+ const { endX, endY } = series.geometry;
1012
+ const day = (t) => new Date(t).toISOString().slice(0, 10);
1013
+ // At most ~21 rows however wide the window is (?days goes to 90), and the
1014
+ // last row is always present so the twin ends where the plot's label does.
1015
+ const step = Math.max(1, Math.ceil(points.length / 20));
1016
+ const twinRows = points.filter((p, i) => i % step === 0 || i === points.length - 1);
1017
+ const range = `${fmt(lo)} – ${fmt(hi)} across ${fmtInt(points.length)} snapshots.`;
1018
+ // A fitted floor multiplies how big every move looks, so the figure says so
1019
+ // in words rather than letting the picture imply a collapse to nothing.
1020
+ const axisNote = zeroBased ? '' : ` The axis starts at ${fmt(Math.round(yMin))}, not zero.`;
1021
+
1022
+ return `<figure class="pchart" id="ledger-trend-${escapeHtml(key)}">
1023
+ <figcaption class="pchart__title">${escapeHtml(title)}</figcaption>
1024
+ ${kit.plotSvgHtml({
1025
+ series: series.body,
1026
+ interactive: false,
1027
+ ariaLabel: `${title}: ${fmt(last.value)} on ${day(last.x)}, ranging ${fmt(lo)} to ${fmt(hi)} over the window.`,
1028
+ label: kit.plotValueLabelHtml(endX, endY - 8, fmt(last.value), 'end'),
1029
+ leftTick: day(points[0].x),
1030
+ rightTick: day(last.x),
1031
+ })}
1032
+ <details class="pchart__twin">
1033
+ <summary>Read it as a table</summary>
1034
+ <table class="ledger pchart__table"><thead><tr><th>Snapshot</th><th>${escapeHtml(title)}</th></tr></thead><tbody>
1035
+ ${twinRows.map((p) => `<tr><td>${escapeHtml(day(p.x))}</td><td>${escapeHtml(fmt(p.value))}</td></tr>`).join('')}
1036
+ </tbody></table>
1037
+ </details>
1038
+ <p class="pchart__foot">${escapeHtml(`${footText} ${range}${axisNote}`)}</p>
1039
+ </figure>`;
1040
+ }
1041
+
1042
+ // The trend the route has always sent and renderLedger discarded (task
1043
+ // 1003447). It is requested WITH ?days=30 — the page was paying for the window
1044
+ // and reading one snapshot off it.
1045
+ function renderTrend(data) {
1046
+ const el = $('#ledger-trend');
1047
+ if (!el) return;
1048
+ const trend = Array.isArray(data.trend) ? data.trend : [];
1049
+ const figures = TREND_METRICS.map((m) => trendFigure(m, trend)).filter(Boolean).join('');
1050
+ // Nothing plottable yet (a fresh instance, or one metrics run) renders
1051
+ // nothing at all — the figures above already say what today's numbers are,
1052
+ // and an empty panel under them would be worse than no panel.
1053
+ el.innerHTML = figures
1054
+ ? `<section class="ov-group"><div class="ov-group__head"><span class="ov-group__name">Over the last ${escapeHtml(String(data.window_days || 30))} days</span></div>`
1055
+ + `<div class="pcharts">${figures}</div></section>`
1056
+ : '';
1057
+ }
1058
+
945
1059
  function renderLedger(data, stats) {
946
1060
  const locEl = $('#ledger-loc');
947
1061
  const filesEl = $('#ledger-files');
@@ -962,6 +1076,8 @@
962
1076
  }
963
1077
  stats.innerHTML = figs(cards);
964
1078
 
1079
+ renderTrend(data);
1080
+
965
1081
  // loc_by_dir as a two-column ledger under its own head.
966
1082
  const locByDir = latest.loc_by_dir && typeof latest.loc_by_dir === 'object' ? latest.loc_by_dir : {};
967
1083
  const dirEntries = Object.entries(locByDir).sort((a, b) => num(b[1]) - num(a[1]));