@bongos/core 1.19.511 → 1.19.513

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 (50) hide show
  1. package/.bongos-core.json +85 -50
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/config/branding.neutral.json +3 -2
  8. package/docs/adr/0238-the-join-door-is-a-provisioning-settings-policy-key.md +57 -0
  9. package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
  10. package/docs/adr/README.md +2 -0
  11. package/docs/api/openapi.json +40 -3
  12. package/docs/api-reference.md +3 -2
  13. package/docs/copy-inventory.md +158 -151
  14. package/docs/copy-registry.json +313 -229
  15. package/docs/module-api-changelog.md +4 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  17. package/modules/hall-ui/public/hall-kit.js +309 -0
  18. package/modules/hall-ui/public/hall-render.js +10 -38
  19. package/modules/hall-ui/public/profile-charts.js +58 -65
  20. package/modules/hall-ui/public/profile.css +9 -18
  21. package/modules/hall-ui/public/style.css +101 -15
  22. package/modules/hall-ui/public/watch.css +6 -14
  23. package/modules/hall-ui/public/watch.html +1 -1
  24. package/modules/hall-ui/public/watch.js +139 -54
  25. package/modules/provisioning/provisioning.js +27 -1
  26. package/modules/provisioning/recommendations.js +260 -0
  27. package/modules/provisioning/routes/provisioning.js +29 -0
  28. package/modules/provisioning/tests/provisioning.mjs +17 -14
  29. package/modules/public-landing/public/projects.html +125 -0
  30. package/modules/ui-design/kit/serve.js +4 -3
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/src/branding.js +9 -0
  34. package/src/module-api.js +1 -1
  35. package/tests/currency_label.mjs +5 -4
  36. package/tests/hall_audit.mjs +84 -0
  37. package/tests/hall_kit_charts.mjs +328 -0
  38. package/tests/hall_profile_world.mjs +27 -6
  39. package/tests/helpers.mjs +161 -0
  40. package/tests/joinability_invite_only.mjs +10 -2
  41. package/tests/project_door_ui.mjs +267 -0
  42. package/tests/projects_hub.mjs +73 -1
  43. package/tests/projects_hub_pre_uat.mjs +4 -1
  44. package/tests/provision_settings_apply.mjs +8 -7
  45. package/tests/provisioning_recommendations.mjs +448 -0
  46. package/tests/provisioning_settings.mjs +65 -6
  47. package/tests/provisioning_settings_apply.mjs +3 -3
  48. package/tests/provisioning_settings_env.mjs +10 -8
  49. package/tests/task_detail_ui.mjs +1 -153
  50. package/tests/watch_applications_queue.mjs +381 -0
@@ -236,6 +236,90 @@ test('the session log pages on the kit — the last hand-roll is gone', () => {
236
236
  assert.match(read('sessions.html'), /hall-kit\.js/, 'and the page has to load the kit');
237
237
  });
238
238
 
