@bongos/core 1.19.515 → 1.19.517

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 (33) hide show
  1. package/.bongos-core.json +56 -36
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +157 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +12 -3
  6. package/docs/api-reference.md +1 -1
  7. package/docs/copy-inventory.md +237 -229
  8. package/docs/copy-registry.json +387 -303
  9. package/docs/module-api-changelog.md +4 -0
  10. package/modules/hall-ui/public/drachmae.html +3 -0
  11. package/modules/hall-ui/public/drachmae.js +138 -2
  12. package/modules/hall-ui/public/hall-kit.js +45 -7
  13. package/modules/hall-ui/public/profile-charts.js +6 -13
  14. package/modules/hall-ui/public/profile.css +6 -35
  15. package/modules/hall-ui/public/style.css +35 -0
  16. package/modules/provisioning/migrations/provisioning_014_module_selection.sql +60 -0
  17. package/modules/provisioning/provisioning.js +24 -4
  18. package/modules/provisioning/routes/provisioning.js +59 -2
  19. package/modules/provisioning/starter-bundles.js +91 -0
  20. package/modules/public-landing/public/assets/cosmos.css +1 -1
  21. package/modules/public-landing/public/projects.html +230 -35
  22. package/modules/public-landing/public/projects.probes.json +2 -2
  23. package/modules/public-landing/public/projects.states.json +5 -4
  24. package/modules/ui-design/kit/serve.js +32 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/src/module-api.js +1 -1
  28. package/tests/hall_kit_charts.mjs +49 -0
  29. package/tests/hall_reference_world.mjs +129 -0
  30. package/tests/projects_hub.mjs +72 -7
  31. package/tests/projects_hub_module_picker.mjs +369 -0
  32. package/tests/provisioning_module_selection.mjs +286 -0
  33. package/tests/provisioning_starter_bundles.mjs +131 -0
@@ -108,6 +108,32 @@ const FEEDS = {
108
108
  const AUTHED = { '/my-projects': 'my-projects' };
109
109
  const UNAUTH = { error: { code: 'unauthenticated', message: 'unauthenticated' } };
110
110
 
111
+ // GET /provisioning/starter-bundles as the create wizard's module picker reads it
112
+ // (task 1002339 / BV1.R19's route, ADR 0237). A fixture rather than an import —
113
+ // the kit stub deliberately requires no module — kept honest by
114
+ // tests/provisioning_starter_bundles.mjs, which reads this file and compares it
115
+ // against the real declaration. `core` is the count the panel's clause names, so
116
+ // only its length matters here; `optional` and `bundles` are byte-for-byte.
117
+ const STARTER_BUNDLES_FIXTURE = {
118
+ core: [
119
+ 'autonomy', 'builder-settings', 'copy-desk', 'economy', 'government', 'grading',
120
+ 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'public-landing',
121
+ 'security', 'sessions', 'status-ui', 'ui-design',
122
+ ],
123
+ bundles: [
124
+ { type: 'game', bundle: ['dev-box', 'discord'] },
125
+ { type: 'research', bundle: ['agents'] },
126
+ { type: 'business', bundle: ['agents', 'dev-box'] },
127
+ { type: 'non-profit', bundle: ['discord'] },
128
+ { type: 'not-sure', bundle: [] },
129
+ ],
130
+ optional: [
131
+ { key: 'agents', label: 'Extra agents', summary: 'More AI agents alongside the ones every project gets, for reading, research and review.' },
132
+ { key: 'dev-box', label: 'Dev boxes', summary: 'A cloud workspace where a change can be tried out before anyone else sees it. Nothing is charged until someone asks for one.' },
133
+ { key: 'discord', label: 'Discord', summary: 'A Discord server that carries your build news, and lets people send ideas and bug reports back.' },
134
+ ],
135
+ };
136
+
111
137
  function json(res, status, body) { res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(body)); }
