@bongos/core 1.19.512 → 1.19.514

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 (42) hide show
  1. package/.bongos-core.json +77 -42
  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/docs/adr/0182-project-visibility-two-axes.md +2 -0
  8. package/docs/adr/0239-a-recommendation-is-a-table-that-carries-its-own-reason.md +177 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +41 -4
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +36 -35
  13. package/docs/copy-registry.json +93 -74
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  16. package/modules/hall-ui/public/hall-kit.js +309 -0
  17. package/modules/hall-ui/public/hall-render.js +10 -38
  18. package/modules/hall-ui/public/profile-charts.js +58 -65
  19. package/modules/hall-ui/public/profile.css +9 -18
  20. package/modules/hall-ui/public/style.css +101 -15
  21. package/modules/hall-ui/public/watch.css +6 -14
  22. package/modules/hall-ui/public/watch.html +1 -1
  23. package/modules/hall-ui/public/watch.js +139 -54
  24. package/modules/platform-identity/platform-identity.js +24 -20
  25. package/modules/platform-identity/projects-feed.js +142 -0
  26. package/modules/platform-identity/routes/projects.js +27 -8
  27. package/modules/platform-identity/tests/platform-identity.mjs +9 -5
  28. package/modules/provisioning/recommendations.js +260 -0
  29. package/modules/provisioning/routes/provisioning.js +29 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/src/module-api.js +1 -1
  33. package/tests/hall_audit.mjs +84 -0
  34. package/tests/hall_kit_charts.mjs +328 -0
  35. package/tests/hall_profile_world.mjs +27 -6
  36. package/tests/helpers.mjs +161 -0
  37. package/tests/joinability_invite_only.mjs +10 -2
  38. package/tests/projects_hub.mjs +5 -1
  39. package/tests/provisioning_recommendations.mjs +448 -0
  40. package/tests/task_detail_ui.mjs +1 -153
  41. package/tests/visible_projects_feed.mjs +320 -0
  42. package/tests/watch_applications_queue.mjs +381 -0
@@ -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
+ }
@@ -187,6 +187,14 @@ test('the Watch page offers the Archon an invite by username — the way in when
187
187
  assert.match(watch, /nobody can apply, so this is how people get in/);
188
188
  const gate = (watch.match(/function renderGate\(data, target\) \{[\s\S]*?\n \}\n/) || [''])[0];
189
189
  assert.ok(gate, 'renderGate() must exist');
190
- assert.equal((gate.match(/wireInviteForm\(target\)/g) || []).length, 2, 'the form is wired in BOTH branches — an empty queue and a listed one');
191
- assert.equal((gate.match(/inviteFormHtml\(\)/g) || []).length, 2, 'and rendered in both');
190
+ // The form used to be rendered and wired once per branch, and this counted the
191
+ // pair. Task 1002332 unified the empty and listed branches onto one `head`, so
192
+ // the count is now 1 and the invariant it stood for is STRONGER: the form is
193
+ // rendered and wired UNCONDITIONALLY, outside the split, so neither branch can
194
+ // drop it. tests/watch_applications_queue.mjs EXECUTES both cases ("an empty
195
+ // view still offers the invite form and the switch out of it") — the proof a
196
+ // source read like this one can only ever approximate.
197
+ assert.equal((gate.match(/inviteFormHtml\(\)/g) || []).length, 1, 'rendered once');
198
+ assert.match(gate, /const head = inviteFormHtml\(\)/, 'into the head BOTH branches are built from');
199
+ assert.match(gate, /\n {4}wireInviteForm\(target\);\n/, 'and wired at renderGate top level, never inside a branch');
192
200
  });
@@ -1858,7 +1858,11 @@ test('the kit proves the picker: a state renders it on the owner\'s feed-carried
1858
1858
 
1859
1859
  test('the feed carries the owner\'s choice through a seam: platform-identity resolves provisioning.projectPlanets optionally and spreads every platform-drawn key on every project (tasks 1003347, 1003352)', () => {
1860
1860
  const route = fs.readFileSync(path.join(ROOT, 'modules', 'platform-identity', 'routes', 'projects.js'), 'utf8');
1861
- assert.match(route, /api\.resolveOptional\('provisioning\.projectPlanets', async \(\) => \(\{\}\)\)\(projects\.map\(\(p\) => hostOfOrigin\(p\.origin\)\)\)/, 'optional — an instance without provisioning has no choices to carry');
1861
+ // R06 (task 1002325) narrowed the hosts asked about from `projects` to `identified`:
1862
+ // a stealth project is on the map ANONYMOUSLY (ADR 0182 D2a), so its host is kept out
1863
+ // of this enrichment entirely rather than resolved and then redacted. The seam itself
1864
+ // is unchanged — still optional, still host-matched, still spread on every project.
1865
+ assert.match(route, /api\.resolveOptional\('provisioning\.projectPlanets', async \(\) => \(\{\}\)\)\(identified\.map\(\(p\) => hostOfOrigin\(p\.origin\)\)\)/, 'optional — an instance without provisioning has no choices to carry');
1862
1866
  assert.match(route, /return \{ planet_template: c\.planet_template \|\| null, planet_accent: c\.planet_accent \|\| null,\r?\n\s*card_backdrop: c\.card_backdrop \|\| null \};/, 'the wire always carries every key — null = nothing chosen');
1863
1867
  assert.match(route, /\.\.\.planetOf\(p\),/, 'spread beside the stats');
1864
1868
  assert.doesNotMatch(route, /require\(['"][^'"]*provisioning/, 'never an import across modules');