239
+ // ---- 6b. every hall bar series is drawn by the kit (task 1003445) ---------
240
+
241
+ test('the kit is the ONLY thing in the hall that draws a bar series', () => {
242
+ // Charts were the one primitive the kit never got, and the hard rule in
243
+ // hall-ui/CLAUDE.md names pager/filter/tab/rail and stops — so the same
244
+ // horizontal bar got written THREE times, each with its own class family:
245
+ // the Curve's kinds (hall-render.js), Watch's Review-quality trend
246
+ // (watch.js) and the profile's craft mix (profile-charts.js `mixChart`).
247
+ // Nothing failed when the third copy landed, which is the whole reason this
248
+ // check exists rather than a sentence in a doc.
249
+ //
250
+ // Two halves, because one alone is escapable.
251
+ //
252
+ // (a) OWNERSHIP, the pager rule's own idiom: the kit stamps `.kit-bars` /
253
+ // `.kit-spark` at runtime and callers pass DATA, so the class appearing
254
+ // in another file is by construction a second implementation.
255
+ const OWNER = 'hall-kit.js';
256
+ const stamped = [];
257
+ for (const f of fs.readdirSync(HALL)) {
258
+ if (!/\.(html|js)$/.test(f) || f === OWNER) continue;
259
+ if (/class="kit-(?:bars|spark|plot)[\s"_-]/.test(read(f))) stamped.push(f);
260
+ }
261
+ assert.deepEqual(stamped, [],
262
+ `${stamped.join(', ')} writes the kit's own chart classes by hand — call `
263
+ + 'OTBKit.barSeriesHtml / sparklineHtml / plotSvgHtml instead.');
264
+
265
+ // No page may draw an SVG series of its own either — a <path>/<rect>/
266
+ // <polyline> built in a template string IS chart drawing, whatever it is
267
+ // classed. This is what the narc caught on the first round: the bars were
268
+ // unified while two hand-rolled SVG charts stayed behind.
269
+ const svgDrawers = [];
270
+ for (const f of fs.readdirSync(HALL)) {
271
+ if (!/\.js$/.test(f) || f === OWNER) continue;
272
+ const src = read(f);
273
+ if (/<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/.test(src)) svgDrawers.push(f);
274
+ }
275
+ assert.deepEqual(svgDrawers, [],
276
+ `${svgDrawers.join(', ')} builds SVG series geometry by hand — the kit owns the `
277
+ + 'shapes (timeStepSeriesHtml, columnSeriesHtml, sparklineHtml) and plotSvgHtml owns '
278
+ + 'the axis frame; a page composes them and wires its own readout.');
279
+
280
+ // (b) SHAPE, so a SEVENTH copy under a fresh class family is caught too —
281
+ // which (a) alone would miss, and which is exactly how copies two and
282
+ // three arrived. A bar series is a percentage-width fill inside a
283
+ // track/bar/fill-named element.
284
+ //
285
+ // The allowlist is METERS, not series, and the distinction is real: a
286
+ // meter shows ONE value against a fixed 0-100 scale (and bands it), a
287
+ // series shows several values against each other's computed max. The kit
288
+ // draws the second; folding the first into it would mean inventing a
289
+ // scale the meter does not have. Adding a name here is where the
290
+ // conversation happens — do not add one to make a chart pass.
291
+ const METERS = new Set([
292
+ 'idea.js', // .ig-score — one Full-Idea completeness gauge, banded full/part/thin
293
+ 'ideas.js', // .fi-mrow — the composer's per-field completeness, driven imperatively
294
+ ]);
295
+ const handRolled = [];
296
+ for (const f of fs.readdirSync(HALL)) {
297
+ if (!/\.js$/.test(f) || f === OWNER || METERS.has(f)) continue;
298
+ const src = read(f);
299
+ if (/class="[^"]*(?:__|-)(?:fill|bar|track)[^"]*"[^>]*style="width:/.test(src)) handRolled.push(f);
300
+ }
301
+ assert.deepEqual(handRolled, [],
302
+ `${handRolled.join(', ')} draws a bar series by hand — call OTBKit.barSeriesHtml(rows, opts). `
303
+ + 'Geometry and tone are options (labelWidth/valueWidth/barHeight/radius/track/tone), so a new '
304
+ + 'chart needs no new drawing code and no new class family.');
305
+ });
306
+
307
+ test('the three bar copies are gone, and their class families with them', () => {
308
+ // The specific copies this rule replaced, pinned by name so their return is
309
+ // loud. Behaviour is exercised for real in tests/hall_kit_charts.mjs.
310
+ // Comments stripped: each sheet keeps a line SAYING which family it retired,
311
+ // and a substring check would read that sentence as the family still living.
312
+ const css = ['style.css', 'watch.css', 'profile.css']
313
+ .map((f) => read(f).replace(/\/\*[\s\S]*?\*\//g, '')).join('\n');
314
+ for (const dead of ['curve__kind-', 'curve__chart', 'watch-trend', 'pmix']) {
315
+ assert.ok(!css.includes(dead), `${dead} is the kit's .kit-bars/.kit-chart now`);
316
+ }
317
+ assert.match(read('hall-render.js'), /OTBKit\.barSeriesHtml\(/, 'the Curve\'s kinds draw through the kit');
318
+ assert.match(read('watch.js'), /OTBKit\.barSeriesHtml\(/, 'Watch\'s trend draws through the kit');
319
+ assert.match(read('profile-charts.js'), /OTBKit\.barSeriesHtml\(/, 'the craft mix draws through the kit');
320
+ assert.match(read('hall-render.js'), /OTBKit\.sparklineHtml\(/, 'and the Curve\'s line does too');
321
+ });
322
+
239
323
  test('the peer-recognition feed is paged through OTBKit, not by hand', () => {
240
324
  assert.match(read('hall-widgets.js'), /pagerBarHtml\('acclaim'/, 'the acclaim card stamps the kit pager bar');
241
325
  const js = read('builders.js');
@@ -0,0 +1,328 @@
1
+ // tests/hall_kit_charts.mjs — the hall's ONE chart primitive (task 1003445).
2
+ //
3
+ // Runs the REAL hall-kit.js, and for the three migrated call sites runs the
4
+ // real page beside it in a vm, so what is asserted is the markup a reader
5
+ // actually gets — not a stub agreeing with a stub. That mattered here: the
6
+ // three copies this primitive replaced were structurally identical and
7
+ // differed only in geometry and tone, so a test that mocked the kit would
8
+ // have passed on a primitive that dropped either.
9
+ //
10
+ // What it holds:
11
+ // 1. the arithmetic — scaling, a shared max, cumulation, the degenerate sets
12
+ // 2. the markup — one class family, geometry as custom properties
13
+ // 3. escaping — a label is server data and lands in innerHTML
14
+ // 4. the three call sites, each drawing through the kit at its own geometry
15
+
16
+ import { test } from 'node:test';
17
+ import assert from 'node:assert/strict';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import vm from 'node:vm';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const HALL = path.join(ROOT, 'modules', 'hall-ui', 'public');
25
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8').replace(/\r\n/g, '\n');
26
+
27
+ // The kit is dependency-free and node-require-safe by contract, so it loads
28
+ // directly — no DOM, no window. If that ever stops being true, this import is
29
+ // the first thing that fails, which is the point.
30
+ const kitPath = path.join(HALL, 'hall-kit.js');
31
+ const kit = await import(`file://${kitPath.replace(/\\/g, '/')}`).then((m) => m.default);
32
+ const kitSrc = read('hall-kit.js');
33
+
34
+ // ---- 1. the arithmetic ------------------------------------------------------
35
+
36
+ test('a bar is its share of the largest value in the set', () => {
37
+ const html = kit.barSeriesHtml([
38
+ { label: 'a', value: 10 },
39
+ { label: 'b', value: 5 },
40
+ { label: 'c', value: 0 },
41
+ ]);
42
+ const widths = [...html.matchAll(/width:(\d+)%/g)].map((m) => Number(m[1]));
43
+ assert.deepEqual(widths, [100, 50, 0], 'the max is 100%, and zero is drawn as empty, not omitted');
44
+ });
45
+
46
+ test('a caller-supplied max lets two series share one scale', () => {
47
+ // Without this, two charts side by side each normalise to their own biggest
48
+ // value and a smaller series draws taller bars than a larger one.
49
+ const html = kit.barSeriesHtml([{ label: 'a', value: 5 }], { max: 20 });
50
+ assert.match(html, /width:25%/);
51
+ });
52
+
53
+ test('an all-zero set draws empty tracks rather than dividing by zero', () => {
54
+ const html = kit.barSeriesHtml([{ label: 'a', value: 0 }, { label: 'b', value: 0 }]);
55
+ assert.match(html, /width:0%/);
56
+ assert.doesNotMatch(html, /NaN|Infinity/);
57
+ });
58
+
59
+ test('a non-finite value reads as zero — a gap-filled feed sends nulls', () => {
60
+ const html = kit.barSeriesHtml([{ label: 'a', value: 4 }, { label: 'b', value: null }]);
61
+ assert.doesNotMatch(html, /NaN/);
62
+ assert.match(html, /width:0%/);
63
+ });
64
+
65
+ test('an empty set renders nothing at all, so a caller can test for it', () => {
66
+ // '' rather than an empty container: every call site wants to substitute its
67
+ // own "nothing in this window" line, and a bare wrapper would defeat that.
68
+ assert.equal(kit.barSeriesHtml([]), '');
69
+ assert.equal(kit.barSeriesHtml(null), '');
70
+ assert.equal(kit.barSeriesHtml([{ value: 1 }]), '', 'a row with no label is not a row');
71
+ });
72
+
73
+ test('cumulate is a running total that survives holes', () => {
74
+ assert.deepEqual(kit.cumulate([1, 2, 3]), [1, 3, 6]);
75
+ assert.deepEqual(kit.cumulate([1, null, 3]), [1, 1, 4], 'a null adds nothing, it does not reset');
76
+ assert.deepEqual(kit.cumulate([]), []);
77
+ });
78
+
79
+ test('the line is scaled to its own min and max, and a flat series sits mid-box', () => {
80
+ const g = kit.linePoints([0, 10], { width: 100, height: 50, pad: 0 });
81
+ const ys = g.points.split(' ').map((p) => Number(p.split(',')[1]));
82
+ assert.equal(ys[0], 50, 'the minimum sits on the floor');
83
+ assert.equal(ys[1], 0, 'the maximum sits on the ceiling');
84
+ // span 0 would divide by zero; the guard puts a flat line halfway up.
85
+ const flat = kit.linePoints([3, 3, 3], { width: 100, height: 50, pad: 0 });
86
+ const flatYs = flat.points.split(' ').map((p) => Number(p.split(',')[1]));
87
+ assert.deepEqual(flatYs, [50, 50, 50]);
88
+ assert.ok(Number.isFinite(flatYs[0]));
89
+ });
90
+
91
+ test('a running-total line is composed, not a second option', () => {
92
+ // There is deliberately no `cumulative` flag on linePoints: `cumulate` is
93
+ // exported, so the two compose. One way to do it, and no option whose only
94
+ // caller is this test.
95
+ const plain = kit.linePoints([1, 1, 1], { width: 100, height: 50, pad: 0 });
96
+ const cumul = kit.linePoints(kit.cumulate([1, 1, 1]), { width: 100, height: 50, pad: 0 });
97
+ assert.deepEqual(cumul.nums, [1, 2, 3]);
98
+ assert.notEqual(plain.points, cumul.points, 'a running total is a different line');
99
+ assert.doesNotMatch(kitSrc, /opts\.cumulative/, 'and the flag is gone from the kit');
100
+ });
101
+
102
+ // ---- 2. the markup ---------------------------------------------------------
103
+
104
+ test('geometry rides custom properties, so a new chart needs no new CSS', () => {
105
+ const html = kit.barSeriesHtml([{ label: 'a', value: 1 }], {
106
+ labelWidth: '64px', valueWidth: '96px', barHeight: '6px', pill: true,
107
+ });
108
+ assert.match(html, /--kit-bars-label-w:64px/);
109
+ assert.match(html, /--kit-bars-val-w:96px/);
110
+ assert.match(html, /--kit-bars-h:6px/);
111
+ assert.match(html, /--kit-bars-radius:var\(--radius-pill\)/);
112
+ });
113
+
114
+ test('a style value that could break out of its declaration is dropped', () => {
115
+ // These land in a style attribute, so they are whitelisted as lengths rather
116
+ // than escaped — a ';' would end the declaration and start another.
117
+ const html = kit.barSeriesHtml([{ label: 'a', value: 1 }], {
118
+ labelWidth: '64px;background:url(http://x)', barHeight: '}*{display:none',
119
+ });
120
+ assert.doesNotMatch(html, /background|url\(|display:none/);
121
+ assert.doesNotMatch(html, /--kit-bars-label-w/, 'a rejected length is omitted, not half-written');
122
+ });
123
+
124
+ test('a label is escaped — it is server data landing in innerHTML', () => {
125
+ const html = kit.barSeriesHtml([{ label: '<img onerror=x>', value: 1, valueLabel: '<b>' }]);
126
+ assert.doesNotMatch(html, /<img|<b>/);
127
+ assert.match(html, /&lt;img/);
128
+ });
129
+
130
+ test('the track carries no information, so it is hidden from a reader', () => {
131
+ // The value cell already says the number in words; a screen reader hearing
132
+ // the track too would hear every row twice.
133
+ const html = kit.barSeriesHtml([{ label: 'a', value: 1 }]);
134
+ assert.match(html, /class="kit-bars__track" aria-hidden="true"/);
135
+ });
136
+
137
+ test('list semantics are opt-in, because only one call site is announced as a list', () => {
138
+ assert.doesNotMatch(kit.barSeriesHtml([{ label: 'a', value: 1 }]), /role="list/);
139
+ const asList = kit.barSeriesHtml([{ label: 'a', value: 1 }], { list: true, ariaLabel: 'Craft mix' });
140
+ assert.match(asList, /role="list" aria-label="Craft mix"/);
141
+ assert.match(asList, /role="listitem"/);
142
+ });
143
+
144
+ test('a one-point line draws a dot, or the chart reads as empty', () => {
145
+ assert.match(kit.sparklineHtml([7]), /<circle/);
146
+ assert.doesNotMatch(kit.sparklineHtml([7, 8]), /<circle/);
147
+ });
148
+
149
+ test('no data is an empty labelled svg, never a broken polyline', () => {
150
+ const html = kit.sparklineHtml([]);
151
+ assert.match(html, /aria-label="no data"/);
152
+ assert.doesNotMatch(html, /polyline/);
153
+ });
154
+
155
+ test('the caller owns the line colour — the Curve spends four', () => {
156
+ assert.match(kit.sparklineHtml([1, 2], { color: 'var(--terracotta)' }), /stroke="var\(--terracotta\)"/);
157
+ });
158
+
159
+ test('the chart frame does not know which shape it holds', () => {
160
+ const withChart = kit.chartCardHtml('Title', '<svg></svg>', 'foot');
161
+ assert.match(withChart, /kit-chart__title">Title/);
162
+ assert.match(withChart, /kit-chart__foot">foot/);
163
+ // No inner → the frame says so itself, so no call site writes that line.
164
+ assert.match(kit.chartCardHtml('T', '', 'f'), /kit-chart__empty/);
165
+ });
166
+
167
+ // ---- 2b. the plot: the axis frame both plotted charts shared ---------------
168
+
169
+ test('the step line holds its value, then rises — that is what cumulative means', () => {
170
+ // A straight polyline between samples would claim the total grew smoothly
171
+ // between two ships. It did not: it held, then jumped.
172
+ const g = kit.timeStepGeometry(
173
+ [{ x: 0, value: 1 }, { x: 10, value: 5 }],
174
+ { x0: 0, x1: 10, yMax: 5, width: 100, height: 100, pad: { l: 0, r: 0, t: 0, b: 0 } },
175
+ );
176
+ // Two L commands per later point: across at the OLD height, then up.
177
+ assert.equal((g.d.match(/ L/g) || []).length, 3, 'across, up, then out to the axis end');
178
+ assert.match(g.d, /^M0\.0,80\.0/, 'the first sample sits at 1/5 of the box');
179
+ });
180
+
181
+ test('the plotted area closes on the axis, so a fill cannot leak upward', () => {
182
+ const g = kit.timeStepGeometry([{ x: 0, value: 2 }], { x0: 0, x1: 4, yMax: 2 });
183
+ assert.match(g.area, /Z$/, 'the area is a closed path');
184
+ assert.ok(g.area.startsWith(g.d), 'it is the line, then down to the base and back');
185
+ });
186
+
187
+ test('a degenerate window does not divide by zero', () => {
188
+ // x0 === x1 (a single day, no window) and a zero max are both reachable from
189
+ // a real feed: one ship today, or a ledger nothing has reached.
190
+ for (const opts of [{ x0: 5, x1: 5, yMax: 3 }, { x0: 0, x1: 10, yMax: 0 }]) {
191
+ const g = kit.timeStepGeometry([{ x: 5, value: 0 }], opts);
192
+ assert.doesNotMatch(g.d, /NaN|Infinity/, JSON.stringify(opts));
193
+ }
194
+ assert.deepEqual(kit.timeStepGeometry([], {}).xs, [], 'and an empty series draws nothing');
195
+ });
196
+
197
+ test('columns cap their width and keep a gap, however few weeks there are', () => {
198
+ const one = kit.columnSeriesHtml([5], { width: 320 });
199
+ const w = Number(/width="([\d.]+)"/.exec(one.body)?.[1] ?? /H([\d.]+)/.exec(one.body)?.[1] ?? 0);
200
+ // One bucket gets the whole plot as its slot; without the cap it would draw
201
+ // a 300px-wide column.
202
+ assert.ok(one.body.length > 0);
203
+ assert.ok(!/width="[3-9]\d\d/.test(one.body), `a lone column is capped, got width ${w}`);
204
+ });
205
+
206
+ test('a zero bucket draws a stub on the axis, not a gap', () => {
207
+ // A missing week and a week with no ships are different facts; an absent
208
+ // column reads as the first.
209
+ const cols = kit.columnSeriesHtml([3, 0, 2]);
210
+ assert.match(cols.body, /kit-plot__col--empty/);
211
+ // Count ELEMENTS, not class occurrences — the empty one is classed
212
+ // `kit-plot__col kit-plot__col--empty`, so the bare name appears twice on it.
213
+ assert.equal((cols.body.match(/class="kit-plot__col/g) || []).length, 3, 'three weeks, three marks');
214
+ assert.equal(cols.xs.length, 3);
215
+ });
216
+
217
+ test('the plot frame carries the axis, the hidden crosshair and both ticks', () => {
218
+ const svg = kit.plotSvgHtml({ series: '<path/>', ariaLabel: 'Works', leftTick: 'Aug 1', rightTick: 'today' });
219
+ assert.match(svg, /class="kit-plot"/);
220
+ assert.match(svg, /class="kit-plot__axis"/);
221
+ assert.match(svg, /class="kit-plot__hair"[^>]*visibility:hidden/, 'the crosshair starts hidden');
222
+ assert.match(svg, /Aug 1/);
223
+ assert.match(svg, /today/);
224
+ assert.match(svg, /aria-label="Works"/);
225
+ // Walkable by arrow key, therefore reachable by tab.
226
+ assert.match(svg, /tabindex="0"/);
227
+ assert.match(svg, /role="img"/);
228
+ });
229
+
230
+ test('the plot escapes its aria-label and ticks — both are page data', () => {
231
+ const svg = kit.plotSvgHtml({ ariaLabel: '"><script>x</script>', leftTick: '<b>' });
232
+ assert.doesNotMatch(svg, /<script|<b>/);
233
+ });
234
+
235
+ test('a plot labels exactly one value, in ink', () => {
236
+ const label = kit.plotValueLabelHtml(10, 20, '42', 'end');
237
+ assert.match(label, /class="kit-plot__end"/);
238
+ assert.match(label, /text-anchor="end"/);
239
+ assert.match(label, />42</);
240
+ });
241
+
242
+ // ---- 3. the three migrated call sites --------------------------------------
243
+
244
+ // Each runs the REAL page source, so these assert the request the page makes
245
+ // of the kit rather than the kit's own defaults.
246
+
247
+ function runPage(file, extraWindow = {}) {
248
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
249
+ { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
250
+ const sandbox = {
251
+ window: {
252
+ OTB: { escapeHtml: esc, $: () => null, fmtNum: (n) => String(n ?? 0), fmtDate: (s) => String(s ?? '') },
253
+ __BRANDING__: { currency: { label: 'drachmae' } },
254
+ ...extraWindow,
255
+ },
256
+ document: { addEventListener() {}, getElementById: () => null, querySelector: () => null },
257
+ console, Date, Map, Set, Math, Number, String, Array, Object, JSON, Infinity,
258
+ URLSearchParams, Promise, RegExp, isNaN, parseInt, parseFloat,
259
+ };
260
+ sandbox.globalThis = sandbox;
261
+ vm.createContext(sandbox);
262
+ vm.runInContext(read('hall-kit.js'), sandbox, { filename: 'hall-kit.js' });
263
+ vm.runInContext(read(file), sandbox, { filename: file });
264
+ return sandbox;
265
+ }
266
+
267
+ test('the profile craft mix asks for its measured geometry, not the default', () => {
268
+ // .pmix carried a square-left bar (it grows from an axis), a soft track and
269
+ // a label that could shrink to 72px. All three are options now; losing any
270
+ // of them would be a silent visual regression on a surface the owner had
271
+ // already accepted (task 1003489).
272
+ const src = read('profile-charts.js');
273
+ assert.match(src, /OTBKit\.barSeriesHtml\(/);
274
+ assert.match(src, /radius: '0 4px 4px 0'/, 'the bar keeps its square left edge');
275
+ assert.match(src, /track: 'soft'/);
276
+ assert.match(src, /labelWidth: 'minmax\(72px, 110px\)'/);
277
+ assert.match(src, /list: true/, 'the mix is announced as a list');
278
+ });
279
+
280
+ test('Watch\'s trend takes the neutral tone — on an oversight page the accent is the pressable\'s', () => {
281
+ const src = read('watch.js');
282
+ assert.match(src, /tone: 'neutral'/);
283
+ assert.match(src, /labelWidth: '64px'/);
284
+ assert.match(src, /valueWidth: '96px'/);
285
+ assert.match(src, /barHeight: '6px'/);
286
+ assert.match(src, /pill: true/);
287
+ });
288
+
289
+ test('the Curve keeps its own line colour, which is not the kit default', () => {
290
+ const src = read('hall-render.js');
291
+ assert.match(src, /sparklineHtml\(values, \{ color: 'var\(--accent-deep\)'/,
292
+ 'the Curve\'s first chart is accent-deep; the kit\'s fallback is --accent');
293
+ assert.match(src, /OTBKit\.barSeriesHtml\(/, 'and its kinds row draws through the kit');
294
+ });
295
+
296
+ test('the kit is loaded before every page that draws through it', () => {
297
+ // A page script reading window.OTBKit at render time with the kit loaded
298
+ // after it is a blank chart in production and a green unit suite.
299
+ for (const [page, script] of [
300
+ ['index.html', 'hall-render.js'],
301
+ ['watch.html', 'watch.js'],
302
+ ['profile.html', 'profile-charts.js'],
303
+ ]) {
304
+ // The loaded ORDER, read off the script tags — index.html's prose mentions
305
+ // hall-render.js ~90 lines above where it loads it, so a substring search
306
+ // on the whole file answers the wrong question.
307
+ const loaded = [...read(page).matchAll(/<script[^>]+src="([^"?]+)/g)]
308
+ .map((m) => m[1].split('/').pop());
309
+ const kitAt = loaded.indexOf('hall-kit.js');
310
+ const pageAt = loaded.indexOf(script);
311
+ assert.ok(kitAt > -1, `${page} must load hall-kit.js`);
312
+ assert.ok(pageAt > -1, `${page} must load ${script}`);
313
+ assert.ok(kitAt < pageAt, `${page} must load hall-kit.js BEFORE ${script}`);
314
+ }
315
+ });
316
+
317
+ test('the migrated pages run for real against the kit and emit its classes', () => {
318
+ // hall-render.js is a factory, so this proves the whole file parses and the
319
+ // chart helpers resolve against the real kit — the failure mode a
320
+ // source-text assertion cannot see.
321
+ const s = runPage('hall-render.js');
322
+ assert.equal(typeof s.window.OTBHallRender, 'function', 'the renderer factory survived the migration');
323
+ assert.equal(typeof s.window.OTBKit.barSeriesHtml, 'function');
324
+ assert.match(
325
+ s.window.OTBKit.barSeriesHtml([{ label: 'engineer', value: 3 }]),
326
+ /class="kit-bars"/,
327
+ );
328
+ });
@@ -53,7 +53,14 @@ const stripComments = (css) => css.replace(/\/\*[\s\S]*?\*\//g, '');
53
53
  const html = read('profile.html');
54
54
  const profileJs = read('profile.js');
55
55
  const chartsJs = read('profile-charts.js');
56
+ // task 1003445: the charts draw their bars through OTBKit.barSeriesHtml, so the
57
+ // sandbox runs the REAL kit beside the real page rather than a stub agreeing
58
+ // with a stub (the tests/sessions_kit_pager.mjs pattern). A stub here would
59
+ // still pass if the kit's markup changed underneath the page.
60
+ const kitJs = read('hall-kit.js');
56
61
  const profileCss = stripComments(read('profile.css'));
62
+ // The plot's dataviz spec lives in the kit's sheet since task 1003445.
63
+ const styleCss = stripComments(read('style.css'));
57
64
 
58
65
  const GROUPS = ['overview', 'works', 'activity', 'connections'];
59
66
 
@@ -120,13 +127,21 @@ test('profile-charts.js loads before profile.js, and profile.js hands it the pag
120
127
  });
121
128
 
122
129
  test('the marks keep the dataviz specs: 2px accent line, 10% wash, hairline solid axis, an 8px end dot with a surface ring', () => {
123
- assert.match(profileCss, /\.pchart__line \{ fill: none; stroke: var\(--accent\); stroke-width: 2;/);
124
- assert.match(profileCss, /\.pchart__area \{ fill: color-mix\(in srgb, var\(--accent\) 10%, transparent\)/);
125
- assert.match(profileCss, /\.pchart__axis, \.pchart__hair \{ stroke: var\(--rule-soft\); stroke-width: 1; \}/);
126
- assert.match(profileCss, /\.pchart__dot \{ fill: var\(--accent\); stroke: var\(--bg-card\); stroke-width: 2; \}/);
127
- assert.match(chartsJs, /r="4"/, 'the end dot is r=4 — an 8px marker');
128
- assert.match(profileCss, /\.pchart__end \{[^}]*fill: var\(--ink\)/, 'the end label wears ink, not the series colour');
130
+ // task 1003445: the PLOT moved to OTBKit (`.kit-plot*` in style.css) — the
131
+ // same frame had been drawn twice by profile-charts.js. The spec is asserted
132
+ // wherever it lives, with the same values, because it is measured rather
133
+ // than chosen; what must not happen is the spec quietly not being checked
134
+ // because its class family was renamed.
135
+ assert.match(styleCss, /\.kit-plot__line \{ fill: none; stroke: var\(--accent\); stroke-width: 2;/);
136
+ assert.match(styleCss, /\.kit-plot__area \{ fill: color-mix\(in srgb, var\(--accent\) 10%, transparent\)/);
137
+ assert.match(styleCss, /\.kit-plot__axis, \.kit-plot__hair \{ stroke: var\(--rule-soft\); stroke-width: 1; \}/);
138
+ assert.match(styleCss, /\.kit-plot__dot \{ fill: var\(--accent\); stroke: var\(--bg-card\); stroke-width: 2; \}/);
139
+ assert.match(kitJs, /r="4"/, 'the end dot is r=4 — an 8px marker');
140
+ assert.match(styleCss, /\.kit-plot__end \{[^}]*fill: var\(--ink\)/, 'the labelled value wears ink, not the series colour');
141
+ // The page's own sheet keeps the figure chrome, and still spends no alias.
129
142
  assert.doesNotMatch(profileCss, /var\(--(surface|surface-2|border|border-strong|accent-weak|accent-text)\)/, 'no compat alias spent');
143
+ assert.doesNotMatch(profileCss, /\.pchart__(line|area|axis|hair|dot|col|end|tick)\b/,
144
+ 'the plot families are the kit\'s now — a copy left here is a second source of truth');
130
145
  });
131
146
 
132
147
  // ---- the charts: arithmetic, in a sandbox --------------------------------------
@@ -146,6 +161,9 @@ function bootCharts() {
146
161
  };
147
162
  sandbox.globalThis = sandbox;
148
163
  vm.createContext(sandbox);
164
+ // The kit first — profile.html pins that order too (hall-kit.js before
165
+ // profile-charts.js), and the page reads window.OTBKit at render time.
166
+ vm.runInContext(kitJs, sandbox, { filename: 'hall-kit.js' });
149
167
  vm.runInContext(chartsJs, sandbox, { filename: 'profile-charts.js' });
150
168
  return sandbox.window.OTBProfileCharts;
151
169
  }
@@ -277,6 +295,9 @@ function bootRender({ rows, status = 200, analytics = null, ledger = [] }) {
277
295
  };
278
296
  sandbox.globalThis = sandbox;
279
297
  vm.createContext(sandbox);
298
+ // The kit first — profile.html pins that order too (hall-kit.js before
299
+ // profile-charts.js), and the page reads window.OTBKit at render time.
300
+ vm.runInContext(kitJs, sandbox, { filename: 'hall-kit.js' });
280
301
  vm.runInContext(chartsJs, sandbox, { filename: 'profile-charts.js' });
281
302
  return { C: sandbox.window.OTBProfileCharts, api, requests, mount, controls };
282
303
  }
package/tests/helpers.mjs CHANGED
@@ -188,3 +188,164 @@ export function taskRoutesSource(modulesDir = path.join(ROOT, 'modules')) {
188
188
  return familySource(path.join(modulesDir, 'lifecycle', 'routes'),
189
189
  (f) => f === 'tasks.js' || (f.startsWith('task-') && f.endsWith('.js')));
190
190
  }
191
+
192
+ // ===========================================================================
193
+ // A minimal DOM that actually PARSES what a page script writes into innerHTML
194
+ // (task 1003516, extracted here by task 1002332 when the access-requests queue
195
+ // needed the same harness). querySelector resolves only if the element is
196
+ // genuinely there, form fields carry the values the file rendered (escaping
197
+ // included), and handlers captured by addEventListener run for real — which is
198
+ // what makes a hall page testable at all: there is no jsdom in this repo.
199
+ // ===========================================================================
200
+ const VOID_TAGS = new Set(['input', 'br', 'img', 'hr', 'meta', 'link']);
201
+
202
+ function decodeEntities(s) {
203
+ return String(s)
204
+ .replace(/&lt;/g, '<').replace(/&gt;/g, '>')
205
+ .replace(/&quot;/g, '"').replace(/&#39;/g, "'")
206
+ .replace(/&amp;/g, '&');
207
+ }
208
+
209
+ function parseAttrs(text) {
210
+ const out = {};
211
+ const re = /([\w:-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'>]+)))?/g;
212
+ let m;
213
+ while ((m = re.exec(text))) {
214
+ const raw = m[2] !== undefined ? m[2] : m[3] !== undefined ? m[3] : m[4] !== undefined ? m[4] : '';
215
+ out[m[1].toLowerCase()] = decodeEntities(raw);
216
+ }
217
+ return out;
218
+ }
219
+
220
+ // Compound selectors only (tag / .class / #id / [attr] / [attr="v"], comma lists)
221
+ // — that is every selector task-detail.js uses, and refusing anything richer
222
+ // keeps the stub honest about what it can prove.
223
+ function matchesOne(el, sel) {
224
+ let ok = true;
225
+ const re = /([a-zA-Z][\w-]*)|\.([\w-]+)|#([\w-]+)|\[([\w:-]+)(?:=["']([^"']*)["'])?\]/g;
226
+ let m;
227
+ while ((m = re.exec(sel))) {
228
+ if (m[1]) ok = ok && el.tagName === m[1].toLowerCase();
229
+ else if (m[2]) ok = ok && String(el.className || '').split(/\s+/).includes(m[2]);
230
+ else if (m[3]) ok = ok && el.id === m[3];
231
+ else if (m[4]) {
232
+ const have = el.attrs[m[4].toLowerCase()];
233
+ ok = ok && (m[5] === undefined ? have !== undefined : have === m[5]);
234
+ }
235
+ }
236
+ return ok;
237
+ }
238
+ export const matches = (el, sel) => String(sel).split(',').map((s) => s.trim()).filter(Boolean).some((s) => matchesOne(el, s));
239
+
240
+ export function walk(el, fn) {
241
+ for (const child of el.children) {
242
+ const hit = fn(child);
243
+ if (hit) return hit;
244
+ const deeper = walk(child, fn);
245
+ if (deeper) return deeper;
246
+ }
247
+ return null;
248
+ }
249
+
250
+ export function makeEl(tagName, attrs = {}) {
251
+ const el = {
252
+ tagName: String(tagName).toLowerCase(),
253
+ attrs,
254
+ children: [],
255
+ parentNode: null,
256
+ className: attrs.class || '',
257
+ id: attrs.id || '',
258
+ name: attrs.name,
259
+ dataset: {},
260
+ disabled: 'disabled' in attrs,
261
+ style: {},
262
+ listeners: {},
263
+ _text: '',
264
+ _html: null,
265
+ _value: null,
266
+
267
+ get value() {
268
+ if (this._value !== null) return this._value;
269
+ if (this.tagName === 'select') {
270
+ const opts = this.querySelectorAll('option');
271
+ const chosen = opts.find((o) => 'selected' in o.attrs) || opts[0];
272
+ return chosen ? (chosen.attrs.value !== undefined ? chosen.attrs.value : chosen.textContent) : '';
273
+ }
274
+ return this.attrs.value !== undefined ? this.attrs.value : '';
275
+ },
276
+ set value(v) { this._value = String(v); },
277
+
278
+ get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); },
279
+ set textContent(v) { this._text = String(v == null ? '' : v); this.children = []; this._html = null; },
280
+
281
+ get innerHTML() { return this._html === null ? '' : this._html; },
282
+ set innerHTML(v) { this._html = String(v); this._value = null; parseInto(this, this._html); },
283
+
284
+ setAttribute(k, v) { this.attrs[String(k).toLowerCase()] = String(v); },
285
+ getAttribute(k) { const v = this.attrs[String(k).toLowerCase()]; return v === undefined ? null : v; },
286
+ removeAttribute(k) { delete this.attrs[String(k).toLowerCase()]; },
287
+ appendChild(child) { child.parentNode = this; this.children.push(child); return child; },
288
+ remove() {
289
+ const p = this.parentNode;
290
+ if (!p) return;
291
+ p.children = p.children.filter((c) => c !== this);
292
+ this.parentNode = null;
293
+ },
294
+ querySelector(sel) { return walk(this, (c) => (matches(c, sel) ? c : null)); },
295
+ querySelectorAll(sel) {
296
+ const out = [];
297
+ walk(this, (c) => { if (matches(c, sel)) out.push(c); return null; });
298
+ return out;
299
+ },
300
+ closest(sel) {
301
+ let node = this;
302
+ while (node) { if (matches(node, sel)) return node; node = node.parentNode; }
303
+ return null;
304
+ },
305
+ addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn); },
306
+ focus() {},
307
+ fire(type, event) { (this.listeners[type] || []).forEach((fn) => fn(event)); },
308
+ };
309
+ for (const [k, v] of Object.entries(attrs)) {
310
+ if (!k.startsWith('data-')) continue;
311
+ el.dataset[k.slice(5).replace(/-([a-z])/g, (_, c) => c.toUpperCase())] = v;
312
+ }
313
+ return el;
314
+ }
315
+
316
+ // The reserved names a <form>'s named-field shorthand (form.title.value) must
317
+ // never shadow — everything else on the element is fair game, same as the DOM.
318
+ const FORM_FIELD_RESERVED = new Set(['tagName', 'attrs', 'children', 'parentNode', 'className', 'id', 'dataset',
319
+ 'disabled', 'style', 'listeners', 'value', 'textContent', 'innerHTML', 'name']);
320
+
321
+ export function parseInto(host, html) {
322
+ host.children = [];
323
+ host._text = '';
324
+ const stack = [host];
325
+ const re = /<(\/?)([a-zA-Z][\w-]*)([^>]*?)(\/?)>/g;
326
+ let last = 0;
327
+ let m;
328
+ const addText = (raw) => { if (raw) stack[stack.length - 1]._text += decodeEntities(raw); };
329
+ while ((m = re.exec(html))) {
330
+ addText(html.slice(last, m.index));
331
+ last = re.lastIndex;
332
+ const tag = m[2].toLowerCase();
333
+ if (m[1]) {
334
+ for (let i = stack.length - 1; i > 0; i--) if (stack[i].tagName === tag) { stack.length = i; break; }
335
+ continue;
336
+ }
337
+ const el = makeEl(tag, parseAttrs(m[3]));
338
+ stack[stack.length - 1].appendChild(el);
339
+ if (!m[4] && !VOID_TAGS.has(tag)) stack.push(el);
340
+ }
341
+ addText(html.slice(last));
342
+ // Named-field access on every form the markup just produced.
343
+ walk(host, (el) => {
344
+ if (el.tagName !== 'form') return null;
345
+ for (const field of el.querySelectorAll('input, select, textarea')) {
346
+ if (!field.name || FORM_FIELD_RESERVED.has(field.name) || field.name in el) continue;
347
+ el[field.name] = field;
348
+ }
349
+ return null;
350
+ });
351
+ }