@bongos/core 1.21.4 → 1.21.6

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 (126) hide show
  1. package/.bongos-core.json +233 -108
  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 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  8. package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +37 -2
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +541 -658
  13. package/docs/copy-registry.json +1205 -2340
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
  16. package/docs/onboarding/diagrams/assertions.json +1 -1
  17. package/docs/page-inventory.json +47 -28
  18. package/docs/page-readings.json +1128 -1339
  19. package/modules/autonomy/config-idle.js +192 -0
  20. package/modules/autonomy/governor-docket.js +101 -0
  21. package/modules/autonomy/routes/autonomy.js +27 -0
  22. package/modules/autonomy/runner-health.js +22 -1
  23. package/modules/discord/board-broadcast.js +3 -1
  24. package/modules/discord/craft-broadcast.js +107 -0
  25. package/modules/discord/routes/discord.js +10 -0
  26. package/modules/discord/ship-broadcast.js +30 -2
  27. package/modules/economy/reward.js +3 -0
  28. package/modules/government/board.js +20 -5
  29. package/modules/government/docket-vocab.js +82 -0
  30. package/modules/government/docket.js +458 -0
  31. package/modules/government/routes/government.js +34 -0
  32. package/modules/hall-ui/public/city-draw.js +438 -0
  33. package/modules/hall-ui/public/city.css +110 -0
  34. package/modules/hall-ui/public/city.html +71 -0
  35. package/modules/hall-ui/public/city.js +177 -0
  36. package/modules/hall-ui/public/city.states.json +30 -0
  37. package/modules/hall-ui/public/gate.js +11 -4
  38. package/modules/hall-ui/public/genesis-home.js +9 -10
  39. package/modules/hall-ui/public/profile.css +4 -0
  40. package/modules/hall-ui/public/profile.js +25 -1
  41. package/modules/hall-ui/public/settings-autobongos.js +19 -1
  42. package/modules/hall-ui/public/settings.css +26 -0
  43. package/modules/hall-ui/public/settings.html +8 -0
  44. package/modules/hall-ui/public/settings.js +43 -3
  45. package/modules/ideas/governor-docket.js +93 -0
  46. package/modules/ideas/routes/blockers.js +5 -0
  47. package/modules/lifecycle/governor-docket.js +192 -0
  48. package/modules/lifecycle/routes/gate-approvals.js +78 -33
  49. package/modules/lifecycle/routes/lifecycle.js +3 -0
  50. package/modules/onboarding/routes/access-requests.js +68 -27
  51. package/modules/platform-identity/craft-rollup.js +72 -0
  52. package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
  53. package/modules/platform-identity/platform-identity.js +12 -6
  54. package/modules/platform-identity/routes/sso.js +4 -0
  55. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  56. package/modules/provisioning/core-upgrade.js +34 -7
  57. package/modules/provisioning/module.json +2 -1
  58. package/modules/provisioning/seams.js +2 -0
  59. package/modules/public-landing/public/assets/cosmos.css +6 -50
  60. package/modules/public-landing/public/projects.html +1170 -1659
  61. package/modules/public-landing/public/projects.probes.json +18 -18
  62. package/modules/public-landing/public/projects.states.json +24 -31
  63. package/modules/security/routes/reports.js +39 -0
  64. package/package-lock.json +2 -2
  65. package/package.json +1 -1
  66. package/release-notes.json +72 -0
  67. package/scripts/gds/autobongos-grade-cap.js +187 -0
  68. package/scripts/gds/autobongos-loop.js +4 -0
  69. package/scripts/gds/autobongos-run.js +154 -13
  70. package/scripts/gds/autobongos-verify.js +211 -14
  71. package/scripts/gds/provision-core-upgrade.js +14 -2
  72. package/scripts/gds/provision-repo.js +77 -13
  73. package/scripts/gds/ship-flow.js +5 -0
  74. package/scripts/gds/ship-preflight-steps.js +2 -1
  75. package/scripts/gds/ship.js +10 -0
  76. package/scripts/gds/update-sweep.js +24 -8
  77. package/scripts/gds/upgrade-outcome.js +1 -0
  78. package/src/bongos/auth-admission.js +95 -13
  79. package/src/bongos/db-kernel.js +1 -1
  80. package/src/bongos/module-rechecks.js +77 -0
  81. package/src/bongos/route-rank-check.js +3 -0
  82. package/src/bongos/routes/core-update.js +68 -2
  83. package/src/bongos/routes/modules.js +17 -0
  84. package/src/bongos/serve-internal.js +14 -0
  85. package/src/bongos/software-update.js +3 -2
  86. package/src/module-api.js +1 -1
  87. package/tests/activity_rollup_order_db.mjs +3 -1
  88. package/tests/activity_snapshots_db.mjs +2 -0
  89. package/tests/autobongos_grade_cap.mjs +125 -0
  90. package/tests/autobongos_loop.mjs +230 -1
  91. package/tests/autobongos_verify.mjs +239 -3
  92. package/tests/autonomy_config_idle.mjs +272 -0
  93. package/tests/core_update_banner.mjs +29 -0
  94. package/tests/core_upgrade_door.mjs +31 -1
  95. package/tests/core_upgrade_runner.mjs +20 -0
  96. package/tests/discord_craft_broadcast.mjs +162 -0
  97. package/tests/genesis_home.mjs +1 -1
  98. package/tests/government_routes.mjs +3 -1
  99. package/tests/governor_docket.mjs +552 -0
  100. package/tests/hall_audit.mjs +10 -1
  101. package/tests/hall_city.mjs +369 -0
  102. package/tests/hall_page_gate_map.mjs +5 -0
  103. package/tests/hub_craft_rollup.mjs +350 -0
  104. package/tests/onboard.mjs +10 -4
  105. package/tests/platform_boot.mjs +1 -1
  106. package/tests/profile_rollup_consent.mjs +2 -0
  107. package/tests/profile_route.mjs +16 -2
  108. package/tests/projects_hub.mjs +196 -89
  109. package/tests/projects_hub_app_status.mjs +3 -1
  110. package/tests/projects_hub_app_step.mjs +72 -48
  111. package/tests/projects_hub_dns_ready.mjs +10 -2
  112. package/tests/projects_hub_look.mjs +32 -22
  113. package/tests/projects_hub_module_picker.mjs +112 -62
  114. package/tests/projects_hub_pre_uat.mjs +48 -41
  115. package/tests/provision.mjs +78 -0
  116. package/tests/public_landing_projects.mjs +12 -6
  117. package/tests/settings_main_role.mjs +190 -0
  118. package/tests/software_update.mjs +56 -1
  119. package/tests/update_subscription_engine.mjs +55 -3
  120. package/tests/wizard_demo.mjs +37 -11
  121. package/tests/wizard_draft_resume.mjs +34 -22
  122. package/tests/wizard_front_door.mjs +173 -103
  123. package/tests/wizard_intent_resume.mjs +28 -25
  124. package/tests/wizard_physics.mjs +94 -76
  125. package/tests/wizard_physics_more.mjs +428 -277
  126. package/tests/wizard_six_screens.mjs +126 -0
@@ -1,14 +1,22 @@
1
- // tests/wizard_physics_more.mjs — planet physics, questions 5 to 7, the summary, the
2
- // voice split and the pre-filled technical steps (task 1004419 / BV2.PS11, goal 1000121).
1
+ // tests/wizard_physics_more.mjs — planet physics, questions 5 to 7, the one plain voice and
2
+ // the prefilled setup (task 1004419 / BV2.PS11, goal 1000121; the planet voice and the
3
+ // question rail retired by task 1004504, PS16; the questions regrouped by the same task).
3
4
  //
4
- // Done-when: a new owner walks from question 1 to "create my planet" with the technical
5
- // steps pre-filled. So the walk below is RUN end to end — the page's own physics
6
- // controller sliced out of projects.html into a vm under a DOM stub (the
7
- // tests/wizard_physics.mjs idiom), its pre-create check answered by the REAL soft-limit
8
- // table (modules/provisioning/soft-limits.js), never a hand-written verdict. The rest
9
- // pins each rule on its own: rewards hidden when solo, the framing's suite and its
10
- // reasons, the voice chosen from the entity (spec D5), the summary's change links, what
11
- // the create sends, which technical step is skipped, and the forming planet.
5
+ // Since task 1004504 the founder answers the "few quick checks" (country, builders'
6
+ // geography, under-18s, who is behind it) on screen 3, and questions 5 to 7 are rows of the
7
+ // review on screen 4 ("Check what we set up."): Team size, Rewards and We run, each
8
+ // PREFILLED (one person, all managed) and confirmed or changed in place. The summary, the
9
+ // forming planet and the pre-fill notes are gone; confirming a row is the answer.
10
+ //
11
+ // Done-when: a new owner walks from the quick checks to a review whose every row is
12
+ // confirmed, with the setup prefilled. So the walk below is RUN end to end — the page's own
13
+ // physics controller, its team pills, its app choice, the review's row machinery and
14
+ // validateStep, sliced out of projects.html into a vm under a DOM stub built from the
15
+ // screens' own markup (the tests/wizard_physics.mjs idiom), its pre-create check answered by
16
+ // the REAL soft-limit table (modules/provisioning/soft-limits.js), never a hand-written
17
+ // verdict. The rest pins each rule on its own: rewards hidden when solo, the framing's suite
18
+ // and its reasons, the one voice every founder reads (spec D5), what the create sends, and
19
+ // which rows are fixed.
12
20
 