112
138
  function fixture(name, res, origin) {
113
139
  if (FAIL.has(name)) return json(res, 500, { error: { code: 'stub_failure', message: `FAIL=${name}` } });
@@ -277,6 +303,12 @@ const server = http.createServer((req, res) => {
277
303
  }
278
304
  if (rest.startsWith('/github/repo')) return AUTH ? json(res, 200, { repo: { private: false, admin: true } }) : json(res, 401, UNAUTH);
279
305
  if (rest.startsWith('/provisioning/slug-available')) return json(res, 200, { available: true }); /* the wizard's handle check (task 1003346): every handle is free on the stub */
306
+ /* the module picker's one read (task 1002339). A FIXTURE, like everything
307
+ else here — the stub imports no module — but not a free-hand one:
308
+ tests/provisioning_starter_bundles.mjs pins it against the real
309
+ starter-bundles.js declaration, so the picture the kit renders and the
310
+ payload the route serves cannot drift apart silently. */
311
+ if (rest === '/provisioning/starter-bundles') return json(res, 200, STARTER_BUNDLES_FIXTURE);
280
312
  if (rest === '/auth/logout') return json(res, 200, { ok: true });
281
313
  if (rest.startsWith('/auth/web/start')) { res.writeHead(302, { location: PREFIX }); return res.end(); }
282
314
  if (rest === '/instance') { const b = pages.size ? pages.values().next().value.brand : {}; return json(res, 200, { brand: { productName: (b && b.productName) || '', project: {} }, modules: {} }); }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.515",
3
+ "version": "1.19.517",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.515",
9
+ "version": "1.19.517",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.515",
3
+ "version": "1.19.517",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.515'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.517'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -76,6 +76,55 @@ test('cumulate is a running total that survives holes', () => {
76
76
  assert.deepEqual(kit.cumulate([]), []);
77
77
  });
78
78
 
79
+ test('cumulateByDay groups a day, cumulates it, and drops what will not parse', () => {
80
+ // The third caller of this shape put it in the kit (task 1003446): the
81
+ // profile's two lines and /drachmae's treasury.
82
+ const pts = kit.cumulateByDay([
83
+ { day: '2026-05-02T10:00:00Z', v: 1 },
84
+ { day: '2026-05-02T22:00:00Z', v: 2 }, // same day → one point
85
+ { day: '2026-05-04T00:00:00Z', v: 4 },
86
+ ], { valueOf: (r) => r.v });
87
+ assert.deepEqual(pts.map((p) => p.value), [3, 7], 'summed within a day, cumulative across');
88
+ assert.equal(pts.length, 2, 'a day with no row is not invented');
89
+ assert.ok(pts[0].x < pts[1].x, 'ascending');
90
+ });
91
+
92
+ test('an unparsable date is dropped, not counted at the epoch', () => {
93
+ // Counted at 0 it would stretch the axis back to 1970 and flatten every real
94
+ // point onto the floor — the failure mode is a chart, not an exception.
95
+ const pts = kit.cumulateByDay([
96
+ { day: 'not a date', v: 99 },
97
+ { day: '2026-05-02T10:00:00Z', v: 5 },
98
+ ], { valueOf: (r) => r.v });
99
+ assert.deepEqual(pts.map((p) => p.value), [5]);
100
+ assert.ok(pts[0].x > Date.parse('2020-01-01'), 'and the axis starts in this decade');
101
+ });
102
+
103
+ test('cumulateByDay reads the date column the caller names', () => {
104
+ // shipped_at on an activity row, recorded_at on a credits row, day on a
105
+ // gap-filled feed — only the caller knows which.
106
+ const pts = kit.cumulateByDay(
107
+ [{ recorded_at: '2026-05-02T10:00:00Z', delta: 8 }],
108
+ { dateOf: (r) => r.recorded_at, valueOf: (r) => r.delta },
109
+ );
110
+ assert.deepEqual(pts.map((p) => p.value), [8]);
111
+ assert.deepEqual(kit.cumulateByDay(null), [], 'and no rows is no points');
112
+ });
113
+
114
+ test('a plot with no readout ships no focus stop and no dead crosshair', () => {
115
+ // A role="img" with tabindex="0" is a focus stop a keyboard reader lands on
116
+ // with nothing to do; a crosshair nothing moves only looks like a feature.
117
+ const inert = kit.plotSvgHtml({ series: '<path/>', ariaLabel: 'Spent', interactive: false });
118
+ assert.doesNotMatch(inert, /tabindex/);
119
+ assert.doesNotMatch(inert, /kit-plot__hair/);
120
+ assert.match(inert, /aria-label="Spent"/, 'the label is still the reading');
121
+ assert.match(inert, /kit-plot__axis/, 'and the axis is still drawn');
122
+ // The default stays interactive, because the profile wires a readout.
123
+ const live = kit.plotSvgHtml({ series: '<path/>' });
124
+ assert.match(live, /tabindex="0"/);
125
+ assert.match(live, /kit-plot__hair/);
126
+ });
127
+
79
128
  test('the line is scaled to its own min and max, and a flat series sits mid-box', () => {
80
129
  const g = kit.linePoints([0, 10], { width: 100, height: 50, pad: 0 });
81
130
  const ys = g.points.split(' ').map((p) => Number(p.split(',')[1]));
@@ -220,3 +220,132 @@ test('the reference renders exactly one h1, the page head\'s', () => {
220
220
  assert.equal([...html.matchAll(/<h1\b/g)].length, 1);
221
221
  assert.match(html, /<h1 class="page-head__h">/);
222
222
  });
223
+
224
+ // ---- 9. the treasury section (task 1003446) --------------------------------
225
+ //
226
+ // The sharpest version of goal 1000072's thesis. /drachmae is labelled Economy
227
+ // in the nav and was a markdown reader with ZERO live numbers, while the
228
+ // signed-out status page already drew this very endpoint's cumulative line and
229
+ // the Work board fetched the same payload to read ONE scalar off it. The
230
+ // stranger got the chart; the builder got the number.
231
+ //
232
+ // The section's ARITHMETIC is not asserted here — it is the kit's
233
+ // (`cumulateByDay`, `timeStepSeriesHtml`, `barSeriesHtml`) and
234
+ // tests/hall_kit_charts.mjs runs the real kit against it. What these hold is
235
+ // the page's half of the contract, the half a kit test cannot see.
236
+
237
+ const costSummaryFixture = JSON.parse(
238
+ fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'fixtures', 'cost-summary.json'), 'utf8'),
239
+ );
240
+
241
+ test('the treasury reads the endpoint that already carried the series — no new route', () => {
242
+ // The whole point of the task: the payload had both series all along, and the
243
+ // route's own comment calls daily[] "the treasury chart's cumulative line".
244
+ assert.match(drachmaeJs, /TREASURY_URL = '\/api\/gds\/public\/cost-summary'/);
245
+ assert.ok(costSummaryFixture.daily.length, 'the payload really carries daily[]');
246
+ assert.ok(costSummaryFixture.daily_credits.length, 'and daily_credits[]');
247
+ assert.ok(Number(costSummaryFixture.total_usd) > 0, 'and the totals the prose states');
248
+ assert.ok(Number(costSummaryFixture.total_value_credits) > 0);
249
+ });
250
+
251
+ test('RUN: the cumulative line ends exactly on the total the prose states', async () => {
252
+ // The invariant that matters, and the one a source-text assertion cannot
253
+ // reach: the chart and the sentence above it are derived from the SAME
254
+ // payload, so they can never disagree. Run against the real kit and the real
255
+ // checked-in summary, which is where a units or gap-fill mistake would show
256
+ // up as a line ending somewhere other than the headline number.
257
+ const kitPath = path.join(HALL, 'hall-kit.js');
258
+ const kit = await import(`file://${kitPath.replace(/\\/g, '/')}`).then((m) => m.default);
259
+ const spend = kit.cumulateByDay(costSummaryFixture.daily, { valueOf: (r) => r.amount_usd });
260
+ const earned = kit.cumulateByDay(costSummaryFixture.daily_credits, { valueOf: (r) => r.credits });
261
+ assert.ok(spend.length > 1 && earned.length > 1, 'both series have depth');
262
+ assert.ok(Math.abs(spend[spend.length - 1].value - Number(costSummaryFixture.total_usd)) < 1,
263
+ 'the spend line ends on total_usd');
264
+ assert.equal(earned[earned.length - 1].value, Number(costSummaryFixture.total_value_credits),
265
+ 'and the awarded line ends on total_value_credits');
266
+ // Whatever the plot draws from that must be finite — a NaN in a path `d`
267
+ // renders as an invisible chart with no error anywhere.
268
+ const series = kit.timeStepSeriesHtml(spend, {
269
+ x0: spend[0].x, x1: spend[spend.length - 1].x, yMax: spend[spend.length - 1].value,
270
+ });
271
+ assert.doesNotMatch(series.body, /NaN|Infinity/);
272
+ assert.doesNotMatch(
273
+ kit.barSeriesHtml(costSummaryFixture.by_category.map((c) => ({ label: c.category, value: c.total_usd }))),
274
+ /NaN|Infinity/,
275
+ );
276
+ });
277
+
278
+ test('the page loads the chart primitive BEFORE the script that reads it', () => {
279
+ // window.OTBKit read at insert time with the kit loaded after it is an empty
280
+ // section in production and a green unit suite.
281
+ const loaded = [...drachmaeHtml.matchAll(/<script[^>]+src="([^"?]+)/g)].map((m) => m[1].split('/').pop());
282
+ const kitAt = loaded.indexOf('hall-kit.js');
283
+ const pageAt = loaded.indexOf('drachmae.js');
284
+ assert.ok(kitAt > -1, 'drachmae.html must load hall-kit.js');
285
+ assert.ok(pageAt > -1, 'and drachmae.js');
286
+ assert.ok(kitAt < pageAt, 'kit first');
287
+ });
288
+
289
+ test('every mark on the page is drawn by the kit, none by hand', () => {
290
+ const js = dejscomment(drachmaeJs);
291
+ for (const fn of ['cumulateByDay', 'timeStepSeriesHtml', 'plotSvgHtml', 'plotValueLabelHtml', 'barSeriesHtml']) {
292
+ assert.match(js, new RegExp(`(kit|window\\.OTBKit)\\.${fn}\\(`), `the treasury draws through ${fn}`);
293
+ }
294
+ // The §6b audit rule holds this globally; asserted here too because THIS is
295
+ // the page that had no chart at all and so the easiest place to hand-roll one.
296
+ assert.doesNotMatch(js, /<(?:path|polyline|rect)\b[^>]*\b(?:d|points)="\$\{/,
297
+ 'no hand-built SVG geometry on the reference page');
298
+ });
299
+
300
+ test('the section degrades to NOTHING rather than an empty panel', () => {
301
+ const js = dejscomment(drachmaeJs);
302
+ // The bounty section's contract, kept: a failed read leaves the prose alone
303
+ // and adds no outline entry that leads nowhere.
304
+ const fn = js.slice(js.indexOf('async function insertTreasurySection'));
305
+ const body = fn.slice(0, fn.indexOf('\n }\n'));
306
+ assert.match(body, /console\.warn\([^)]*cost summary unavailable/);
307
+ assert.equal((body.match(/return false;/g) || []).length, 3,
308
+ 'three honest exits: no kit, an unreadable summary, and a treasury with nothing in it');
309
+ assert.match(body, /if \(!spend\.length && !earned\.length\) return false;/,
310
+ 'both series empty is one honest nothing, not two empty plots');
311
+ });
312
+
313
+ test('the plots ship no focus stop, because nothing here wires a readout', () => {
314
+ // A role="img" with tabindex="0" is a focus stop a keyboard reader lands on
315
+ // with nothing to do, and a crosshair nothing moves only looks like a
316
+ // feature. The kit half is asserted in tests/hall_kit_charts.mjs.
317
+ assert.match(dejscomment(drachmaeJs), /interactive: false/);
318
+ });
319
+
320
+ test('the currency is the branding pack\'s, never a hardcoded word', () => {
321
+ const js = dejscomment(drachmaeJs);
322
+ assert.match(js, /__BRANDING__ && window\.__BRANDING__\.currency/);
323
+ assert.match(js, /\|\| 'credits'/, 'with a neutral fallback, not a world-specific one');
324
+ assert.doesNotMatch(js.replace(/\|\| 'credits'/g, ''), /drachmae'/i,
325
+ 'the instance\'s own currency name is not spelled into the core page');
326
+ });
327
+
328
+ test('the treasury lands ahead of the rate card, so the doc still ends on "dig deeper"', () => {
329
+ const js = dejscomment(drachmaeJs);
330
+ // The bounty section goes in first and the treasury anchors on its id, so the
331
+ // order is prose → what happened → the published rates → the closing links.
332
+ const boot = js.slice(js.indexOf('async function boot()'));
333
+ assert.ok(boot.indexOf('insertBountySection(article)') < boot.indexOf('insertTreasurySection(article)'),
334
+ 'the rate card is inserted first, so the treasury can anchor ahead of it');
335
+ assert.match(js, /article\.querySelector\(`#\$\{BOUNTY_SECTION_ID\}`\)/);
336
+ assert.match(js, /CLOSING_H2\.test/, 'and it falls back to the closing links, then to appending');
337
+ });
338
+
339
+ test('the figure chrome is shared, because a second page now draws figures', () => {
340
+ // .pchart* moved from profile.css to style.css with this task. drachmae.html
341
+ // loads style.css and NOT profile.css, so a copy left behind would render
342
+ // this page's figures unstyled.
343
+ const style = decomment(read('style.css'));
344
+ const profile = decomment(read('profile.css'));
345
+ for (const sel of ['.pcharts', '.pchart ', '.pchart__title', '.pchart__twin', '.pchart__foot']) {
346
+ assert.ok(style.includes(sel), `${sel} is in the shared sheet`);
347
+ }
348
+ assert.ok(!/^\.pcharts\s*\{/m.test(profile), 'and not left duplicated in profile.css');
349
+ assert.match(drachmaeHtml, /style\.css/);
350
+ assert.doesNotMatch(drachmaeHtml, /profile\.css/, 'the reference page must not need the profile sheet');
351
+ });
@@ -706,14 +706,15 @@ test('the About answers travel on the create request, and an empty one is OMITTE
706
706
  });
