@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.
- package/.bongos-core.json +74 -39
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +11 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +186 -3
- package/docs/api-reference.md +10 -2
- package/docs/copy-inventory.md +64 -43
- package/docs/copy-registry.json +293 -62
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
- package/modules/government/catalog.js +1 -0
- package/modules/government/migrations/government_010_project_settings_manage.sql +58 -0
- package/modules/hall-ui/public/hall-kit.js +93 -7
- package/modules/hall-ui/public/project-settings.css +90 -0
- package/modules/hall-ui/public/project-settings.html +68 -0
- package/modules/hall-ui/public/project-settings.js +249 -0
- package/modules/hall-ui/public/roadmap-timeline.js +105 -0
- package/modules/hall-ui/public/roadmap.html +7 -1
- package/modules/hall-ui/public/roadmap.js +16 -0
- package/modules/hall-ui/public/shell.js +3 -0
- package/modules/hall-ui/public/style.css +4 -2
- package/modules/hall-ui/public/watch.html +4 -0
- package/modules/hall-ui/public/watch.js +116 -0
- package/modules/lifecycle/goal-category.js +82 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/routes/goals.js +16 -0
- package/modules/lifecycle/routes/project-settings.js +263 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +6 -0
- package/src/bongos/serve-internal.js +12 -1
- package/src/module-api.js +13 -1
- package/tests/hall_kit_charts.mjs +173 -0
- package/tests/hall_nav.mjs +3 -1
- package/tests/hall_oversight_world.mjs +159 -0
- package/tests/hall_page_gate_map.mjs +4 -0
- package/tests/hall_reading_world.mjs +153 -0
- 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
|
-
|
|
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
|
-
|
|
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]));
|