13
21
  import { strict as assert } from 'node:assert';
14
22
  import { test } from 'node:test';
@@ -25,115 +33,176 @@ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace
25
33
  const physics = require('../modules/provisioning/planet-physics.js');
26
34
  const { SOFT_LIMIT_RULES, softLimitsFor } = require('../modules/provisioning/soft-limits.js');
27
35
 
28
- const START = '/* ── planet physics, questions 1 to 7 (tasks 1004418 and 1004419, BV2.PS07 + PS11) ──';
29
- const SCRIPT = HUB.slice(HUB.indexOf(START), HUB.indexOf(' /* per-panel validation on Next */'));
30
- const PANEL = HUB.slice(HUB.indexOf('<section class="panel" id="panel-physics"'), HUB.indexOf('<!-- step 4 · name'));
36
+ // Every region is cut at code that exists, and every edge is asserted, so a rename fails
37
+ // loudly instead of testing nothing.
38
+ function slice(start, end, what) {
39
+ const a = HUB.indexOf(start), b = HUB.indexOf(end, a);
40
+ assert.ok(a > -1, `${what}: the start is on the page`);
41
+ assert.ok(b > a, `${what}: the end follows it`);
42
+ return HUB.slice(a, b);
43
+ }
44
+ const SCRIPT = slice('/* ── planet physics (tasks 1004418 and 1004419, BV2.PS07 + PS11; regrouped by task 1004504) ──',
45
+ ' /* ── checks the old steps made on Next, now made by their rows ── */', 'the physics controller');
46
+ const TEAM = slice(' function teamLabel(t) {', " $('f-desc').value = state.description || '';", 'the team pills');
47
+ const APP = slice(' function appPlatformChoice() {', ' var ROWS = [', 'the app choice');
48
+ const REVIEW = slice(' var ROWS = [', ' function renderRecap() {', 'the review rows');
49
+ const VALIDATE = slice(' /* per-screen validation on Continue */', " root.querySelectorAll('.panel form')", 'validateStep');
50
+ for (const [name, region] of [['checksAdvance', SCRIPT], ['paintChecks', SCRIPT], ['physDefaults', SCRIPT], ['confirmRow', REVIEW],
51
+ ['rowValid', REVIEW], ['rowFixed', REVIEW], ['paintSetup', REVIEW], ['validateStep', VALIDATE], ['paintTeam', TEAM]]) {
52
+ assert.ok(region.includes(`function ${name}(`), `the slice carries ${name}()`);
53
+ }
54
+ // the screens the controller paints: the quick checks and the review
55
+ const PANEL = slice('<section class="panel" id="panel-checks"', '<!-- 5 · a look, then Create', 'the checks and review markup');
31
56
  const plain = (v) => JSON.parse(JSON.stringify(v)); // values born in the vm
32
57
 
33
- // ── a small DOM: the panel's own elements, matched by attribute ──────────────
58
+ // ── a small DOM: the screens' own elements, as a tree, matched by selector ────
34
59
  class El {
35
60
  constructor(tag, attrs = {}) {
36
- this.tagName = tag; this.attrs = { ...attrs }; this.hidden = 'hidden' in attrs; this.disabled = false;
61
+ this.tagName = tag; this.attrs = { ...attrs }; this.hidden = 'hidden' in attrs; this.disabled = 'disabled' in attrs;
37
62
  this.value = attrs.value || ''; this.textContent = ''; this.title = ''; this.className = attrs.class || '';
38
- this.listeners = {}; this.children = []; this.options = [{}];
63
+ this.listeners = {}; this.children = []; this.options = [{}]; this.parent = null; this.checked = 'checked' in attrs;
39
64
  const self = this;
40
65
  this.classes = new Set(this.className.split(/\s+/).filter(Boolean));
41
- this.classList = { toggle(c, on) { if (on) self.classes.add(c); else self.classes.delete(c); }, contains: (c) => self.classes.has(c), add: (c) => self.classes.add(c) };
66
+ this.classList = { toggle(c, on) { if (on) self.classes.add(c); else self.classes.delete(c); }, contains: (c) => self.classes.has(c),
67
+ add: (c) => self.classes.add(c), remove: (c) => self.classes.delete(c) };
42
68
  }
43
69
  setAttribute(k, v) { this.attrs[k] = String(v); }
44
70
  getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
71
+ hasAttribute(k) { return k in this.attrs; }
45
72
  removeAttribute(k) { delete this.attrs[k]; }
46
73
  addEventListener(t, fn) { this.listeners[t] = fn; }
47
- click() { this.listeners.click && this.listeners.click({ target: this }); }
48
- appendChild(c) { this.children.push(c); if (this.tagName === 'select') this.options.push(c); return c; }
74
+ // a click runs the element's own listener, then bubbles to the delegated ones above it
75
+ click() { for (let n = this; n; n = n.parent) if (n.listeners.click) n.listeners.click({ target: this }); }
76
+ appendChild(c) { this.children.push(c); c.parent = this; if (this.tagName === 'select') this.options.push(c); return c; }
49
77
  removeChild(c) { const i = this.children.indexOf(c); if (i !== -1) this.children.splice(i, 1); if (this.tagName === 'select') this.options.pop(); }
50
78
  get firstChild() { return this.children[0] || null; }
51
79
  get lastChild() { return this.tagName === 'select' ? this.options[this.options.length - 1] : this.children[this.children.length - 1]; }
52
80
  focus() {}
53
- text() { return this.textContent + this.children.map((c) => c.text()).join(''); }
81
+ descendants() { return this.children.flatMap((c) => [c, ...c.descendants()]); }
82
+ querySelectorAll(sel) { return this.descendants().filter((e) => matches(e, sel)); }
83
+ querySelector(sel) { return this.querySelectorAll(sel)[0] || null; }
84
+ closest(sel) { for (let n = this; n && n.tagName !== '#root'; n = n.parent) if (matches(n, sel)) return n; return null; }
54
85
  }