707
707
 
708
708
  test('a renumbering of the wizard invalidates old drafts — the key carries the version', () => {
709
- // task 1002772 renumbered once and bumped to -v2; task 1002334 renumbered again.
710
- // A draft replayed under a different numbering lands on the wrong panel with the
711
- // rail already marking the skipped steps done.
709
+ // task 1002772 renumbered once and bumped to -v2; task 1002334 renumbered again;
710
+ // task 1002339 (the module picker) is the third. A draft replayed under a
711
+ // different numbering lands on the wrong panel with the rail already marking
712
+ // the skipped steps done.
712
713
  const key = (SCRIPT.match(/var DRAFT_KEY = '([^']+)'/) || [, ''])[1];
713
714
  assert.match(key, /-v\d+$/, 'the draft key is versioned');
714
715
  const panels = (SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1];
715
716
  const count = [...panels.matchAll(/\d+:\s*'panel-/g)].length;
716
- assert.equal(count, 8, 'gate + six numbered steps + done — bump DRAFT_KEY when this changes');
717
+ assert.equal(count, 9, 'gate + seven numbered steps + done — bump DRAFT_KEY when this changes');
717
718
  });
718
719
 
719
720
  test('every row lays the same five parts — the ledger has no ragged row', () => {
@@ -2058,7 +2059,9 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
2058
2059
  assert.ok(at > -1, 'the #view-new block exists in cosmos.css');
2059
2060
  const block = COSMOS.slice(at);
2060
2061
  assert.match(block, /#view-new \.card\{background:none;border-radius:0;box-shadow:none;/, 'no card ground');
2061
- assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(6,1fr\);/, 'the strip lays its six steps on six tracks');
2062
+ // seven tracks since task 1002339 added the Modules step; the count is the
2063
+ // rail's own, so it moves with the panels map rather than being a magic number
2064
+ assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(7,1fr\);/, 'the strip lays a track per numbered step');
2062
2065
  assert.match(block, /#view-new \.steps li::before\{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;/, 'the connector becomes the 7px dot');
2063
2066
  assert.match(block, /#view-new \.steps li\.now::before\{background:var\(--accent\);border-color:var\(--accent\);outline:4px solid/, 'the lit step carries the accent and an outline halo');
2064
2067
  assert.match(block, /#view-new \.panel h1\{font-weight:300;[^}]*text-transform:lowercase;/, 'the step title in the display voice, lowercase');
@@ -2105,7 +2108,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2105
2108
  assert.match(block, /\.addrBig code\{display:block;font-family:var\(--font-display\);/, 'the address stands in the display voice');
2106
2109
  assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
2107
2110
  assert.doesNotMatch(block, /box-shadow:(?!none)/, 'no box shadow in the wizard block');
2108
- assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="5">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
2111
+ assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="6">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
2109
2112
  assert.match(HUB, /<h2 class="stepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style');
2110
2113
  // what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
2111
2114
  assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
@@ -2114,7 +2117,69 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2114
2117
  assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \? 'adopt' : 'greenfield'/, 'the onboard mode is unchanged');
2115
2118
  // the kit walks every step and files against the stub
2116
2119
  const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
2117
- for (const s of ['type', 'about', 'home', 'address', 'review', 'done']) assert.ok(states['in-new-' + s], `the ${s} step has a kit state`);
2120
+ for (const s of ['type', 'about', 'modules', 'home', 'address', 'review', 'done']) assert.ok(states['in-new-' + s], `the ${s} step has a kit state`);
2118
2121
  const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
2119
2122
  assert.ok(probes.some((p) => /the wizard driver \(task 1003346\)/.test(p.name) && p.steps.some((st) => st[1] === '#doCreate')), 'the wizard driver files a project end to end');
2120
2123
  });
2124
+
2125
+ test('the Modules step ships as real markup and is skippable like About (task 1002339)', () => {
2126
+ const panel = (HUB.match(/<section class="panel" id="panel-modules"[\s\S]*?<\/section>/) || [''])[0];
2127
+ assert.ok(panel, '#panel-modules ships as real markup, not built by script');
2128
+ assert.match(panel, /id="modCards"/, 'the band the picker paints its rows into');
2129
+ assert.match(panel, /class="typeCards"/, 'and it is the SAME band the type and team questions use — no new grammar');
2130
+ assert.match(panel, /id="modCoreNote"/, 'the always-on core is stated on the panel');
2131
+ // the skip must be its own control, not "leave it and press Next" — the rule
2132
+ // task 1002334 wrote for the one other optional step
2133
+ assert.match(panel, /id="skipModules"/, 'the skip is a control, not an inference');
2134
+ assert.match(panel, /Optional/, 'and the panel says so before it asks');
2135
+ // nothing in validateStep may gate it
2136
+ const validate = SCRIPT.slice(SCRIPT.indexOf('function validateStep('));
2137
+ const map = SCRIPT.match(/var panels = \{([^}]*)\}/)[1];
2138
+ const modStep = Number((map.match(/(\d+):\s*'panel-modules'/) || [, 0])[1]);
2139
+ assert.ok(modStep > 0, 'the Modules panel is in the map');
2140
+ assert.ok(!validate.slice(0, validate.indexOf('function ', 1)).includes(`if (step === ${modStep}) {`),
2141
+ 'the optional step has no validation clause — Next past an untouched picker is the point');
2142
+ });
2143
+
2144
+ test('the picker reads ONE public declaration, and it is the one the bundles come from (task 1002339)', () => {
2145
+ // A second source for "what may be chosen" is exactly what would let a
2146
+ // preselect name a module the panel has no row for (ADR 0237 §5).
2147
+ assert.match(SCRIPT, /fetch\(API \+ '\/provisioning\/starter-bundles'/, 'the picker reads the starter-bundles route');
2148
+ assert.equal((SCRIPT.match(/'\/provisioning\/starter-bundles'/g) || []).length, 1, 'exactly once');
2149
+ // and it is a public read — no other module-catalog call joins it
2150
+ assert.doesNotMatch(SCRIPT, /fetch\(API \+ '\/modules'/, 'no signed-in catalog read from a signed-out wizard');
2151
+ });
2152
+
2153
+ test('the picker\'s answer rides the create request, and an untouched picker sends nothing (task 1002339)', () => {
2154
+ // absent means SKIPPED, and the server then applies the same starter bundle
2155
+ // the panel preselected — the same contract `description` and `detail` carry
2156
+ const body = SCRIPT.slice(SCRIPT.indexOf('body: JSON.stringify({'));
2157
+ assert.ok(body.includes('modules: modulesForCreate(),'), 'the create body carries the picker\'s ids');
2158
+ assert.match(SCRIPT, /function modulesForCreate\(\) \{\s*return state\.modules === null \? undefined : state\.modules\.slice\(\);/,
2159
+ 'and modulesForCreate() omits the key while nothing has been said');
2160
+ });
2161
+
2162
+ test('the review names what the project will actually come with (task 1002339)', () => {
2163
+ // The picker is optional, so the last screen before Create must show the set
2164
+ // the owner is about to get — their picks, or the preselect they walked past.
2165
+ const render = SCRIPT.slice(SCRIPT.indexOf('function renderReview()'));
2166
+ const rows = render.slice(0, render.indexOf('$(\'reviewList\').innerHTML'));
2167
+ assert.match(rows, /\['Extras', extrasRow\(\)\]/, 'the ledger carries an Extras row');
2168
+ // What that row SAYS has three cases (picked / preselect / catalog not loaded)
2169
+ // and they live with the picker's own rules, executed by
2170
+ // tests/projects_hub_module_picker.mjs. This pins that the review defers to them.
2171
+ assert.match(SCRIPT, /function extrasRow\(\) \{/, 'and it is the picker that decides what it says');
2172
+ assert.match(SCRIPT, /'None — just the core'/, 'an empty selection reads as a fact, not a blank');
2173
+ });
2174
+
2175
+ test('the kit stub answers the picker\'s read, so the panel renders in the design kit (task 1002339)', () => {
2176
+ // The stub is not the server (this module's CLAUDE.md trap): it serves
2177
+ // fixtures. A 404 here would have rendered the couldn't-load clause in every
2178
+ // state and probe, and nobody would have seen the picker at all.
2179
+ const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
2180
+ assert.match(stub, /rest === '\/provisioning\/starter-bundles'/, 'the stub serves the picker\'s read');
2181
+ const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
2182
+ assert.ok(probes.some((p) => /the wizard driver \(task 1003346\)/.test(p.name)
2183
+ && p.steps.some((st) => st[1] === '#panel-modules button[type=submit]')),
2184
+ 'and the end-to-end driver walks the new step rather than stopping at it');
2185
+ });