@bongos/core 1.19.528 → 1.19.530

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.
@@ -106,6 +106,60 @@ const OPTIONAL_MODULES = Object.freeze([
106
106
  // so the declaration above stays the single place a choice is written down.
107
107
  const OPTIONAL_MODULE_KEYS = Object.freeze(OPTIONAL_MODULES.map((m) => m.key));
108
108
 
109
+ // How the interview's team-shape answer ADJUSTS the type's starter bundle, in
110
+ // application order. Each entry is one condition (`teamShape`) and one effect on
111
+ // ONE opt-in module, carrying the sentence the picker shows when it fires.
112
+ //
113
+ // WHY THE TABLE LIVES HERE and not in recommendations.js beside the governance
114
+ // and invite tables it shipped with (task 1002336, ADR 0239). A bundle adjustment
115
+ // is not advice about the bundle — it IS the bundle, once the owner has answered.
116
+ // `effectiveModules` below is the function ADR 0240 §2 leans the whole skip
117
+ // contract on ("the preselect and the fallback are the same declaration — one
118
+ // function, called twice"), and a rule the picker applies but that function does
119
+ // not is exactly two lists kept in agreement by hand. So the rules sit at the
120
+ // bundle layer, `effectiveModules` applies them, and recommendations.js re-exports
121
+ // them unchanged — its governance preview and invite seam are genuinely advice and
122
+ // stay there. Decision: ADR 0243 (BV1.R22, task 1002341).
123
+ //
124
+ // Deliberately tiny: ADR 0237 already made the per-type preset the opinion, and
125
+ // today's opt-in catalog is three modules wide — so this table's job is to correct
126
+ // a preset the owner's answer contradicts, not to re-derive it.
127
+ //
128
+ // A rule may only ever name an OPT-IN module. Adding or removing an always-on one
129
+ // would be a toggle that toggles nothing (ADR 0237 §3's argument, and
130
+ // `tests/provisioning_recommendations.mjs` fails the build over it).
131
+ const TEAM_SHAPE_RULES = Object.freeze([
132
+ Object.freeze({
133
+ id: 'a-community-needs-somewhere-to-gather',
134
+ teamShape: 'community',
135
+ effect: 'add',
136
+ module: 'discord',
137
+ reason: 'You said an open community is building this, so somewhere for it to gather is switched on.',
138
+ }),
139
+ Object.freeze({
140
+ id: 'a-team-shares-one-environment',
141
+ teamShape: 'small-team',
142
+ effect: 'add',
143
+ module: 'dev-box',
144
+ reason: 'A small team gets one shared environment to build in, so nobody is debugging their own laptop.',
145
+ }),
146
+ Object.freeze({
147
+ id: 'building-alone-needs-no-shared-environment',
148
+ teamShape: 'solo',
149
+ effect: 'remove',
150
+ module: 'dev-box',
151
+ reason: 'You are building this alone, so the shared cloud environment is off — your own machine is the setup.',
152
+ }),
153
+ ]);
154
+
155
+ // The team shapes the rules above can fire on. Derived from the table rather than
156
+ // imported from provisioning.js's PROJECT_DETAIL_VOCAB, because importing it would
157
+ // point this file UP at the module that requires it. It costs nothing to derive:
158
+ // an off-vocabulary shape matches no rule and adjusts nothing, which is already
159
+ // the right answer — `tests/provisioning_recommendations.mjs` pins the two lists
160
+ // against each other so a fourth interview answer cannot arrive unhandled.
161
+ const RULED_TEAM_SHAPES = Object.freeze([...new Set(TEAM_SHAPE_RULES.map((r) => r.teamShape))]);
162
+
109
163
  // The curated presets, keyed by the declared project type (the closed vocabulary
110
164
  // owner-decided 2026-08-13 and mirrored in provisioning.PROJECT_TYPES + the
111
165
  // provisioning_007 CHECK). `label` and `summary` are the owner-facing copy the
@@ -192,6 +246,69 @@ function optionalModules() {
192
246
  return OPTIONAL_MODULES.map((m) => ({ ...m }));
193
247
  }
194
248
 
249
+ // Apply the team-shape rules to one type's opt-in bundle. Returns the adjusted
250
+ // list plus the adjustments that ACTUALLY changed it — a rule whose module the
251
+ // bundle already carries (or already lacks) records nothing, because a stated
252
+ // reason for a change that did not happen is a lie the owner cannot check.
253
+ // A null/unknown/off-vocabulary shape fires no rule and adjusts nothing, which is
254
+ // the same answer `normalizeProjectDetail` gives such a value on the way in.
255
+ // Never touches the always-on core. PURE.
256
+ function adjustBundle(bundle, teamShape) {
257
+ const held = new Set(bundle);
258
+ const adjustments = [];
259
+ for (const rule of TEAM_SHAPE_RULES) {
260
+ if (rule.teamShape !== teamShape) continue;
261
+ if (rule.effect === 'add' && !held.has(rule.module)) held.add(rule.module);
262
+ else if (rule.effect === 'remove' && held.has(rule.module)) held.delete(rule.module);
263
+ else continue;
264
+ adjustments.push({ rule: rule.id, effect: rule.effect, module: rule.module, reason: rule.reason });
265
+ }
266
+ // CATALOG order, not append order — `normalizeModuleSelection` stores a chosen
267
+ // set this way and the picker re-derives its toggles this way, so an adjusted
268
+ // bundle that appended would make the same set of modules read as a different
269
+ // list depending on whether the owner answered the picker or skipped it.
270
+ return { modules: OPTIONAL_MODULE_KEYS.filter((k) => held.has(k)), adjustments };
271
+ }
272
+
273
+ // What the creation flow preselects for one set of ANSWERS — `bundleForProjectType`
274
+ // with the team-shape rules applied, in the same `{ type, label, summary, core,
275
+ // bundle, modules }` shape plus the `adjustments` delta that got it there.
276
+ //
277
+ // This is the single resolver the picker paints from and `effectiveModules` falls
278
+ // back to, so an owner who answers the interview and then skips the picker gets
279
+ // the set the panel was showing them (ADR 0240 §2's property, extended to the
280
+ // interview's answer — BV1.R22, ADR 0243). PURE; arrays are fresh.
281
+ function bundleForAnswers(type, teamShape) {
282
+ const preset = bundleForProjectType(type);
283
+ const { modules, adjustments } = adjustBundle(preset.bundle, teamShape);
284
+ return {
285
+ ...preset,
286
+ bundle: modules,
287
+ modules: [...ALWAYS_ON_CORE, ...modules],
288
+ adjustments,
289
+ };
290
+ }
291
+
292
+ // Every curated bundle resolved for every team shape the rules can fire on, plus
293
+ // the no-answer resolution — the whole preselect table in ONE read.
294
+ //
295
+ // The picker needs this shape rather than a per-answer query because ADR 0240 §4's
296
+ // rule is that what may be chosen and what is preselected have to arrive together
297
+ // or the panel can preselect a key it has no row for. The owner flips the type
298
+ // question and the team-shape question freely on two earlier panels; re-preselecting
299
+ // must not cost a round trip, and a round trip it did pay could land after they had
300
+ // moved on. PURE — five types by four answers is twenty small rows.
301
+ function starterBundleAnswerTable() {
302
+ return allStarterBundles().map((preset) => {
303
+ const byTeamShape = {};
304
+ for (const shape of RULED_TEAM_SHAPES) {
305
+ const { modules, adjustments } = adjustBundle(preset.bundle, shape);
306
+ byTeamShape[shape] = { bundle: modules, adjustments };
307
+ }
308
+ return { ...preset, byTeamShape };
309
+ });
310
+ }
311
+
195
312
  // A MODULE SELECTION as it may be STORED (BV1.R20, task 1002339): opt-in keys
196
313
  // only, deduped, in catalog order. Everything else is dropped — an always-on
197
314
  // module (it was never a choice), a hub-only one (the control plane's own), and
@@ -218,22 +335,39 @@ function normalizeModuleSelection(input) {
218
335
 
219
336
  // What a row's modules READ as: the always-on floor, plus whatever the owner
220
337
  // chose — or, when they never answered, the starter bundle for their declared
221
- // type. `source` says which of the two it is, so a surface can tell a chosen
222
- // empty set from an unanswered one without re-deriving the rule.
338
+ // type ADJUSTED by what they told the interview. `source` says which of the two
339
+ // it is, so a surface can tell a chosen empty set from an unanswered one without
340
+ // re-deriving the rule.
223
341
  //
224
342
  // This is what makes skipping the picker a supported default rather than a
225
343
  // project with nothing on it: the flow's preselect and the server's fallback are
226
344
  // the SAME declaration, so the owner gets exactly the set the panel showed them.
227
- // PURE — takes a row-shaped `{ type, modules }`, touches no database.
345
+ // The team-shape adjustment is INSIDE that fallback (BV1.R22, ADR 0243) for
346
+ // exactly that reason — the picker paints the adjusted set, so a picker skipped
347
+ // after an answered interview has to resolve to the adjusted set too, or the
348
+ // panel's promise stops being true the moment the interview is answered.
349
+ //
350
+ // Reads `detail.team_shape` straight off the row rather than through
351
+ // provisioning.js's `normalizeProjectDetail` — importing it would point this file
352
+ // up at the module that requires it, and it would buy nothing: an off-vocabulary
353
+ // shape fires no rule and adjusts nothing, which is the answer the normalizer
354
+ // would have produced anyway.
355
+ //
356
+ // PURE — takes a row-shaped `{ type, modules, detail }`, touches no database.
228
357
  function effectiveModules(row) {
229
358
  const chosen = normalizeModuleSelection(row && row.modules);
230
- const preset = bundleForProjectType(row && row.type);
359
+ const detail = row && row.detail && typeof row.detail === 'object' ? row.detail : null;
360
+ const preset = bundleForAnswers(row && row.type, detail ? detail.team_shape : null);
231
361
  const selected = chosen === null ? preset.bundle : chosen;
232
362
  return {
233
363
  core: [...ALWAYS_ON_CORE],
234
364
  selected,
235
365
  modules: [...ALWAYS_ON_CORE, ...selected],
236
366
  source: chosen === null ? 'bundle' : 'chosen',
367
+ // Why the unanswered set is what it is, in the owner's own words — empty when
368
+ // nothing adjusted it, and never populated for a `chosen` set, because an
369
+ // owner's own toggle needs no reason given back to them.
370
+ adjustments: chosen === null ? preset.adjustments : [],
237
371
  };
238
372
  }
239
373
 
@@ -242,9 +376,14 @@ module.exports = {
242
376
  HUB_ONLY_MODULES,
243
377
  OPTIONAL_MODULES,
244
378
  STARTER_BUNDLES,
379
+ TEAM_SHAPE_RULES,
380
+ RULED_TEAM_SHAPES,
245
381
  bundleForProjectType,
246
382
  allStarterBundles,
247
383
  optionalModules,
384
+ adjustBundle,
385
+ bundleForAnswers,
386
+ starterBundleAnswerTable,
248
387
  normalizeModuleSelection,
249
388
  effectiveModules,
250
389
  };
@@ -328,6 +328,14 @@ summary{min-height:24px;padding:3px 0;}
328
328
  .tpill:hover::before{border-color:var(--ink-faint);}
329
329
  .tpill[aria-pressed=true]{background:none;border-color:var(--rule-soft);color:var(--ink);}
330
330
  .tpill[aria-pressed=true]::before{background:var(--accent);border-color:var(--accent);outline:4px solid color-mix(in srgb,var(--accent) 22%,transparent);}
331
+ /* Why the picker preselected what it did (task 1002341) — one line per rule that
332
+ moved something, between the band and the always-on clause. Wears .reassure's
333
+ weight on purpose: this is the panel explaining itself, never a thing to act on,
334
+ so it must not compete with the toggles it is explaining. */
335
+ .modWhy{display:none;}
336
+ .modWhy.on{display:grid;gap:6px;margin:0 0 14px;max-width:62ch;}
337
+ .modWhy span{font-size:13.5px;font-weight:400;color:var(--ink-soft);line-height:1.5;}
338
+
331
339
  /* the module picker (task 1002339) wears the SAME band as the type and team
332
340
  questions — one hairline cell per choice, the dot on the chosen ones. The only
333
341
  difference is that a module carries a one-line summary under its name, so its
@@ -1017,11 +1025,12 @@ summary{min-height:24px;padding:3px 0;}
1017
1025
  to make, when in fact they were never choices. -->
1018
1026
  <section class="panel" id="panel-modules" aria-labelledby="mod-h">
1019
1027
  <h1 id="mod-h">What it comes with</h1>
1020
- <p class="lede">Optional — skip it and you get the suggested set below. These are extras on top of everything every project already has, and you can switch any of them on or off later.</p>
1028
+ <p class="lede">Optional — skip it and you get the suggested set below, shaped by what you just told us. These are extras on top of everything every project already has, and you can switch any of them on or off later.</p>
1021
1029
  <form data-next="5">
1022
1030
  <div class="wizField"><span id="modLbl">Extras for this project</span>
1023
1031
  <div class="typeCards" id="modCards" role="group" aria-labelledby="modLbl"></div>
1024
1032
  </div>
1033
+ <div class="modWhy" id="modWhy" aria-live="polite"></div>
1025
1034
  <div class="reassure" id="modCoreNote"></div>
1026
1035
  <div class="hint" id="h-modules">&nbsp;</div>
1027
1036
  <div class="nav">
@@ -3204,10 +3213,39 @@ summary{min-height:24px;padding:3px 0;}
3204
3213
  if (state.step === 4) renderModules();
3205
3214
  else if (state.step === 7) renderReview();
3206
3215
  }
3216
+ /* The row for the declared type, or null before the read lands / for a type
3217
+ the table has no row for (including no type at all). */
3218
+ function bundleRow() {
3219
+ if (!starterBundles) return null;
3220
+ return (starterBundles.bundles || []).filter(function (b) { return b.type === state.projType; })[0] || null;
3221
+ }
3222
+ /* The type's preselect AS THE ABOUT STEP'S ANSWER SHAPES IT (task 1002341,
3223
+ BV1.R22). The server resolved every combination into byTeamShape, so
3224
+ answering "just me" three panels back re-preselects here with no round trip
3225
+ and no rule applied twice in two languages — the wizard reads the same
3226
+ table `effectiveModules` falls back to (ADR 0243).
3227
+
3228
+ An older payload without byTeamShape, or a team shape no rule fires on,
3229
+ falls through to the unadjusted bundle: the answer this panel showed before
3230
+ R22, which is never wrong, only untailored. */
3231
+ function tailoredRow() {
3232
+ var row = bundleRow();
3233
+ if (!row) return null;
3234
+ var forShape = row.byTeamShape && state.teamShape ? row.byTeamShape[state.teamShape] : null;
3235
+ return forShape || { bundle: row.bundle || [], adjustments: [] };
3236
+ }
3207
3237
  function preselectedModules() {
3208
- if (!starterBundles) return [];
3209
- var forType = (starterBundles.bundles || []).filter(function (b) { return b.type === state.projType; })[0];
3210
- return forType ? forType.bundle.slice() : [];
3238
+ var row = tailoredRow();
3239
+ return row ? row.bundle.slice() : [];
3240
+ }
3241
+ /* Why the preselect is what it is, in the owner's own words — one sentence per
3242
+ rule that ACTUALLY moved something. Empty when nothing did, and empty once
3243
+ the owner has answered for themselves: a reason given back for a toggle they
3244
+ flipped reads as the panel arguing with them. */
3245
+ function preselectReasons() {
3246
+ if (state.modules !== null) return [];
3247
+ var row = tailoredRow();
3248
+ return row ? (row.adjustments || []) : [];
3211
3249
  }
3212
3250
  /* What the panel paints and the review names: the owner's answer if they
3213
3251
  gave one, otherwise the type's preselect. */
@@ -3233,6 +3271,8 @@ summary{min-height:24px;padding:3px 0;}
3233
3271
  a moment; only a read that ANSWERED badly gets the clause, so a slow
3234
3272
  network never tells the owner something is broken when it isn't. */
3235
3273
  band.innerHTML = '';
3274
+ $('modWhy').innerHTML = '';
3275
+ $('modWhy').classList.remove('on');
3236
3276
  note.textContent = bundlesFailed
3237
3277
  ? 'We couldn’t load the list of extras just now. Your project is created with everything it needs either way, and you can add extras from its settings.'
3238
3278
  : '';
@@ -3245,6 +3285,17 @@ summary{min-height:24px;padding:3px 0;}
3245
3285
  + (on.indexOf(m.key) === -1 ? 'false' : 'true') + '"><span><b>' + esc(m.label)
3246
3286
  + '</b><small>' + esc(m.summary) + '</small></span></button>';
3247
3287
  }).join('');
3288
+ /* The tailoring, said out loud (task 1002341). A preselect an owner cannot
3289
+ see the reason for is one they have to audit, which is the thing ADR 0237
3290
+ says is worse than no preselect at all. Rendered as its own block above the
3291
+ core clause, because it is about THIS project's answers while the clause
3292
+ below is about every project. */
3293
+ var why = $('modWhy');
3294
+ var reasons = preselectReasons();
3295
+ why.innerHTML = reasons.length
3296
+ ? reasons.map(function (a) { return '<span>' + esc(a.reason) + '</span>'; }).join('')
3297
+ : '';
3298
+ why.classList.toggle('on', reasons.length > 0);
3248
3299
  var core = (starterBundles.core || []).length;
3249
3300
  note.textContent = core
3250
3301
  ? 'Every project also starts with the same ' + core + ' core parts — the platform itself, the way it tracks work, and the builders’ hall. They are always on, so they are not part of this choice.'
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.528",
3
+ "version": "1.19.530",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.528",
9
+ "version": "1.19.530",
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.528",
3
+ "version": "1.19.530",
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.528'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.530'; // 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');
@@ -132,7 +132,7 @@ test('the explore page mirrors the pack, var for var', () => {
132
132
 
133
133
  test('the provisioning onboarding pages resolve their accent pair from branding', () => {
134
134
  const source = fs.readFileSync(
135
- path.join(REPO_ROOT, 'modules/provisioning/routes/provisioning.js'), 'utf8');
135
+ path.join(REPO_ROOT, 'modules/provisioning/routes/callback-page.js'), 'utf8');
136
136
  assert.match(source, /--sun:\$\{/, '--sun interpolates a resolved value, never a literal');
137
137
  assert.match(source, /--sun600:\$\{/, '--sun600 interpolates a resolved value, never a literal');
138
138
  const block = source.match(/const ACCENT =[\s\S]*?\}\)\(\);/);
@@ -25,6 +25,17 @@ import path from 'node:path';
25
25
  import vm from 'node:vm';
26
26
  import { fileURLToPath } from 'node:url';
27
27
 
28
+ // ESM named imports off the CJS module on purpose (the knip learning): the
29
+ // cross-check test at the bottom runs the REAL declaration the route serves, so a
30
+ // rule change cannot pass this file while breaking the panel.
31
+ import {
32
+ ALWAYS_ON_CORE,
33
+ RULED_TEAM_SHAPES,
34
+ starterBundleAnswerTable,
35
+ optionalModules,
36
+ effectiveModules,
37
+ } from '../modules/provisioning/starter-bundles.js';
38
+
28
39
  const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
29
40
  const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
30
41
  const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8');
@@ -59,6 +70,39 @@ const PAYLOAD = {
59
70
  ],
60
71
  };
61
72
 
73
+ // The `byTeamShape` half of the same payload (BV1.R22, task 1002341): the bundle
74
+ // re-resolved for each About-step answer, with the adjustments that got it there.
75
+ //
76
+ // Deliberately only TWO types carry it. The rest are left without so the
77
+ // degradation path is exercised by the same fixture — a payload from a server that
78
+ // predates R22, or a team shape no rule fires on, must fall through to the
79
+ // unadjusted bundle rather than preselecting nothing. The real table for every
80
+ // type is pinned against the real module further down ("the picker and the server
81
+ // resolve the same set").
82
+ const TAILORED = {
83
+ ...PAYLOAD,
84
+ bundles: PAYLOAD.bundles.map((b) => {
85
+ if (b.type === 'game') {
86
+ return { ...b, byTeamShape: {
87
+ // solo: the shared environment comes off
88
+ solo: { bundle: ['discord'], adjustments: [{ rule: 'building-alone-needs-no-shared-environment', effect: 'remove', module: 'dev-box', reason: 'You are building this alone, so the shared cloud environment is off.' }] },
89
+ // small-team: the rule wants dev-box, the bundle already has it — a rule
90
+ // that changed nothing claims nothing
91
+ 'small-team': { bundle: ['dev-box', 'discord'], adjustments: [] },
92
+ community: { bundle: ['dev-box', 'discord'], adjustments: [] },
93
+ } };
94
+ }
95
+ if (b.type === 'research') {
96
+ return { ...b, byTeamShape: {
97
+ solo: { bundle: ['agents'], adjustments: [] },
98
+ 'small-team': { bundle: ['agents', 'dev-box'], adjustments: [{ rule: 'a-team-shares-one-environment', effect: 'add', module: 'dev-box', reason: 'A small team gets one shared environment to build in.' }] },
99
+ community: { bundle: ['agents', 'discord'], adjustments: [{ rule: 'a-community-needs-somewhere-to-gather', effect: 'add', module: 'discord', reason: 'You said an open community is building this, so somewhere for it to gather is switched on.' }] },
100
+ } };
101
+ }
102
+ return b;
103
+ }),
104
+ };
105
+
62
106
  function makeEl(id) {
63
107
  const classes = new Set();
64
108
  const attrs = new Map();
@@ -88,13 +132,26 @@ function rowsOf(html) {
88
132
  return out;
89
133
  }
90
134
 
135
+ // The reason lines the panel painted, in order (task 1002341). Parsed out of the
136
+ // markup for the same reason rowsOf is: a selector that resolves against markup
137
+ // nobody wrote proves nothing.
138
+ function whyOf(html) {
139
+ const out = [];
140
+ const re = /<span>([^<]*)<\/span>/g;
141
+ let m;
142
+ while ((m = re.exec(html))) out.push(m[1]);
143
+ return out;
144
+ }
145
+
91
146
  // Boot the picker with a given draft state. `fetchOk: false` drives the
92
- // couldn't-load branch. Returns the handles the assertions drive it through.
93
- function boot({ state: seed = {}, fetchOk = true } = {}) {
147
+ // couldn't-load branch; `payload` swaps in a different server answer (TAILORED
148
+ // carries the R22 byTeamShape half). Returns the handles the assertions drive it
149
+ // through.
150
+ function boot({ state: seed = {}, fetchOk = true, payload = PAYLOAD } = {}) {
94
151
  const els = new Map();
95
152
  const $ = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
96
153
  const form = makeEl('panel-modules-form');
97
- const state = { step: 4, projType: null, modules: null, ...seed };
154
+ const state = { step: 4, projType: null, teamShape: null, modules: null, ...seed };
98
155
  const shown = [];
99
156
  let saves = 0;
100
157
  let fetches = 0;
@@ -112,7 +169,7 @@ function boot({ state: seed = {}, fetchOk = true } = {}) {
112
169
  renderReview: () => { reviews++; },
113
170
  API: '/api/bongos',
114
171
  fetch: () => { fetches++; return Promise.resolve(fetchOk
115
- ? { ok: true, json: () => Promise.resolve(PAYLOAD) }
172
+ ? { ok: true, json: () => Promise.resolve(payload) }
116
173
  : { ok: false, json: () => Promise.resolve(null) }); },
117
174
  Promise, JSON, Math, Object, Array, String, Number, console,
118
175
  };
@@ -134,6 +191,8 @@ function boot({ state: seed = {}, fetchOk = true } = {}) {
134
191
  render: () => sandbox.renderModules(),
135
192
  open: async function () { sandbox.renderModules(); for (let i = 0; i < 20; i++) await Promise.resolve(); },
136
193
  rows: () => rowsOf(band.innerHTML),
194
+ why: () => whyOf($('modWhy').innerHTML),
195
+ whyShown: () => $('modWhy').classList.contains('on'),
137
196
  coreNote: () => $('modCoreNote').textContent,
138
197
  coreShown: () => $('modCoreNote').classList.contains('on'),
139
198
  click: (key) => {
@@ -367,3 +426,157 @@ test('the review reads an answered picker back, empty included', async () => {
367
426
  await none.settle();
368
427
  assert.equal(none.extrasRow(), 'None — just the core', 'a deliberate "none" is stated as one');
369
428
  });
429
+
430
+ // ─────────────────────────────────────────────────────────────────────────────
431
+ // BV1.R22 (task 1002341) — the About step's answer reaches the preselect, and
432
+ // the panel says why. ADR 0243.
433
+ // ─────────────────────────────────────────────────────────────────────────────
434
+
435
+ test('the team shape adjusts the type bundle, and the panel says why in the owner\'s words', async () => {
436
+ // Arrange — "a game", then "just me" one panel later
437
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
438
+
439
+ // Act
440
+ await h.open();
441
+
442
+ // Assert — the shared environment came off, and the reason is on screen
443
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['discord'],
444
+ 'solo drops the dev box the game bundle would have preselected');
445
+ assert.equal(h.whyShown(), true);
446
+ assert.deepEqual(h.why(), ['You are building this alone, so the shared cloud environment is off.']);
447
+ });
448
+
449
+ test('a rule that changed nothing claims nothing', async () => {
450
+ // small-team ADDS dev-box, and the game bundle already carries it. A reason
451
+ // shown for a change that did not happen is a claim the owner cannot check.
452
+ const h = boot({ state: { projType: 'game', teamShape: 'small-team' }, payload: TAILORED });
453
+ await h.open();
454
+
455
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
456
+ assert.deepEqual(h.why(), [], 'no adjustment, no sentence');
457
+ assert.equal(h.whyShown(), false, 'and no empty block sitting on the panel');
458
+ });
459
+
460
+ test('an added module is preselected and explained', async () => {
461
+ const h = boot({ state: { projType: 'research', teamShape: 'community' }, payload: TAILORED });
462
+ await h.open();
463
+
464
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents', 'discord']);
465
+ assert.deepEqual(h.why(), ['You said an open community is building this, so somewhere for it to gather is switched on.']);
466
+ });
467
+
468
+ test('answering the About step later re-preselects, with no second read', async () => {
469
+ // The two questions live on two earlier panels and are both changeable. The
470
+ // whole table arrived in one read precisely so going back costs nothing.
471
+ const h = boot({ state: { projType: 'research' }, payload: TAILORED });
472
+ await h.open();
473
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents'], 'unanswered: the raw bundle');
474
+
475
+ h.state.teamShape = 'small-team';
476
+ h.render();
477
+
478
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['agents', 'dev-box']);
479
+ assert.equal(h.fetches(), 1, 'and the answer cost no round trip');
480
+ });
481
+
482
+ test('Skip after an answered About step still yields the set the panel showed', async () => {
483
+ // ADR 0240 §2's property, extended to the interview's answer (ADR 0243): the
484
+ // preselect and the server's fallback are the same declaration, so skipping
485
+ // must still answer NOTHING and still land the tailored set.
486
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
487
+ await h.open();
488
+
489
+ h.skip();
490
+
491
+ assert.deepEqual(h.shown, [5]);
492
+ assert.equal(h.state.modules, null, 'skipping answers nothing, tailored or not');
493
+ assert.equal(h.forCreate(), undefined, 'so the create request still carries no modules key');
494
+ assert.deepEqual(h.picked(), ['discord'], 'while the set they were shown is still the set they get');
495
+ });
496
+
497
+ test('once the owner has answered, the reasons go — the panel stops arguing', async () => {
498
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
499
+ await h.open();
500
+ assert.equal(h.why().length, 1);
501
+
502
+ h.click('dev-box');
503
+
504
+ assert.deepEqual(h.state.modules, ['dev-box', 'discord'], 'their toggle stands over the rule');
505
+ assert.deepEqual(h.why(), [], 'and the rule stops explaining a set that is no longer its own');
506
+ assert.equal(h.whyShown(), false);
507
+ });
508
+
509
+ test('a payload with no byTeamShape degrades to the untailored bundle, never to nothing', async () => {
510
+ // A server that predates R22 still serves `bundles[].bundle`. The panel must
511
+ // preselect that rather than empty out — untailored is never wrong, only less.
512
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: PAYLOAD });
513
+ await h.open();
514
+
515
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
516
+ assert.deepEqual(h.why(), []);
517
+ });
518
+
519
+ test('an off-vocabulary team shape adjusts nothing', async () => {
520
+ // A value that was valid when the draft was stored and has since left the
521
+ // vocabulary reads as no answer, exactly as normalizeProjectDetail drops one.
522
+ const h = boot({ state: { projType: 'game', teamShape: 'a-huge-corporation' }, payload: TAILORED });
523
+ await h.open();
524
+
525
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), ['dev-box', 'discord']);
526
+ assert.deepEqual(h.why(), []);
527
+ });
528
+
529
+ test('a couldn\'t-load read clears any reasons it had already painted', async () => {
530
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
531
+ await h.open();
532
+ assert.equal(h.why().length, 1);
533
+
534
+ h.sandbox.starterBundles = null;
535
+ h.render();
536
+
537
+ assert.deepEqual(h.why(), [], 'a stale reason for a set no longer on screen is a lie');
538
+ assert.equal(h.whyShown(), false);
539
+ });
540
+
541
+ test('a reason goes through the page escaper like every other rendered value', async () => {
542
+ const h = boot({ state: { projType: 'game', teamShape: 'solo' }, payload: TAILORED });
543
+ await h.open();
544
+ h.sandbox.starterBundles = {
545
+ core: [], optional: [],
546
+ bundles: [{ type: 'game', bundle: [], byTeamShape: { solo: { bundle: [], adjustments: [{ reason: '<img src=x onerror=alert(1)>' }] } } }],
547
+ };
548
+ h.render();
549
+
550
+ const html = h.sandbox.$('modWhy').innerHTML;
551
+ assert.ok(!html.includes('<img'), 'a hostile reason cannot inject markup');
552
+ assert.ok(html.includes('&lt;img src=x onerror=alert(1)&gt;'), 'it is shown escaped');
553
+ });
554
+
555
+ // The pin that makes the two ends of ADR 0243 unable to drift. Everything above
556
+ // runs the picker against a FIXTURE; this one runs it against the REAL payload the
557
+ // route serves, and asserts the panel's preselect is the same set the server would
558
+ // resolve for a row with those answers and a skipped picker. If a rule is ever
559
+ // added, changed or moved, one of these twenty combinations fails here rather than
560
+ // silently handing an owner a project that is not the one they were shown.
561
+ test('the picker and the server resolve the same set for every answer an owner can give', async () => {
562
+ const LIVE = { core: [...ALWAYS_ON_CORE], bundles: starterBundleAnswerTable(), optional: optionalModules() };
563
+ const shapes = [null, ...RULED_TEAM_SHAPES];
564
+
565
+ for (const preset of LIVE.bundles) {
566
+ for (const teamShape of shapes) {
567
+ const h = boot({ state: { projType: preset.type, teamShape }, payload: LIVE });
568
+ await h.open();
569
+
570
+ // what the server gives a project that answered the interview and then
571
+ // skipped the picker — `modules: null`, resolved at read time
572
+ const server = effectiveModules({ type: preset.type, modules: null, detail: teamShape ? { team_shape: teamShape } : {} });
573
+
574
+ assert.deepEqual(h.picked(), server.selected,
575
+ `type=${preset.type} team_shape=${teamShape}: the panel preselects what the server resolves`);
576
+ assert.deepEqual(h.rows().filter((r) => r.pressed).map((r) => r.key), server.selected,
577
+ `type=${preset.type} team_shape=${teamShape}: and the toggles show it`);
578
+ assert.deepEqual(h.why().length, server.adjustments.length,
579
+ `type=${preset.type} team_shape=${teamShape}: one sentence per adjustment the server made`);
580
+ }
581
+ }
582
+ });