55
-
56
86
  function parseAttrs(s) {
57
87
  const out = {};
58
88
  for (const m of s.matchAll(/([a-zA-Z-]+)(?:="([^"]*)")?/g)) out[m[1]] = m[2] === undefined ? '' : m[2];
59
89
  return out;
60
90
  }
91
+ function compound(c) {
92
+ const bare = c.replace(/\[[^\]]*\]/g, '');
93
+ return {
94
+ tag: (bare.match(/^[a-z][a-z0-9]*/i) || [null])[0],
95
+ id: (bare.match(/#([\w-]+)/) || [])[1],
96
+ classes: [...bare.matchAll(/\.([\w-]+)/g)].map((m) => m[1]),
97
+ attrs: [...c.matchAll(/\[([\w-]+)(?:="?([^"\]]*)"?)?\]/g)].map((m) => [m[1], m[2]]),
98
+ };
99
+ }
100
+ function fits(el, c) {
101
+ if (c === '#view-new') return true; // every element here lives inside the wizard's view
102
+ const p = compound(c);
103
+ if (p.tag && el.tagName !== p.tag) return false;
104
+ if (p.id && el.attrs.id !== p.id) return false;
105
+ if (p.classes.some((k) => !el.classes.has(k))) return false;
106
+ return p.attrs.every(([k, v]) => k in el.attrs && (v === undefined || el.attrs[k] === v));
107
+ }
108
+ function matches(el, sel) {
109
+ return sel.split(',').some((group) => {
110
+ const parts = group.trim().split(/\s+/);
111
+ if (!fits(el, parts[parts.length - 1])) return false;
112
+ let n = el.parent;
113
+ for (let i = parts.length - 2; i >= 0; i -= 1) {
114
+ if (parts[i] === '#view-new') continue;
115
+ while (n && !fits(n, parts[i])) n = n.parent;
116
+ if (!n) return false;
117
+ n = n.parent;
118
+ }
119
+ return true;
120
+ });
121
+ }
122
+ const VOID = new Set(['input', 'img', 'br', 'hr', 'path', 'source']);
61
123
  function buildDom() {
62
- const all = [];
124
+ const top = new El('#root');
63
125
  const byId = {};
64
- for (const m of PANEL.matchAll(/<(button|div|p|ul|fieldset|span|li|img|input|select|output|dd|legend|h1|ol|dl|form|label)\b([^>]*)>/g)) {
65
- const el = new El(m[1], parseAttrs(m[2]));
66
- all.push(el);
126
+ let cur = top;
127
+ const raw = new Map();
128
+ const src = PANEL.replace(/<!--[\s\S]*?-->/g, '');
129
+ for (const m of src.matchAll(/<(\/?)([a-zA-Z][a-zA-Z0-9]*)\b([^>]*?)(\/?)>|([^<]+)/g)) {
130
+ if (m[5] !== undefined) { raw.set(cur, (raw.get(cur) || '') + m[5]); continue; }
131
+ const tag = m[2].toLowerCase();
132
+ if (m[1]) {
133
+ for (let n = cur; n !== top; n = n.parent) {
134
+ if (n.tagName === tag) {
135
+ // a leaf's words are its text, as the browser would read them
136
+ if (!n.children.length) n.textContent = (raw.get(n) || '').replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/\s+/g, ' ').trim();
137
+ cur = n.parent; break;
138
+ }
139
+ }
140
+ continue;
141
+ }
142
+ const el = new El(tag, parseAttrs(m[3]));
143
+ el.parent = cur; cur.children.push(el);
67
144
  if (el.attrs.id) byId[el.attrs.id] = el;
145
+ if (!VOID.has(tag) && !m[4]) cur = el;
68
146
  }
69
- // the stage's moons and ghosts are the children of their wrappers, in markup order
70
- const kids = (id) => {
71
- const at = PANEL.indexOf(`id="${id}"`);
72
- const chunk = PANEL.slice(at, PANEL.indexOf('</span>', at));
73
- const n = (chunk.match(/<img /g) || []).length;
74
- return all.filter((e) => e.tagName === 'img' && e.attrs.src && e.attrs.src.includes('bead')).slice(id === 'physGhosts' ? 0 : 4, id === 'physGhosts' ? n : 4 + n);
75
- };
76
- const lists = { '#physMoons img': kids('physMoons'), '#physGhosts img': kids('physGhosts') };
77
- lists['#physMoons img, #physGhosts img'] = [...lists['#physMoons img'], ...lists['#physGhosts img']];
78
- const matches = (el, compound) => {
79
- if (compound.startsWith('.')) return el.classes.has(compound.slice(1));
80
- for (const m of compound.matchAll(/\[([a-z-]+)(?:="([^"]*)")?\]/g)) {
81
- if (!(m[1] in el.attrs)) return false;
82
- if (m[2] !== undefined && el.attrs[m[1]] !== m[2]) return false;
83
- }
84
- return /\[/.test(compound);
85
- };
86
- const root = {
87
- querySelectorAll(sel) {
88
- if (lists[sel]) return lists[sel];
89
- if (sel === '#physRail li') return all.filter((e) => e.tagName === 'li' && 'data-q' in e.attrs);
90
- if (sel === '#physStage .physBelt') return all.filter((e) => e.classes.has('physBelt'));
91
- if (sel === '#panel-physics .physQ') return all.filter((e) => e.classes.has('physQ'));
92
- const last = sel.trim().split(/\s+/).pop();
93
- return all.filter((e) => matches(e, last));
94
- },
95
- querySelector(sel) { return this.querySelectorAll(sel)[0] || null; },
96
- };
97
- return { all, byId, root };
147
+ return { top, byId, root: top };
98
148
  }
99
149
 
100
- // the pre-create check, answered by the real table
101
- function boot({ check = (body) => ({ stop: false, soft_limits: softLimitsFor(body.detail) }), draft = {} } = {}) {
150
+ // the pre-create check, answered by the real table; /provisioning/instances by `instances`
151
+ function boot({ check = (body) => ({ stop: false, soft_limits: softLimitsFor(body.detail) }), draft = {}, instances = [], renderOk = true } = {}) {
102
152
  const dom = buildDom();
103
153
  const els = { ...dom.byId };
104
154
  const $ = (id) => (els[id] = els[id] || new El('div', { id }));
105
155
  const shown = [];
106
156
  const fetched = [];
107
- const state = Object.assign({ description: '', teamShape: null, physics: null, projType: null, mode: 'greenfield', appPlatform: 'later', demo: null }, draft);
157
+ const state = Object.assign({ step: 4, name: 'Kestrel', description: '', teamShape: null, physics: null, projType: 'game', mode: 'greenfield',
158
+ appPlatform: 'later', demo: null, modules: null, confirmed: {}, termsAck: true }, draft);
108
159
  const sandbox = {
109
160
  $, state, API: '/api/bongos', root: dom.root, Intl,
110
161
  document: { createElement: (t) => new El(t) },
111
- saveDraft() {}, show(n) { shown.push(n); },
162
+ saveDraft() {}, show(n) { shown.push(n); state.step = n; },
163
+ setMode(m) { state.mode = m; }, authKnown: true,
164
+ // the code, address and Render checks are their own files' to run (projects_hub_app_step,
165
+ // public_landing_projects); here they answer as told
166
+ validateCode: () => true, validateAddress: () => true, validateRender: () => renderOk,
167
+ checkSlugAvailable() {}, renderAddrPanel() {}, renderModules() {}, syncHome() {}, syncRenderSetup() {}, renderLater: () => false,
168
+ slugAvailState: () => 'free', looksLikeDomain: () => false, resolvedDomain: () => 'kestrel.example.com',
169
+ // the extras row's words are tests/projects_hub_module_picker.mjs's
170
+ starterBundles: { optional: [{ key: 'discord', label: 'Discord' }] }, pickedModules: () => ['discord'], extrasRow: () => '', typeLabel: () => 'Game',
112
171
  fetch: async (url, opts) => {
113
172
  const body = opts && opts.body ? JSON.parse(opts.body) : null;
114
173
  fetched.push({ url, body });
115
- return { ok: true, json: async () => (url.includes('physics-check') ? check(body) : []) };
174
+ return { ok: true, json: async () => (url.includes('physics-check') ? check(body) : instances) };
116
175
  },
117
176
  };
118
177
  vm.createContext(sandbox);
119
- vm.runInContext(SCRIPT, sandbox, { filename: 'projects.html#physics' });
178
+ vm.runInContext([TEAM, SCRIPT, APP, REVIEW, VALIDATE].join('\n'), sandbox, { filename: 'projects.html#physics' });
120
179
  const q = (sel) => dom.root.querySelectorAll(sel);
180
+ const row = (key) => q(`#reviewRows .setRow[data-row="${key}"]`)[0];
121
181
  return {
122
- state, $, shown, fetched, q, dom,
182
+ state, $, shown, fetched, q, dom, row,
123
183
  run: (code) => vm.runInContext(code, sandbox),
124
184
  a: () => state.physics.answers,
125
185
  pick: (key, val) => q(`[data-phys="${key}"][data-val="${val}"]`)[0].click(),
126
- next: async () => { sandbox.$('physNext'); vm.runInContext('physAdvance()', sandbox); await tick(); await tick(); },
186
+ rowSays: (key) => [row(key).querySelector('[data-row-v]').textContent, row(key).querySelector('[data-row-s]').textContent],
187
+ ok: (key) => row(key).querySelector('[data-row-ok]').click(),
188
+ change: (key) => row(key).querySelector('[data-row-chg]').click(),
189
+ use: (key) => row(key).querySelector('[data-row-use]').click(),
190
+ isOpen: (key) => !row(key).querySelector('.opts').hidden,
191
+ go: () => $('reviewGo'),
192
+ settle: async () => { for (let i = 0; i < 4; i += 1) await tick(); },
127
193
  };
128
194
  }
129
195
  const tick = () => new Promise((r) => setTimeout(r, 0));
130
- // questions 1 to 4, answered (the Kestrel sample of the canvas, a new planet)
131
- const FOUR = { project_state: 'new', home_country: 'US', home_region: 'US-CA', builder_geography: 'us-eu', minors_on_board: false, entity: 'intent-solo' };
132
- function at(q, answers = FOUR, opts = {}) {
196
+ // the quick checks, answered (the Kestrel sample of the canvas)
197
+ const CHECKS = { home_country: 'US', home_region: 'US-CA', builder_geography: 'us-eu', minors_on_board: false, entity: 'intent-solo' };
198
+ // on the review, with the checks answered, the prefills written and the table asked
199
+ async function onReview(answers = {}, opts = {}) {
133
200
  const w = boot(opts);
134
201
  w.run('phys()');
135
- Object.assign(w.state.physics.answers, answers);
136
- w.run(`physGo(${JSON.stringify(q)})`);
202
+ Object.assign(w.state.physics.answers, CHECKS, answers);
203
+ w.run('physDefaults()');
204
+ const res = await w.run('physCheck()');
205
+ w.run(`physLimits = ${JSON.stringify(res.soft_limits.limits)}; paintSetup();`);
137
206
  return w;
138
207
  }
139
208
 
@@ -141,24 +210,23 @@ function at(q, answers = FOUR, opts = {}) {
141
210
  test('the page carries the server\'s rewards, framings and services, in order, and says every one', () => {
142
211
  const box = {};
143
212
  vm.createContext(box);
144
- vm.runInContext(SCRIPT.slice(0, SCRIPT.indexOf(' var PHYS_LAST')).replace(/\bvar /g, 'this.'), box);
213
+ vm.runInContext(SCRIPT.slice(0, SCRIPT.indexOf(' var physLimits = [];')).replace(/\bvar /g, 'this.'), box);
145
214
  const Q = physics.PLANET_PHYSICS_QUESTIONS;
146
215
  assert.deepEqual(plain(box.PHYS_REWARDS), [...Q.reward_intents.values]);
147
216
  assert.deepEqual(plain(box.PHYS_FRAMINGS), [...Q.framing.values]);
148
217
  assert.deepEqual(plain(box.PHYS_SERVICES), [...Q.services.values]);
149
- assert.deepEqual(Object.keys(box.PHYS_ORBIT), [...Q.framing.values]);
150
218
  for (const key of ['project_state', 'builder_geography', 'entity', 'reward_intents', 'framing', 'services']) {
151
- assert.deepEqual(Object.keys(box.PHYS_SAY[key]).sort(), [...Q[key].values].sort(), `the summary can say every ${key}`);
219
+ assert.deepEqual(Object.keys(box.PHYS_SAY[key]).sort(), [...Q[key].values].sort(), `the review can say every ${key}`);
152
220
  }
153
- for (const org of box.PHYS_ORGS) assert.ok(physics.ENTITY_FORMS.includes(org), `${org} is a formed entity`);
221
+ assert.deepEqual(plain(box.PHYS_SAY.reward_intents), { 'non-monetary': 'Learning and fun', cash: 'Cash for work', equity: 'Equity', 'future-payouts': 'Future payouts' },
222
+ 'the reward words are the review\'s pills (task 1004504)');
223
+ assert.equal(box.PHYS_ORGS, undefined, 'the organization list that picked the voice is gone (task 1004504)');
224
+ assert.equal(box.PHYS_ORBIT, undefined, 'the orbit per framing left with the forming planet');
225
+ assert.equal(box.PHYS_TYPE, undefined, 'the kind is the founder\'s own answer now, never pre-filled from the entity');
154
226
  for (const [f, list] of Object.entries(box.PHYS_SUITE_START)) {
155
227
  assert.ok(['hybrid', 'local'].includes(f));
156
228
  assert.notEqual(physics.canonicalAnswer(Q.services, plain(list)), undefined, `${f}'s starting suite is a services answer`);
157
229
  }
158
- const TYPES = ['game', 'research', 'business', 'non-profit', 'not-sure'];
159
- for (const [entity, type] of Object.entries(box.PHYS_TYPE)) {
160
- assert.ok(Q.entity.values.includes(entity)); assert.ok(TYPES.includes(type), `${type} is a project type`);
161
- }
162
230
  });
163
231
 
164
232
  test('the kit stub\'s physics hide rules are the table\'s own, word for word', () => {
@@ -174,50 +242,149 @@ test('the kit stub\'s physics hide rules are the table\'s own, word for word', (
174
242
  }
175
243
  });
176
244
 
177
- // ── question 5 ───────────────────────────────────────────────────────────────
178
- test('question 5: one or many, a start count, and a guessed growth the founder corrects', async () => {
179
- const w = at(5);
180
- await w.next();
181
- assert.equal(w.state.physics.q, 5, 'unanswered: it stays');
182
- assert.match(w.$('h-phys').textContent, /one or many/);
245
+ // ── screen 3, the quick checks ───────────────────────────────────────────────
246
+ test('the quick checks reveal what an answer opens: the age tiers, the legal forms, a state or province', () => {
247
+ const w = boot({ draft: { step: 3 } });
248
+ w.run('paintChecks()');
249
+ assert.equal(w.$('physAges').hidden, true, 'no tiers before a yes');
250
+ assert.equal(w.$('physForms').hidden, true, 'the plain intents first');
251
+ assert.equal(w.$('physIntent').hidden, false);
252
+ w.pick('minors_on_board', 'true');
253
+ assert.equal(w.a().minors_on_board, true);
254
+ assert.equal(w.$('physAges').hidden, false, 'a yes shows the tiers young builders work under');
255
+ assert.equal(w.q('[data-phys="minors_on_board"][data-val="true"]')[0].getAttribute('aria-pressed'), 'true');
256
+ w.pick('minors_on_board', 'true');
257
+ assert.equal(w.a().minors_on_board, null, 'a second click un-says it');
258
+ assert.equal(w.$('physAges').hidden, true);
259
+ w.$('physHaveEntity').click();
260
+ assert.equal(w.$('physForms').hidden, false, 'an existing entity opens the full legal list');
261
+ assert.equal(w.$('physIntent').hidden, true);
262
+ w.pick('entity', 'single-owner-llc');
263
+ assert.equal(w.a().entity, 'single-owner-llc');
264
+ w.$('physNoEntity').click();
265
+ assert.equal(w.$('physForms').hidden, true, 'and the way back closes it');
266
+ assert.equal(w.a().entity, null, 'taking the legal form with it');
267
+ w.$('physCountry').value = 'US';
268
+ w.$('physCountry').listeners.change();
269
+ assert.equal(w.$('physRegionField').hidden, false, 'a country with states asks for one');
270
+ assert.ok(w.$('physRegion').children.some((o) => o.value === 'US-CA'));
271
+ w.$('physCountry').value = 'NL';
272
+ w.$('physCountry').listeners.change();
273
+ assert.equal(w.$('physRegionField').hidden, true, 'and one without does not');
274
+ });
275
+
276
+ test('the quick checks fill in from the account\'s last project, never its state, and offer nothing without one', async () => {
277
+ const none = boot({ draft: { step: 3 } });
278
+ none.run('physLoadAccountOnce()');
279
+ await none.settle();
280
+ assert.equal(none.$('physFillOffer').hidden, true, 'no earlier answers: no offer');
281
+ const w = boot({ draft: { step: 3 }, instances: { instances: [
282
+ { created_at: '2026-01-01', detail: { home_country: 'GB', entity: 'intent-unsure' } },
283
+ { created_at: '2026-06-01', detail: { home_country: 'NL', builder_geography: 'global', minors_on_board: false, entity: 'for-profit', project_state: 'existing', framing: 'local' } },
284
+ ] } });
285
+ w.run('physLoadAccountOnce()');
286
+ await w.settle();
287
+ assert.equal(w.$('physFillOffer').hidden, false);
288
+ w.$('physFromAccount').click();
289
+ assert.deepEqual(plain(w.a()), { home_country: 'NL', builder_geography: 'global', minors_on_board: false, entity: 'for-profit' }, 'the newest project\'s four checks, and nothing of its own');
290
+ assert.equal(w.$('physCountry').value, 'NL', 'painted where the founder can see it');
291
+ });
292
+
293
+ test('Continue on the checks writes the review\'s prefills, asks the real table, and opens the review', async () => {
294
+ const w = boot({ draft: { step: 3, description: 'A field guide app for birdwatchers' } });
295
+ w.run('phys()');
296
+ Object.assign(w.a(), CHECKS);
297
+ assert.equal(w.run('checksAdvance()'), false, 'the screen never says yes itself');
298
+ await w.settle();
299
+ assert.deepEqual(w.shown, [4]);
300
+ const sent = w.fetched.find((f) => f.url.endsWith('/provisioning/physics-check')).body;
301
+ assert.equal(sent.description, 'A field guide app for birdwatchers');
302
+ assert.deepEqual({ s: sent.detail.project_state, f: sent.detail.framing, n: [sent.detail.builders_at_start, sent.detail.builders_expected] },
303
+ { s: 'new', f: 'managed', n: [1, 1] }, 'a new project, all managed, one person');
304
+ assert.equal(w.state.teamShape, 'solo');
305
+ assert.ok(w.run('physLimits').some((l) => l.option === 'reward_intents' && l.effect === 'hide'), 'the table\'s verdicts shape the review');
306
+ });
307
+
308
+ test('a stop from the server ends on the stop screen, before anything exists', async () => {
309
+ const w = boot({ draft: { step: 3, description: 'ransomware' }, check: () => ({ stop: true, message: 'We cannot host this.' }) });
310
+ w.run('phys()');
311
+ Object.assign(w.a(), CHECKS);
312
+ w.run('checksAdvance()');
313
+ await w.settle();
314
+ assert.deepEqual(w.shown, [], 'no review');
315
+ assert.equal(w.$('physStopMsg').textContent, 'We cannot host this.');
316
+ assert.equal(w.$('checksStop').hidden, false);
317
+ assert.equal(w.$('checksMain').hidden, true);
318
+ });
319
+
320
+ // ── the Team size row (question 5) ───────────────────────────────────────────
321
+ test('Team size: one or many, a start count, and a guessed growth the founder corrects', async () => {
322
+ const w = await onReview();
323
+ assert.deepEqual(w.rowSays('team'), ['One person', 'Just you.'], 'prefilled: one person');
324
+ assert.equal(w.q('[data-phys-solo="one"]')[0].getAttribute('aria-pressed'), 'true');
325
+ assert.equal(w.$('physTeam').hidden, true);
326
+ w.change('team');
327
+ assert.equal(w.isOpen('team'), true);
183
328
  w.q('[data-phys-solo="many"]')[0].click();
184
329
  assert.equal(w.a().builders_at_start, 2);
185
330
  assert.equal(w.a().builders_expected, 5, 'the guess: about 2.6 times the start');
331
+ assert.equal(w.state.teamShape, 'small-team', 'a team invites its people by default');
186
332
  assert.equal(w.$('physTeam').hidden, false);
187
333
  w.q('[data-phys-count="1"]')[0].click();
188
334
  assert.equal(w.a().builders_at_start, 3);
189
335
  assert.equal(w.a().builders_expected, 8, 'the guess follows the start');
190
- assert.deepEqual([w.$('physM1').textContent, w.$('physM2').textContent, w.$('physM3').textContent], ['3', '5', '8'], 'the canvas\'s three months');
191
- assert.match(w.$('physGuessLine').textContent, /about 8 builders by month three/);
336
+ assert.equal(w.$('physStartN').textContent, '3');
337
+ assert.match(w.$('physGuessLine').textContent, /about 8 people by month three/);
192
338
  w.$('physExpected').value = '12';
193
339
  w.$('physExpected').listeners.input();
194
340
  assert.equal(w.a().builders_expected, 12, 'the founder corrects it');
195
341
  w.q('[data-phys-count="1"]')[0].click();
196
342
  assert.equal(w.a().builders_expected, 12, 'and once corrected the guess stays theirs');
197
- assert.equal(w.q('[data-phys-solo="many"]')[0].getAttribute('aria-pressed'), 'true');
198
- await w.next();
199
- assert.equal(w.state.physics.q, 6, 'a team goes on to rewards');
343
+ assert.deepEqual(w.rowSays('team'), ['4 people now', 'About 12 by month three · people you invite.']);
344
+ w.state.teamShape = 'community'; w.run('paintSetup()');
345
+ assert.equal(w.rowSays('team')[1], 'About 12 by month three · anyone can apply.');
346
+ w.use('team');
347
+ assert.equal(w.state.confirmed.team, true);
348
+ assert.equal(w.isOpen('team'), false, 'Use this closes the row');
200
349
  });
201
350
 
202
- test('rewards are hidden when solo: the table says so, the walk skips question 6, the create sends none', async () => {
203
- const w = at(5, { ...FOUR, reward_intents: ['cash'] });
351
+ test('changing the team re-asks the rows it moves: rewards and extras', async () => {
352
+ const w = await onReview({ builders_at_start: 3, builders_expected: 8 });
353
+ w.state.confirmed = { rewards: true, extras: true, code: true };
204
354
  w.q('[data-phys-solo="one"]')[0].click();
205
- assert.equal(w.a().builders_at_start, 1);
206
- assert.equal(w.a().builders_expected, 1);
207
- assert.equal(w.$('physTeam').hidden, true);
208
- await w.next();
209
- assert.equal(w.state.physics.q, 7, 'straight to framing');
210
- assert.ok(w.fetched.at(-1).body.detail.builders_at_start === 1, 'the check was asked with the answer');
211
- const rail6 = w.q('#physRail li').find((li) => li.attrs['data-q'] === '6');
212
- assert.ok(rail6.classList.contains('skipped'), 'the rail strikes it');
213
- assert.equal(w.run('createDetail()').reward_intents, undefined, 'nothing answered for a question never asked');
214
- w.$('physBack').listeners.click();
215
- assert.equal(w.state.physics.q, 5, 'Back steps over it too');
355
+ assert.deepEqual(Object.keys(w.state.confirmed), ['code'], 'rewards and extras come back to be confirmed; the repo does not');
356
+ assert.equal(w.state.teamShape, 'solo');
357
+ });
358
+
359
+ test('a team row with no count cannot be confirmed: it opens instead', async () => {
360
+ const w = await onReview();
361
+ w.state.physics.answers.builders_at_start = 4; w.state.physics.answers.builders_expected = 2; // fewer later than now
362
+ assert.equal(w.run("confirmRow('team')"), false);
363
+ assert.equal(w.isOpen('team'), true, 'the row opens with its controls');
364
+ assert.equal(w.row('team').classList.contains('open'), true);
365
+ assert.equal(w.row('team').querySelector('[data-row-chg]').getAttribute('aria-expanded'), 'true');
366
+ assert.equal(w.state.confirmed.team, undefined);
367
+ });
368
+
369
+ // ── the Rewards row (question 6) ─────────────────────────────────────────────
370
+ test('rewards are hidden for a solo founder: the real table hides the row, and the create sends none', async () => {
371
+ const w = await onReview({ reward_intents: ['cash'] });
372
+ assert.equal(w.run("rowApplies('rewards')"), false, 'the REAL softLimitsFor hides reward_intents for one-and-one');
373
+ assert.equal(w.row('rewards').hidden, true, 'and the row goes with it');
374
+ assert.ok(!w.run('unconfirmedRows()').includes('rewards'), 'nothing to confirm for a question never asked');
375
+ assert.equal(w.go().textContent, 'Confirm 6 more', 'six rows apply');
376
+ assert.equal(w.run('createDetail()').reward_intents, undefined);
377
+ w.change('team');
378
+ w.q('[data-phys-solo="many"]')[0].click();
379
+ await w.settle();
380
+ assert.equal(w.row('rewards').hidden, false, 'a team brings the question back, once the table is asked again');
381
+ assert.equal(w.go().textContent, 'Confirm 7 more');
216
382
  });
217
383
 
218
384
  test('rewards: a greyed option is disabled and leaves the list, and each reason is said once', async () => {
219
- const w = at(6, { ...FOUR, minors_on_board: true, builders_at_start: 3, builders_expected: 8 });
220
- await w.run('physCheck()').then((res) => { w.run(`physLimits = ${JSON.stringify(res.soft_limits.limits)}`); });
385
+ const w = await onReview({ minors_on_board: true, builders_at_start: 3, builders_expected: 8 });
386
+ assert.deepEqual(w.rowSays('rewards'), ['Not decided yet', 'Intent only. You set the real terms later.']);
387
+ w.change('rewards');
221
388
  w.pick('reward_intents', 'non-monetary');
222
389
  w.pick('reward_intents', 'future-payouts');
223
390
  assert.deepEqual(plain(w.a().reward_intents), ['non-monetary', 'future-payouts']);
@@ -227,23 +394,24 @@ test('rewards: a greyed option is disabled and leaves the list, and each reason
227
394
  const list = w.q('[data-why-list="reward_intents"]')[0];
228
395
  assert.equal(list.hidden, false);
229
396
  const lines = list.children.map((li) => li.textContent);
230
- assert.ok(lines.some((l) => /^Equity or shares: Equity is off until you form a company/.test(l)), lines.join('\n'));
397
+ assert.ok(lines.some((l) => /^Equity: Equity is off until you form a company/.test(l)), lines.join('\n'));
231
398
  assert.equal(lines.filter((l) => /13 to 15/.test(l)).length, 1, 'the minors reason names its three options in one line');
232
- assert.ok(lines.some((l) => /^Cash for work, Equity or shares, Future profits or payouts: Builders aged 13 to 15/.test(l)));
399
+ assert.ok(lines.some((l) => /^Cash for work, Equity, Future payouts: Builders aged 13 to 15/.test(l)));
233
400
  assert.ok(lines.some((l) => /^Cash for work: Cash payouts to builders in the EU/.test(l)), 'a note is said too');
401
+ assert.deepEqual(w.rowSays('rewards'), ['Learning and fun, Future payouts', 'Intent only. You set the real terms later.']);
234
402
  w.pick('reward_intents', 'future-payouts');
235
403
  assert.deepEqual(plain(w.a().reward_intents), ['non-monetary'], 'a second click takes one away');
236
- await w.next();
237
- assert.equal(w.state.physics.q, 7);
238
404
  });
239
405
 
240
- // ── question 7 ───────────────────────────────────────────────────────────────
241
- test('framing: hybrid and local open the suite pre-picked; managed and open source hide it with the table\'s reason', async () => {
242
- const w = at(7, { ...FOUR, builders_at_start: 3, builders_expected: 8, reward_intents: ['non-monetary'] });
243
- await w.next();
244
- assert.equal(w.state.physics.q, 7, 'no framing: it stays');
406
+ // ── the We run row (question 7) ──────────────────────────────────────────────
407
+ test('We run: hybrid and local open the suite pre-picked; managed and open source hide it with the table\'s reason', async () => {
408
+ const w = await onReview({ builders_at_start: 3, builders_expected: 8, reward_intents: ['non-monetary'] });
409
+ assert.deepEqual(w.rowSays('run'), ['Everything, for you', 'Hosting, payouts and the starter kit, managed.'], 'prefilled: all managed');
410
+ w.state.confirmed.app = true;
411
+ w.change('run');
245
412
  w.pick('framing', 'hybrid');
246
- await tick(); await tick();
413
+ await w.settle();
414
+ assert.equal(w.state.confirmed.app, undefined, 'a new framing re-asks the App hosting row');
247
415
  assert.deepEqual(plain(w.a().services), ['hosting', 'starter-kit', 'marketplace-buy', 'guild-access'], 'the canvas\'s hybrid suite');
248
416
  assert.equal(w.$('physSuite').hidden, false);
249
417
  assert.equal(w.$('physSuiteLbl').textContent, 'Your hybrid suite');
@@ -252,219 +420,202 @@ test('framing: hybrid and local open the suite pre-picked; managed and open sour
252
420
  assert.equal(w.q('[data-phys="services"][data-val="public-marketing"]')[0].disabled, true, 'on request');
253
421
  w.pick('services', 'marketplace-sell');
254
422
  assert.ok(w.a().services.includes('marketplace-sell'));
423
+ assert.deepEqual(w.rowSays('run'), ['Some of it', 'Hosting, Starting module kit, Module marketplace: buy, Module marketplace: sell, Builder guild access']);
255
424
  w.pick('framing', 'local');
256
- await tick(); await tick();
425
+ await w.settle();
257
426
  assert.ok(w.a().services.includes('marketplace-sell'), 'a suite the founder touched is theirs: not re-seeded');
427
+ assert.equal(w.$('physSuiteLbl').textContent, 'Your local suite');
258
428
  w.pick('framing', 'managed');
259
- await tick(); await tick();
429
+ await w.settle();
260
430
  assert.equal(w.$('physSuite').hidden, true);
261
431
  const why = w.q('[data-why="services"]')[0];
262
432
  assert.equal(why.hidden, false);
263
433
  assert.equal(why.textContent, SOFT_LIMIT_RULES.find((r) => r.id === 'managed-includes-every-service').reason);
264
434
  assert.equal(w.run('createDetail()').services, undefined, 'all managed sends no suite');
265
- assert.equal(w.$('physStage').getAttribute('data-orbit'), 'near');
266
- await w.next();
267
- assert.equal(w.state.physics.q, 'sum');
435
+ w.pick('framing', 'managed');
436
+ assert.equal(w.a().framing, null, 'a second click un-says it');
437
+ assert.equal(w.run("rowValid('run')"), false, 'and a run row with no framing cannot be confirmed');
268
438
  });
269
439
 
270
440
  test('a company already formed hides company creation from the suite', async () => {
271
- const w = at(7, { ...FOUR, entity: 'for-profit', builders_at_start: 3, builders_expected: 8, reward_intents: ['equity'] });
441
+ const w = await onReview({ entity: 'for-profit', builders_at_start: 3, builders_expected: 8, reward_intents: ['equity'] });
272
442
  w.pick('framing', 'hybrid');
273
- await tick(); await tick();
443
+ await w.settle();
274
444
  assert.equal(w.q('[data-phys="services"][data-val="company-creation"]')[0].hidden, true);
275
445
  });
276
446
 
277
- // ── the voice (spec D5) ──────────────────────────────────────────────────────
278
- test('the voice is chosen from the entity: professional for an organization, the planet\'s for everyone else', () => {
279
- for (const [entity, voice] of [['for-profit', 'professional'], ['nonprofit', 'professional'], ['research', 'professional'], ['corporate-division', 'professional'],
280
- ['intent-company', 'planet'], ['intent-solo', 'planet'], ['social-group', 'planet'], ['university-project', 'planet'], ['private-individual', 'planet'], ['single-owner-llc', 'planet']]) {
281
- const w = at(5, { ...FOUR, entity });
282
- assert.equal(w.run('physVoice()'), voice, entity);
283
- assert.equal(w.run('createDetail()').voice, voice, `${entity} stores the voice it reads`);
284
- const pro = w.q('[data-voice="pro"]'), planet = w.q('[data-voice="planet"]');
285
- assert.ok(pro.length >= 10 && planet.length === pro.length, 'both variants are baked, pair by pair');
286
- assert.ok(pro.every((e) => e.hidden === (voice !== 'professional')), `${entity}: the professional copy shows only for an organization`);
287
- assert.ok(planet.every((e) => e.hidden === (voice === 'professional')), `${entity}: the planet copy otherwise`);
447
+ // ── fixed rows: the App hosting row on your own machines, and a demo's own answers ──
448
+ test('a planet on its own machines fixes the App hosting row: it says so, confirms without the Render check, and never opens', async () => {
449
+ for (const [framing, fixed] of [['managed', false], ['hybrid', false], ['local', true], ['open-source', true]]) {
450
+ const w = await onReview({ framing }, { renderOk: false });
451
+ assert.equal(w.run("rowFixed('app')"), fixed, framing);
452
+ assert.equal(w.row('app').querySelector('[data-row-chg]').hidden, fixed, `${framing}: Change is ${fixed ? 'gone' : 'offered'}`);
453
+ assert.equal(w.run("confirmRow('app')"), fixed, `${framing}: ${fixed ? 'confirmed without' : 'held by'} the Render check`);
454
+ if (fixed) assert.equal(w.rowSays('app')[0], 'Runs on your own machines');
455
+ else assert.equal(w.isOpen('app'), true, 'a failed check opens the row with its reason');
288
456
  }
289
- const pro = at(5, { ...FOUR, entity: 'for-profit', builders_at_start: 12, builders_expected: 30 });
290
- assert.match(pro.$('physGuessLine').textContent, /we estimate 30 contributors by month three/);
291
- assert.equal(pro.$('physCapLbl').textContent, 'project profile');
292
- assert.match(pro.$('physCoords').textContent, /team 12 → 30/);
293
- const planet = at(5, { ...FOUR, builders_at_start: 3, builders_expected: 8 });
294
- assert.match(planet.$('physCallout').textContent, /moons · 3 builders now · the dashed orbit holds about 8/);
457
+ const demo = await onReview({ framing: 'local' }, { renderOk: false, draft: { demo: { people: 3, hours_each: 4 } } });
458
+ assert.equal(demo.run("rowFixed('app')"), false, 'a demo is all managed, whatever framing a walk left behind');
295
459
  });
296
460
 
297
- test('the voice variants are worded apart: the professional copy carries no planet words', () => {
298
- const pro = [...PANEL.matchAll(/<span data-voice="pro" hidden>([^<]*)/g)].map((m) => m[1]);
299
- assert.ok(pro.length >= 10);
300
- for (const t of pro) assert.doesNotMatch(t, /planet|moon|orbit/i, t);
461
+ test('a demo\'s team, rewards and run are the demo\'s own: fixed, said plainly, never opened', async () => {
462
+ const w = await onReview({}, { draft: { demo: { people: 3, hours_each: 4, idea: '', invites: [] } } });
463
+ for (const key of ['team', 'rewards', 'run']) {
464
+ assert.equal(w.run(`rowFixed('${key}')`), true, key);
465
+ assert.equal(w.row(key).querySelector('[data-row-chg]').hidden, true, `${key}: no Change`);
466
+ w.run(`openRow('${key}')`);
467
+ assert.equal(w.isOpen(key), false, `${key}: never opens`);
468
+ }
469
+ assert.deepEqual(w.rowSays('team'), ['3 people', '4 hours each. When the time box runs out, we ask if you want to keep it.']);
470
+ assert.deepEqual(w.rowSays('rewards'), ['None', 'It’s a demo, for learning.']);
471
+ assert.deepEqual(w.rowSays('run'), ['Everything, for you', 'A demo is all managed and private to your team.']);
301
472
  });
302
473
 
303
- // ── the summary ──────────────────────────────────────────────────────────────
304
- const SEVEN = { ...FOUR, builders_at_start: 3, builders_expected: 8, reward_intents: ['non-monetary', 'future-payouts'], framing: 'hybrid', services: ['hosting', 'starter-kit'] };
305
- test('the summary lists every answer with a change link back to its question', async () => {
306
- const w = at('sum', SEVEN);
307
- w.state.description = 'A field guide app for birdwatchers';
308
- w.run('paintPhysics()');
309
- const dl = w.$('physSummary');
310
- const dts = dl.children.filter((c) => c.tagName === 'dt').map((c) => c.textContent);
311
- assert.deepEqual(dts, ['State', 'Geography', 'Under 18', 'You are', 'Builders', 'Rewards', 'Framing']);
312
- const dds = dl.children.filter((c) => c.tagName === 'dd');
313
- const said = dds.map((dd) => dd.children[0].text());
314
- assert.match(said[0], /^NewA field guide app for birdwatchers$/);
315
- assert.match(said[1], /^United States \(California\)Builders in the US and EU$/);
316
- assert.match(said[4], /^3 nowAbout 8 by month three$/);
317
- assert.match(said[5], /^Non-monetary, Future profits or payoutsIntent only$/);
318
- assert.match(said[6], /^HybridHosting, Starting module kit$/);
319
- const gotos = dds.map((dd) => dd.children[1]);
320
- assert.deepEqual(gotos.map((b) => b.getAttribute('data-phys-goto')), ['1', '2', '3', '4', '5', '6', '7']);
321
- assert.ok(gotos.every((b) => b.textContent === 'change' && b.type === 'button'));
322
- assert.equal(w.$('physNext').textContent, 'Create my planet →');
323
- assert.ok(w.q('#physRail li').every((li) => li.classList.contains('done')), 'the rail reads complete');
324
- gotos[4].listeners.click();
325
- assert.equal(w.state.physics.q, 5, 'change goes back to that question');
326
- await w.next();
327
- assert.equal(w.state.physics.q, 'sum', 'and Continue returns to the summary, the rest already answered');
474
+ // ── confirming, and the locked Continue ──────────────────────────────────────
475
+ test('the review locks Continue until every row that applies is confirmed, and validateStep opens the first one left', async () => {
476
+ const w = await onReview({ builders_at_start: 3, builders_expected: 8 });
477
+ assert.equal(w.go().disabled, true);
478
+ assert.equal(w.go().textContent, 'Confirm 7 more');
479
+ assert.equal(w.run('validateStep(4)'), false);
480
+ assert.equal(w.isOpen('code'), true, 'the first unconfirmed row opens');
481
+ for (const [i, key] of ['code', 'address', 'app', 'extras', 'team', 'rewards'].entries()) {
482
+ w.ok(key);
483
+ assert.equal(w.state.confirmed[key], true, key);
484
+ assert.equal(w.row(key).querySelector('[data-row-ok]').textContent, 'Confirmed');
485
+ assert.equal(w.row(key).querySelector('[data-row-ok]').disabled, true);
486
+ assert.equal(w.go().textContent, `Confirm ${6 - i} more`);
487
+ }
488
+ assert.equal(w.state.modules && plain(w.state.modules).join(), 'discord', 'confirming the extras is the answer the create sends');
489
+ w.ok('run');
490
+ assert.equal(w.go().disabled, false);
491
+ assert.equal(w.go().textContent, 'Continue');
492
+ assert.equal(w.run('validateStep(4)'), true);
493
+ w.change('team');
494
+ assert.equal(w.state.confirmed.team, undefined, 'Change takes a row back');
495
+ assert.equal(w.isOpen('team'), true);
496
+ assert.equal(w.go().textContent, 'Confirm 1 more');
497
+ w.change('team');
498
+ assert.equal(w.isOpen('team'), false, 'a second Change closes it again');
328
499
  });
329
500
 
330
- test('the summary\'s Create my planet pre-fills the technical steps and moves on to the name', async () => {
331
- const w = at('sum', { ...SEVEN, project_state: 'existing', existing_work: ['code'], main_work: 'code', entity: 'for-profit' });
332
- w.run('paintPhysics()');
333
- assert.equal(w.$('physNext').textContent, 'Continue to setup →', 'the professional voice');
334
- assert.equal(w.run('physAdvance()'), false);
335
- assert.deepEqual(w.shown, [4], 'on to the name step');
336
- assert.equal(w.state.projType, 'business', 'a for-profit company is a business on the map');
337
- assert.equal(w.state.mode, 'adopt', 'a planet that already has code adopts its repo');
338
- assert.equal(w.state.teamShape, null, 'a team of three is not guessed into a shape');
339
- w.run('physPaintPrefillNotes()');
340
- assert.match(w.$('typePrefill').textContent, /Picked from your answer “For-profit company”/);
341
- assert.match(w.$('homePrefill').textContent, /Adopt is picked because your project already has code/);
501
+ test('the prefills never overwrite a founder\'s own answer', () => {
502
+ const w = boot();
503
+ w.run('phys()');
504
+ Object.assign(w.a(), CHECKS, { project_state: 'existing', existing_work: ['code'], main_work: 'code', framing: 'hybrid', builders_at_start: 3, builders_expected: 9 });
505
+ w.state.teamShape = 'community';
506
+ w.run('physDefaults()');
507
+ assert.deepEqual({ s: w.a().project_state, f: w.a().framing, n: [w.a().builders_at_start, w.a().builders_expected], t: w.state.teamShape },
508
+ { s: 'existing', f: 'hybrid', n: [3, 9], t: 'community' });
509
+ assert.equal(w.state.mode, 'adopt', 'a project that already has code adopts its repo');
510
+ w.state.mode = 'greenfield'; w.state.confirmed = { code: true };
511
+ w.run('physDefaults()');
512
+ assert.equal(w.state.mode, 'greenfield', 'but never over a Code row the founder confirmed');
513
+ for (const gone of ['physPrefill', 'typePrefill', 'homePrefill', 'physSummary', 'data-phys-goto', 'physStepSkipped', 'physPastSkips']) {
514
+ assert.ok(!HUB.includes(gone), `${gone} is gone: the review shows every prefill as a row instead`);
515
+ }
342
516
  });
343
517
 
344
- // ── pre-fill and skip, rule by rule ──────────────────────────────────────────
345
- test('a pre-fill never overwrites what the founder chose, and follows a changed answer', () => {
346
- const w = at('sum', { ...SEVEN, builders_at_start: 1, builders_expected: 1, entity: 'nonprofit' });
347
- w.run('physPrefill()');
348
- assert.equal(w.state.projType, 'non-profit');
349
- assert.equal(w.state.teamShape, 'solo', 'one builder says solo');
350
- assert.equal(w.state.mode, 'greenfield');
351
- w.state.projType = 'game'; // the founder picks for themselves on the Type step
352
- w.state.physics.answers.entity = 'research';
353
- w.run('physPrefill()');
354
- assert.equal(w.state.projType, 'game', 'their choice stands');
355
- w.state.projType = 'non-profit'; // back to what we filled …
356
- w.state.prefilled = { projType: 'non-profit' };
357
- w.run('physPrefill()');
358
- assert.equal(w.state.projType, 'research', '… and an untouched pre-fill follows the answer');
359
- w.state.physics.answers.entity = 'intent-unsure';
360
- w.run('physPrefill()');
361
- assert.equal(w.state.projType, null, 'an answer that suggests nothing clears what we filled');
362
- w.run('physPaintPrefillNotes()');
363
- assert.equal(w.$('typePrefill').hidden, true);
518
+ // ── the voice (spec D5; one plain voice since task 1004504) ──────────────────
519
+ test('one plain voice for everyone: professional is stored for an organization and an individual alike', async () => {
520
+ assert.doesNotMatch(HUB, /data-voice=/, 'no voice variants are baked anywhere on the page');
521
+ assert.doesNotMatch(SCRIPT, /physVoice|physPaintVoice|PHYS_ORGS/, 'no voice switch is left in the controller');
522
+ for (const entity of ['for-profit', 'nonprofit', 'research', 'corporate-division',
523
+ 'intent-company', 'intent-solo', 'social-group', 'university-project', 'private-individual', 'single-owner-llc']) {
524
+ const w = await onReview({ entity });
525
+ assert.equal(w.run('createDetail()').voice, 'professional', `${entity} stores the one voice`);
526
+ }
527
+ assert.doesNotMatch(HUB, /id="(physCoords|physCallout|physCapLbl)"/, 'the profile caption left with the forming planet');
364
528
  });
365
529
 
366
- test('a planet on its own machines skips the app step; Next and Back step over it, and a demo never does', () => {
367
- for (const [framing, skipped] of [['managed', false], ['hybrid', false], ['local', true], ['open-source', true]]) {
368
- const w = at('sum', { ...SEVEN, framing });
369
- assert.equal(w.run('physStepSkipped(10)'), skipped, framing);
370
- assert.equal(w.run('physPastSkips(10, 1)'), skipped ? 11 : 10);
371
- assert.equal(w.run('physPastSkips(10, -1)'), skipped ? 9 : 10);
372
- for (const n of [4, 5, 6, 7, 8, 9, 11, 12]) assert.equal(w.run(`physStepSkipped(${n})`), false, `step ${n} always applies`);
373
- }
374
- const w = at('sum', { ...SEVEN, framing: 'open-source' }, { draft: { appPlatform: 'render' } });
375
- w.run('physPrefill()');
376
- assert.equal(w.state.appPlatform, 'later', 'a skipped step keeps its default answer');
377
- w.state.demo = { people: 3, hours_each: 4 };
378
- assert.equal(w.run('physStepSkipped(10)'), false, 'a demo is all managed');
530
+ test('the one voice carries no planet words on the checks and the review', () => {
531
+ const words = PANEL.replace(/<!--[\s\S]*?-->/g, '').replace(/<[^>]*>/g, ' ');
532
+ assert.doesNotMatch(words, /\bplanet|\bmoons?\b|\borbit/i, 'the plain copy names no planet, moon or orbit');
379
533
  });
380
534
 
381
535
  // ── what the create sends ────────────────────────────────────────────────────
536
+ const SEVEN = { ...CHECKS, project_state: 'new', builders_at_start: 3, builders_expected: 8, reward_intents: ['non-monetary', 'future-payouts'], framing: 'hybrid', services: ['hosting', 'starter-kit'] };
382
537
  test('the create sends questions 5 to 7 and the voice as detail, in the vocabulary\'s order', () => {
383
- const w = at('sum', { ...SEVEN, reward_intents: ['future-payouts', 'non-monetary'], services: ['starter-kit', 'hosting'] });
538
+ const w = boot();
539
+ w.run('phys()');
540
+ Object.assign(w.a(), SEVEN, { reward_intents: ['future-payouts', 'non-monetary'], services: ['starter-kit', 'hosting'] });
384
541
  w.state.teamShape = 'small-team';
385
542
  const d = plain(w.run('createDetail()'));
386
543
  assert.deepEqual(d, {
387
544
  project_state: 'new', home_country: 'US', home_region: 'US-CA', builder_geography: 'us-eu', minors_on_board: false, entity: 'intent-solo',
388
545
  builders_at_start: 3, builders_expected: 8, framing: 'hybrid',
389
- reward_intents: ['non-monetary', 'future-payouts'], services: ['hosting', 'starter-kit'], voice: 'planet', team_shape: 'small-team',
546
+ reward_intents: ['non-monetary', 'future-payouts'], services: ['hosting', 'starter-kit'], voice: 'professional', team_shape: 'small-team',
390
547
  });
391
548
  const { badProjectDetail } = require('../modules/provisioning/routes/body-validators.js');
392
549
  assert.equal(badProjectDetail(d), null, 'every key and value is one the create route accepts');
393
550
  });
394
551
 
395
- // ── the planet ───────────────────────────────────────────────────────────────
396
- test('the planet gains moons, rings and its orbit around the platform\'s mark', () => {
397
- const w = at(7, { ...SEVEN, entity: 'for-profit', builders_at_start: 4, builders_expected: 6, reward_intents: ['non-monetary', 'cash', 'future-payouts'], framing: 'local' });
398
- const shown = (sel) => w.q(sel).filter((e) => !e.hidden).length;
399
- assert.equal(shown('#physMoons img'), 4, 'a moon per builder now');
400
- assert.equal(shown('#physGhosts img'), 2, 'faint ones for the expected growth');
401
- assert.ok(w.q('#physMoons img').every((m) => m.getAttribute('src') === '/assets/world/r3-gold-bead-cut.webp'), 'beads of the core\'s material');
402
- assert.equal(shown('#physStage .physBelt'), 3, 'a ring per reward being considered');
403
- assert.equal(w.$('physStage').getAttribute('data-orbit'), 'far', 'local is a far orbit');
404
- assert.equal(w.$('physSun').hidden, false);
405
- assert.equal(w.$('physSun').getAttribute('src'), '/assets/world/r2-bongos-chrome-a-cut.webp', 'the platform\'s own mark');
406
- w.state.physics.answers.framing = 'open-source';
407
- w.run('physPaintStage()');
408
- assert.equal(w.$('physStage').getAttribute('data-orbit'), 'own');
409
- assert.equal(w.$('physSun').hidden, true, 'open source is a system of its own');
410
- w.state.physics.answers.builders_at_start = 9; w.state.physics.answers.builders_expected = 40;
411
- w.run('physPaintStage()');
412
- assert.equal(shown('#physMoons img'), 6, 'six at most');
413
- assert.equal(shown('#physGhosts img'), 4, 'four at most');
414
- w.state.physics.q = 'sum';
415
- w.run('physPaintStage()');
416
- assert.equal(w.$('physPlanet').getAttribute('data-size'), '6', 'the full planet');
417
- assert.equal(w.$('physCapN').textContent, 'complete');
418
- for (const plate of ['r3-frost-bead-cut', 'r3-gold-bead-cut', 'r3-obsidian-bead-cut', 'r2-bongos-chrome-a-cut']) {
419
- assert.ok(fs.existsSync(path.join(LANDING, 'assets', 'world', `${plate}.webp`)), `${plate} is a world plate`);
420
- }
421
- });
422
-
423
- test('the orbit and the moons move on CSS alone, and the reduced-motion switch stops them', () => {
424
- const css = HUB.slice(HUB.indexOf('.physBelt{'), HUB.indexOf('/* under 520 the two doors'));
425
- assert.match(css, /@media \(prefers-reduced-motion:no-preference\)\{\s*\.physMoons\{animation:physOrbit 160s/);
426
- assert.doesNotMatch(css.replace(/@media \(prefers-reduced-motion:no-preference\)\{[\s\S]*?\n\}/, ''), /animation:/, 'no animation outside the no-preference block');
427
- assert.doesNotMatch(css, /box-shadow/);
428
- assert.doesNotMatch(SCRIPT, /\.animate\(|requestAnimationFrame|innerHTML/, 'no motion and no innerHTML in the controller');
552
+ // ── the planet left; the room moves on CSS alone ─────────────────────────────
553
+ test('the forming planet is gone, and the room behind the wizard moves on CSS alone, inside the reduced-motion switch', () => {
554
+ assert.doesNotMatch(HUB, /id="(physMoons|physGhosts|physSun|physStage|physPlanet)"/, 'no moons, rings, sun or stage');
555
+ assert.doesNotMatch(HUB, /physPaintStage|\.physBelt\{/);
556
+ const css = slice('#view-new .wizRoom{', '/* under 900 the window steps back', 'the room\'s rules');
557
+ assert.match(css, /@media \(prefers-reduced-motion:no-preference\)\{\s*#view-new \.rmSeed\{transition:transform 1\.2s ease;\}/);
558
+ assert.doesNotMatch(css.replace(/@media \(prefers-reduced-motion:no-preference\)\{[\s\S]*?\n\}/, ''), /animation:|transition:/, 'no motion outside the no-preference block');
559
+ assert.doesNotMatch(css, /box-shadow:[^;]*#[0-9a-f]{3,6}/i, 'no literal colour in a glow');
560
+ assert.doesNotMatch(SCRIPT + REVIEW, /\.animate\(|requestAnimationFrame|innerHTML/, 'no motion and no innerHTML in the controller or the review');
429
561
  });
430
562
 
431
563
  // ── the done-when, end to end ────────────────────────────────────────────────
432
- test('a new owner walks from question 1 to Create my planet, and the technical steps arrive pre-filled', async () => {
433
- const w = boot();
434
- await w.next(); // the start screen
435
- assert.equal(w.state.physics.q, 1);
436
- w.pick('project_state', 'existing');
437
- w.state.physics.answers.existing_work = ['code']; w.state.physics.answers.main_work = 'code';
438
- w.state.description = 'An inventory tool our team started last year.';
439
- await w.next();
440
- assert.equal(w.state.physics.q, 2);
441
- Object.assign(w.state.physics.answers, { home_country: 'US', home_region: 'US-NY' });
564
+ test('a new owner walks from the quick checks through a fully confirmed review, the setup prefilled', async () => {
565
+ const w = boot({ draft: { step: 3, description: 'An inventory tool our team started last year.' } });
566
+ w.run('paintChecks()');
567
+ w.$('physCountry').value = 'US'; w.$('physCountry').listeners.change();
568
+ w.$('physRegion').value = 'US-NY'; w.$('physRegion').listeners.change();
442
569
  w.pick('builder_geography', 'global');
443
- await w.next();
444
570
  w.pick('minors_on_board', 'false');
445
- await w.next();
446
- w.pick('entity', 'for-profit');
447
- await w.next();
448
- assert.equal(w.state.physics.q, 5);
571
+ w.$('physHaveEntity').click();
572
+ w.pick('entity', 'for-profit'); // a company already formed, so equity is on the table
573
+ w.run('checksAdvance()');
574
+ await w.settle();
575
+ assert.deepEqual(w.shown, [4], 'the review');
576
+ w.run('paintSetup()');
577
+ assert.equal(w.row('rewards').hidden, true, 'one person: no rewards row');
578
+ w.ok('code'); w.ok('address'); w.ok('app'); w.ok('extras');
579
+ w.change('team');
449
580
  w.q('[data-phys-solo="many"]')[0].click();
450
- await w.next();
451
- assert.equal(w.state.physics.q, 6);
452
- w.pick('reward_intents', 'equity');
581
+ w.use('team');
582
+ await w.settle();
583
+ assert.equal(w.state.confirmed.extras, undefined, 'the team moved the extras\' preselect, so that row is asked again');
584
+ w.ok('extras');
585
+ assert.equal(w.row('rewards').hidden, false);
586
+ w.change('rewards');
453
587
  w.pick('reward_intents', 'cash');
454
- await w.next();
455
- assert.equal(w.state.physics.q, 7);
588
+ w.pick('reward_intents', 'equity');
589
+ w.use('rewards');
590
+ w.change('run');
456
591
  w.pick('framing', 'open-source');
457
- await tick(); await tick();
458
- await w.next();
459
- assert.equal(w.state.physics.q, 'sum');
460
- assert.equal(w.$('physNext').textContent, 'Continue to setup →');
461
- await w.next();
462
- assert.deepEqual(w.shown, [4], 'the name step');
463
- assert.deepEqual({ type: w.state.projType, mode: w.state.mode, app: w.state.appPlatform, skipApp: w.run('physStepSkipped(10)') },
464
- { type: 'business', mode: 'adopt', app: 'later', skipApp: true });
592
+ await w.settle();
593
+ w.use('run');
594
+ assert.equal(w.state.confirmed.app, undefined, 'the framing moved the App hosting row');
595
+ assert.equal(w.rowSays('app')[0], 'Runs on your own machines');
596
+ w.ok('app');
597
+ assert.equal(w.go().textContent, 'Continue');
598
+ assert.equal(w.run('validateStep(4)'), true);
465
599
  const d = plain(w.run('createDetail()'));
466
- assert.equal(d.voice, 'professional');
467
- assert.equal(d.framing, 'open-source');
468
- assert.equal(d.services, undefined);
469
- assert.deepEqual(d.reward_intents, ['cash', 'equity']);
600
+ assert.deepEqual({ voice: d.voice, framing: d.framing, services: d.services, team: [d.builders_at_start, d.builders_expected], shape: d.team_shape, rewards: d.reward_intents },
601
+ { voice: 'professional', framing: 'open-source', services: undefined, team: [2, 5], shape: 'small-team', rewards: ['cash', 'equity'] });
602
+ });
603
+
604
+ // ── the address is the founder's own choice, on every path (task 1004507, BV2.PS19) ──
605
+ test('no path reaches Look without the founder confirming the address: a prefill suggests it, never confirms it', async () => {
606
+ for (const draft of [{}, { demo: { people: 3, hours_each: 4, idea: '', invites: [] } }]) {
607
+ const label = draft.demo ? 'demo' : 'real project';
608
+ const w = await onReview({}, { draft });
609
+ assert.equal(w.state.confirmed.address, undefined, `${label}: the prefills leave the address unconfirmed`);
610
+ assert.equal(w.run("rowFixed('address')"), false, `${label}: the address is never a fixed row`);
611
+ assert.equal(w.row('address').querySelector('[data-row-chg]').hidden, false, `${label}: it can always be changed`);
612
+ for (const key of ['code', 'app', 'extras', 'team', 'rewards', 'run']) if (w.run(`rowApplies('${key}')`)) w.ok(key);
613
+ assert.deepEqual(plain(w.run('unconfirmedRows()')), ['address'], `${label}: every other row confirmed, the address still asked`);
614
+ assert.equal(w.go().disabled, true, `${label}: Continue stays locked`);
615
+ assert.equal(w.run('validateStep(4)'), false, `${label}: Continue refuses`);
616
+ assert.equal(w.isOpen('address'), true, `${label}: and opens the address row`);
617
+ w.use('address');
618
+ assert.equal(w.state.confirmed.address, true, `${label}: choosing it is the answer`);
619
+ assert.equal(w.run('validateStep(4)'), true, `${label}: then Continue goes on`);
620
+ }
470
621
  });