@bongos/core 1.21.105 → 1.21.107

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 (55) hide show
  1. package/.bongos-core.json +88 -58
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +6 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/api/openapi.json +91 -2
  8. package/docs/api-reference.md +5 -3
  9. package/docs/copy-inventory.md +368 -366
  10. package/docs/copy-registry.json +475 -457
  11. package/docs/design/project-startup-direction.md +14 -0
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +10 -3
  14. package/docs/page-readings.json +1023 -977
  15. package/migrations/core_275_claims_auto_pickup.sql +24 -0
  16. package/modules/government/decision-rules.js +1 -0
  17. package/modules/lifecycle/autopickup.js +94 -0
  18. package/modules/lifecycle/db-claims.js +18 -4
  19. package/modules/lifecycle/routes/claims.js +12 -1
  20. package/modules/lifecycle/routes/project-settings.js +59 -0
  21. package/modules/public-landing/public/projects-hall-live.states.json +3 -2
  22. package/modules/public-landing/public/projects-setup.states.json +91 -0
  23. package/modules/public-landing/public/projects.html +535 -804
  24. package/modules/public-landing/public/projects.probes.json +3 -3
  25. package/modules/public-landing/public/projects.states.json +1 -1
  26. package/modules/ui-design/kit/fixtures/provisioning-instances-hall-live.json +22 -2
  27. package/modules/ui-design/kit/fixtures/provisioning-instances-setup.json +195 -0
  28. package/modules/ui-design/kit/serve.js +22 -4
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/release-notes.json +12 -0
  32. package/scripts/gds/government-walk.js +1 -0
  33. package/scripts/gds/run-unit-tests.js +3 -0
  34. package/src/bongos/project-settings.js +79 -0
  35. package/src/module-api.js +8 -1
  36. package/tests/autopickup_cap.mjs +236 -0
  37. package/tests/autopickup_cap_db.mjs +115 -0
  38. package/tests/cosmos_token_contrast.mjs +10 -18
  39. package/tests/github_handback_any_device.mjs +4 -3
  40. package/tests/github_one_trip.mjs +45 -47
  41. package/tests/github_org_account.mjs +2 -2
  42. package/tests/github_pick_up.mjs +40 -29
  43. package/tests/government_act_alone_buildings.mjs +1 -0
  44. package/tests/onboard.mjs +13 -7
  45. package/tests/project_invite_ui.mjs +6 -3
  46. package/tests/project_modules_ui.mjs +3 -3
  47. package/tests/projects_hub.mjs +8 -9
  48. package/tests/projects_hub_cloud_hop.mjs +77 -125
  49. package/tests/projects_hub_dns_ready.mjs +42 -51
  50. package/tests/projects_hub_pre_uat.mjs +68 -69
  51. package/tests/public_landing_projects.mjs +45 -102
  52. package/tests/setup_status_facts.mjs +400 -0
  53. package/tests/wizard_github_feedback.mjs +90 -87
  54. package/tests/wizard_one_page.mjs +11 -9
  55. package/tests/wizard_auto_open.mjs +0 -94
@@ -13,7 +13,7 @@
13
13
  // 2. the runner — a stopped sign-in is re-asked with the keys already placed and becomes
14
14
  // 'done'; a placement that throws after GitHub's conversion is a SPENT code (never
15
15
  // re-queued); GitHub's 404 on a conversion drops the dead code.
16
- // 3. the REAL status route's answer run into the REAL page (seedSetup + the step renderers):
16
+ // 3. the REAL status route's answer run into the REAL page (seedSetup + part 03's facts, task 1004683):
17
17
  // every reachable state draws a sentence and an action, none a chore or "contact us".
18
18
  // 4. the page's own actions: pick-up posts to the pick-up route and watches again, Reopen
19
19
  // GitHub goes through the mint route (which hands back the SAME state), and "Send it to
@@ -184,29 +184,29 @@ const express = require('express');
184
184
  const app = express();
185
185
  app.use('/api/bongos', require(path.join(ROOT, 'modules', 'provisioning', 'routes', 'github-setup.js'))());
186
186
 
187
- const RS = SCRIPT.indexOf(' /* The four GitHub steps of part 03');
188
- const RE = SCRIPT.indexOf(' var STEP_RENDERERS = {', RS);
187
+ // part 03's facts (task 1004683): the pure half of the facts region, the seed, and the actions
188
+ const FS = SCRIPT.indexOf(' function ghStep(key) {');
189
+ const FE = SCRIPT.indexOf(' /* ── drawing it: nodes and textContent only', FS);
189
190
  const SS = SCRIPT.indexOf(' /* the checklist the server derived (task 1004649)');
190
191
  const SE = SCRIPT.indexOf(' function setupOpen() {', SS);
191
192
  const AS = SCRIPT.indexOf(' /* ── Pick up where it stopped (task 1004652).');
192
193
  const AE = SCRIPT.indexOf(' function startManifest(btn) {', AS);
193
194
 
194
195
  test('the page regions are findable (a rename must fail loudly)', () => {
195
- assert.ok(RS > 0 && RE > RS && SS > 0 && SE > SS && AS > 0 && AE > AS);
196
+ assert.ok(FS > 0 && FE > FS && SS > 0 && SE > SS && AS > 0 && AE > AS);
196
197
  });
197
198
 
198
- const esc = (x) => String(x).replace(/[&<>"']/g, (ch) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[ch]));
199
199
  function page(extra = {}) {
200
+ const now = new Date().toISOString();
200
201
  const sandbox = {
201
- live: { manifest: null, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug: null, appOrg: null, setup: null, installUrl: null, instStatus: 'active', addrReady: true, pickUp: null, verifying: false, askedPlatform: false },
202
- state: { name: 'Mercury', slug: 'mercury', owner: 'acme', repo: 'mercury' }, autoOpenArmed: false,
203
- resolvedDomain: () => 'mercury.example.com', apexBase: () => 'example.com', esc,
204
- GH_ICON: '', manualOauthDetails: () => '<details>manual</details>', projectPageLink: (t) => t, autoOpenTick: () => '',
205
- progressMarkup: (a, b, note) => `<prog>${note}</prog>`, encodeURIComponent, JSON,
202
+ live: { manifest: null, manifestMsg: '', manifestCode: '', manifestFailure: null, appSlug: null, appOrg: null, setup: null, installUrl: null, instStatus: 'active', addrReady: true, pickUp: null, verifying: false, askedPlatform: false,
203
+ inst: { id: 42, status: 'active', domain: 'mercury.example.com', dns_resolves: true, last_verified_up: true, last_verified_at: now, created_at: now, provisioned_at: now, open_intent: null }, readAt: Date.now(), gone: '', repo: null },
204
+ state: { name: 'Mercury', slug: 'mercury', owner: 'acme', repo: 'mercury' },
205
+ resolvedDomain: () => 'mercury.example.com', apexBase: () => 'example.com', addressReady: () => true, API: '/api/bongos',
206
206
  ...extra,
207
207
  };
208
208
  vm.createContext(sandbox);
209
- vm.runInContext(`${SCRIPT.slice(RS, RE)}\n${SCRIPT.slice(SS, SE)}\n${SCRIPT.slice(AS, AE)}`, sandbox);
209
+ vm.runInContext(`${SCRIPT.slice(FS, FE)}\n${SCRIPT.slice(SS, SE)}\n${SCRIPT.slice(AS, AE)}`, sandbox);
210
210
  return sandbox;
211
211
  }
212
212
  // what the page's poll does with one answer (pollManifest's tick, minus the timer)
@@ -216,7 +216,12 @@ function seedFrom(sb, d) {
216
216
  sb.live.manifest = d.ready ? 'done' : d.status;
217
217
  sb.live.manifestFailure = d.failure || null;
218
218
  }
219
- const ROWS = ['renderStepOauthApp', 'renderStepAppInstall', 'renderStepFirstSignIn', 'renderStepDeviceFlow'];
219
+ // the GitHub facts and the one sentence, as the founder reads them (vm-born values through JSON)
220
+ function facts(sb) {
221
+ return JSON.parse(JSON.stringify(vm.runInContext('(function () { var n = Date.now(), h = homeFacts(live.inst, n), g = ghFacts(n); return { gh: g, said: setupSentence(h, g) }; })()', sb)));
222
+ }
223
+ const words = (r) => [r.title, [].concat(r.sub || []).map((p) => (typeof p === 'string' ? p : p.t)).join('')].join(' ');
224
+ const acts = (r) => [].concat(r.act || []);
220
225
  const CHORE = /\/contact|Contact us|delete it|Developer settings → GitHub Apps\) so|Try again anyway|Start the setup again|by hand/;
221
226
 
222
227
  // every shape the status derivation can reach, as rows the real route reads
@@ -256,31 +261,37 @@ test('every state the setup can reach: the REAL status answer draws a sentence a
256
261
  assert.equal(d.ok, true, name);
257
262
  const sb = page();
258
263
  seedFrom(sb, d);
259
- const html = ROWS.map((f) => vm.runInContext(`${f}()`, sb)).join('\n');
260
- seen[name] = { pick: d.pick_up, html };
261
- assert.doesNotMatch(html, CHORE, `${name}: no manual chore and no "contact us"`);
262
- // a stopped step always carries the action that fixes it
263
- for (const li of html.split('\n').filter((x) => /class="sstopped/.test(x))) {
264
- assert.match(li, /data-action="(pick-up|manifest-start|ask-platform)"/, `${name}: a stopped step has its button — ${li.slice(0, 120)}`);
264
+ const f = facts(sb);
265
+ const all = f.gh.map(words).join('\n') + '\n' + f.said.map((p) => p.t).join('');
266
+ const allActs = f.gh.flatMap(acts);
267
+ seen[name] = { pick: d.pick_up, f, all, allActs };
268
+ assert.doesNotMatch(all, CHORE, `${name}: no manual chore and no "contact us"`);
269
+ // a stopped fact always carries the action that fixes it
270
+ for (const r of f.gh.filter((x) => x.state === 'bad')) {
271
+ assert.ok(acts(r).some((a) => /^(pick-up|pick-up-link|connect|ask-platform|manage)$/.test(a)), `${name}: the stopped "${r.title}" has its button`);
272
+ }
273
+ // a pick-up the server offers is on the page, once
274
+ if (d.pick_up === 'check' || d.pick_up === 'convert') {
275
+ assert.equal(allActs.filter((a) => /^pick-up/.test(a) || a === 'check-again').length, 1, `${name}: ${d.pick_up} is offered once`);
265
276
  }
266
- // a pick-up the server offers is on the page
267
- if (d.pick_up === 'check' || d.pick_up === 'convert') assert.match(html, /data-action="pick-up"/, `${name}: ${d.pick_up} is offered`);
268
- if (d.pick_up === 'operator') assert.match(html, /data-action="ask-platform"/, `${name}: the platform team is one click away`);
269
- if (d.pick_up === 'reopen') assert.match(html, /data-action="reopen-github"/, `${name}: the same link can be reopened`);
277
+ if (d.pick_up === 'operator') assert.ok(allActs.includes('ask-platform'), `${name}: the platform team is one click away`);
278
+ if (d.pick_up === 'reopen') assert.ok(allActs.includes('reopen'), `${name}: the same link can be reopened`);
270
279
  // a new link is offered ONLY where nothing was made on GitHub
271
- if (/data-action="manifest-start"/.test(html)) assert.ok(d.pick_up === 'fresh', `${name}: Connect GitHub is offered only when no app exists (pick_up=${d.pick_up})`);
280
+ if (allActs.includes('connect')) assert.ok(d.pick_up === 'fresh', `${name}: Connect GitHub is offered only when no app exists (pick_up=${d.pick_up})`);
272
281
  }
273
282
  assert.equal(seen['waiting on GitHub'].pick, 'reopen');
274
283
  assert.equal(seen['link lapsed'].pick, 'fresh');
275
284
  assert.equal(seen['conversion stopped, code young'].pick, 'convert');
276
285
  assert.equal(seen['conversion stopped, code spent'].pick, 'operator');
277
286
  assert.equal(seen['sign-in unconfirmed, re-asking'].pick, null);
278
- assert.match(seen['sign-in unconfirmed, re-asking'].html, /We’re asking it again/);
287
+ assert.match(seen['sign-in unconfirmed, re-asking'].all, /Asking your project again, with the app GitHub already made\./);
279
288
  assert.equal(seen['sign-in unconfirmed, window over'].pick, 'check');
280
- assert.match(seen['signed in, install never confirmed, window over'].html, /GitHub stopped before the app was installed on your repo\. Nothing is lost, and no second app will be made\./, 'the mockup\'s sentence, from the real read');
281
- assert.match(seen['installed, device flow off, window over'].html, /Turned it on\? Check again/);
289
+ const never = seen['signed in, install never confirmed, window over'];
290
+ assert.equal(never.f.gh[1].sub, 'GitHub stopped before the app was on acme/mercury.', 'the mockup\'s sentence, from the real read');
291
+ assert.equal(never.f.said.map((p) => p.t).join(''), 'One step stopped. Nothing is lost, and no second app will be made.', 'said once, in the sentence');
292
+ assert.ok(seen['installed, device flow off, window over'].allActs.includes('check-again'));
282
293
  assert.equal(seen['all four ticked'].pick, null);
283
- assert.doesNotMatch(seen['all four ticked'].html, /sstopped|data-action="pick-up"/);
294
+ assert.ok(!seen['all four ticked'].f.gh.some((r) => r.state === 'bad') && !seen['all four ticked'].allActs.some((a) => /^pick-up/.test(a)));
284
295
  t.diagnostic(Object.entries(seen).map(([k, v]) => `${k}: ${v.pick}`).join(' · '));
285
296
  });
286
297
 
@@ -289,13 +300,13 @@ test('every state the setup can reach: the REAL status answer draws a sentence a
289
300
  function actions({ pickUp, answer, manifestState = null, blockerStatus = 201 }) {
290
301
  const calls = { fetched: [], polled: [], progress: [], started: 0, renders: 0 };
291
302
  const sb = page({
292
- API: '/api/bongos', instanceId: 42, manifestState, pollTimer: null, lastStepsHtml: 'x', manifestStartedAt: 1700000000000, ghTab: null,
303
+ instanceId: 42, manifestState, pollTimer: null, manifestStartedAt: 1700000000000, ghTab: null,
293
304
  fetch: async (url, init) => {
294
305
  calls.fetched.push({ url, init });
295
306
  if (/\/blockers$/.test(url)) return { ok: blockerStatus < 300, status: blockerStatus, json: async () => ({}) };
296
307
  return { ok: true, status: 200, json: async () => answer };
297
308
  },
298
- rememberManifest: () => {}, renderSteps: () => { calls.renders++; },
309
+ rememberManifest: () => {}, repaintFacts: () => { calls.renders++; }, paintFacts: () => { calls.renders++; },
299
310
  pollManifest: (s) => calls.polled.push(s), startProgress: (id) => calls.progress.push(id),
300
311
  startManifest: () => { calls.started++; },
301
312
  });
@@ -242,6 +242,7 @@ const BAD = 'x';
242
242
  const REQUESTS = {
243
243
  'PATCH /project-settings/rot-days': ['PATCH', '/project-settings/rot-days', { days: 'soon' }],
244
244
  'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
245
+ 'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
245
246
  'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
246
247
  'POST /security/reports/:id/confirm': ['POST', `/security/reports/${BAD}/confirm`, {}],
247
248
  'POST /security/reports/:id/dispute': ['POST', `/security/reports/${BAD}/dispute`, {}],
package/tests/onboard.mjs CHANGED
@@ -193,11 +193,12 @@ test('every render surface carries the terminal hall step (task 1002703)', () =>
193
193
  const cli = read('scripts/gds/onboard.js');
194
194
  assert.match(cli, /builders hall/i, 'the CLI walkthrough ends at the hall');
195
195
  assert.match(cli, /bongos dev/, 'the CLI offers the local-hall one-liner when no domain');
196
+ // the one page (task 1004683): the hall is entered through the Enter Genesis door, the
197
+ // hall's founding mode, once every required fact of part 03 is done
196
198
  const hubHtml = read('modules/public-landing/public/projects.html');
197
- const enterHall = hubHtml.match(/data-onboard-step="enter-hall"[^]*?<\/li>/g) || [];
198
- assert.ok(enterHall.length >= 1, 'the ?view=new wizard tags the enter-hall step');
199
- assert.match(enterHall.join(''), /\/builders/, 'the wizard points at the hall address');
200
- assert.match(enterHall.join(''), /bongos dev/, 'the wizard offers the local hall when no domain');
199
+ assert.match(hubHtml, /<a class="btn" id="enterGenesis" data-onboard-step="enter-hall" target="_blank" rel="noopener">Enter Genesis<\/a>/,
200
+ 'the ?view=new wizard tags the enter-hall step on its Enter Genesis door');
201
+ assert.match(hubHtml, /\$\('enterGenesis'\)\.setAttribute\('href', 'https:\/\/' \+ inst\.domain\);/, 'which opens the project\'s own address');
201
202
  });
202
203
 
203
204
  test('the wizard done panel is plan-driven, manifest-driven, and owner-honest (task 1002694)', () => {
@@ -206,8 +207,10 @@ test('the wizard done panel is plan-driven, manifest-driven, and owner-honest (t
206
207
  const { readFileSync } = require('node:fs');
207
208
  const path = require('node:path');
208
209
  const src = readFileSync(path.join(import.meta.dirname, '..', 'modules/public-landing/public/projects.html'), 'utf8');
209
- // gap 2 (idea 1000675): the checklist renders FROM the canonical plan endpoint
210
- assert.match(src, /provisioning\/onboard-plan/, 'the done panel fetches the canonical plan');
210
+ // gap 2 (idea 1000675): the checklist follows the canonical plan. Since task 1004683 part
211
+ // 03 is a fixed list of facts the server checks, each GitHub fact named by the plan step
212
+ // it is (FACT_PLAN_STEP); the drift guard below holds that set equal to the plan's.
213
+ assert.match(src, /var FACT_PLAN_STEP = \{ approve: 'oauth-app', install: 'app-install', sign_in: 'first-sign-in', device_flow: 'device-flow' \};/, 'the facts name their plan steps');
211
214
  // gap 3 (idea 1000676): the ADR 0133 one-click manifest flow has a UI caller again
212
215
  assert.match(src, /\/github-app/, 'the done panel drives the GitHub-App manifest flow');
213
216
  assert.match(src, /github-app\/status/, 'and polls its progress to reflect live state');
@@ -305,7 +308,10 @@ test('the web form (?view=new) declares EXACTLY the canonical human steps — dr
305
308
  // keys are kebab-case literals; the charset excludes the page's DYNAMIC fallback
306
309
  // tag (data-onboard-step="' + esc(s.key) + '"), which renders unknown plan steps
307
310
  // at runtime and has no literal key to pin here (task 1002694).
308
- const found = [...src.matchAll(/data-onboard-step="([a-z0-9-]+)"/g)].map((m) => m[1]);
311
+ // …plus the plan step each of part 03's facts is (task 1004683), set from one map
312
+ const facts = (src.match(/var FACT_PLAN_STEP = \{([^}]*)\};/) || [, ''])[1];
313
+ const found = [...src.matchAll(/data-onboard-step="([a-z0-9-]+)"/g)].map((m) => m[1])
314
+ .concat([...facts.matchAll(/: '([a-z0-9-]+)'/g)].map((m) => m[1]));
309
315
  const uniq = [...new Set(found)].sort();
310
316
  assert.deepEqual(uniq, humanKeys, `${name} (${rel}) must tag exactly the canonical human steps [${humanKeys}], found [${uniq}]`);
311
317
  }
@@ -523,7 +523,9 @@ function bootGate({ me = { github_login: 'kestrel' }, created = { id: 7, slug: '
523
523
  test('the wizard opens the invite step only once the hall is up, and only for a signed-in owner of a project that exists (tasks 1002346, 1004503)', () => {
524
524
  assert.ok(gateSlice().includes('function paintInviteGate(live)'), 'the gate slice is found');
525
525
  assert.match(SCRIPT, /function renderDone\(\) \{[\s\S]*?paintInviteGate\(false\);/, 'the done panel paints the gate shut');
526
- assert.match(SCRIPT, /if \(inst\.last_verified_up\) \{[\s\S]{0,400}paintInviteGate\(true\);/, 'and opens it only on the verified Live');
526
+ // since task 1004683: "is alive" — and with it the invite — opens once every required fact
527
+ // of part 03 is done, which includes the hall answering (last_verified_up)
528
+ assert.match(SCRIPT, /var on = !!\(ready && inst && hostOk\(inst\.domain\) && addressReady\(inst\) && inst\.last_verified_up === true\);[\s\S]{0,900}paintInviteGate\(on\);/, 'and opens it only on the verified Live');
527
529
  assert.equal((SCRIPT.match(/setInviteSlug\('wizInvite'/g) || []).length, 2, 'the gate is the only caller: shut, or open on Live');
528
530
 
529
531
  const before = bootGate();
@@ -577,8 +579,9 @@ test('the wizard’s invite step ships as real markup in part 03, with the alive
577
579
  assert.match(panel, /data-invite-send="wizInvite"/, 'the sender');
578
580
  assert.match(panel, /data-invite-skip="wizInvite"/, 'and the skip is a CONTROL, not "leave it and move on"');
579
581
  assert.match(panel, /Optional/, 'the panel says so before it asks');
580
- assert.ok(panel.indexOf('id="humanSteps"') < panel.indexOf('id="done-h"') && panel.indexOf('id="done-h"') < panel.indexOf('id="wizInvite"'),
581
- 'the mockup\'s order: the checklist, then "is alive", then the invite (task 1004648)');
582
+ assert.ok(panel.indexOf('id="ghFacts"') > 0 && panel.indexOf('id="ghFacts"') < panel.indexOf('id="done-h"') && panel.indexOf('id="done-h"') < panel.indexOf('id="wizInvite"') &&
583
+ panel.indexOf('id="wizInvite"') < panel.indexOf('id="enterGenesis"'),
584
+ 'the mockup\'s order: the facts, then "is alive", then the invite, then the Enter Genesis door (tasks 1004648, 1004683)');
582
585
  assert.match(panel, /Invite your first builders\. They become founding builders\./, 'in the mockup\'s words');
583
586
  assert.match(panel, /<p class="samePath" id="wizInviteLater"><\/p>/, 'and the way back afterwards is a slot beside the modules one');
584
587
  assert.match(SCRIPT, /\$\('wizInviteLater'\)\.innerHTML = 'You can invite builders any time from ' \+ projectPageLink\('the project’s own page'\) \+ '\.';/,
@@ -299,10 +299,10 @@ test('the catalog is read ONCE however often the section repaints', async () =>
299
299
 
300
300
  test('the wizard’s done panel points at the project’s settings for its extras', () => {
301
301
  assert.match(HUB, /<p class="samePath" id="doneModules"><\/p>/, 'the panel carries the slot');
302
- assert.match(SCRIPT, /\$\('doneModules'\)\.innerHTML = 'Add or remove modules later in ' \+ projectPageLink\('the project’s settings'\) \+ '\.';/,
303
- 'and renderDone fills it with one sentence pointing at the manage page');
302
+ assert.match(SCRIPT, /var mods = \$\('doneModules'\);\n\s*mods\.textContent = '';\n\s*partsInto\(mods, \['Add or remove modules later in ', \{ t: 'the project’s settings', href: manageHref\(\) \}, '\.'\]\);/,
303
+ 'and renderDone fills it with one sentence pointing at the manage page, drawn from nodes (task 1004683)');
304
304
  assert.equal((SCRIPT.match(/doneModules/g) || []).length, 1, 'one line, written in one place');
305
- assert.match(SCRIPT, /function projectPageLink\(label\) \{\s*return '<a href="' \+ \(instanceId \? '\?view=manage&id=' \+ encodeURIComponent\(instanceId\)/,
305
+ assert.match(SCRIPT, /function manageHref\(\) \{\s*return instanceId \? '\?view=manage&id=' \+ encodeURIComponent\(instanceId\)/,
306
306
  'and the link names the instance just created, not the list');
307
307
  });
308
308
 
@@ -2190,7 +2190,7 @@ test('the wizard folds at one width: no rail fold is left, and no 900px rule is
2190
2190
  assert.match(STYLE, /@media \(max-width:560px\)\{\s*\.setRow\{grid-template-columns:minmax\(0,1fr\) auto;grid-template-areas:"k acts" "v v";/, 'part 02\'s rows stack under 560, Change beside the label');
2191
2191
  });
2192
2192
 
2193
- test('the wizard\'s own structures wear the world: hairline fields, cells of one band, the review as a ledger, the human steps as a numbered ledger, the standup strip as the rail (task 1003346)', () => {
2193
+ test('the wizard\'s own structures wear the world: hairline fields, cells of one band, part 02\'s rows, part 03\'s checked facts (tasks 1003346, 1004683)', () => {
2194
2194
  const at = STYLE.indexOf('the create wizard (task 1002763');
2195
2195
  assert.ok(at > -1, 'the wizard block is still the page\'s own');
2196
2196
  const block = STYLE.slice(at);
@@ -2204,13 +2204,12 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2204
2204
  assert.match(block, /\.setRows\{margin:14px 0 0;border-top:1px solid var\(--rule\);\}/, 'part 02\'s rows hang from one hairline');
2205
2205
  assert.match(block, /\.setRow\{display:grid;grid-template-columns:108px minmax\(0,1fr\) auto;[^}]*border-bottom:1px solid var\(--rule\);\}/, 'each row is label · value · Change on its own hairline');
2206
2206
  assert.doesNotMatch(block, /\.review( li)?\{/, 'the ledger\'s rules are gone with renderReview');
2207
- assert.match(block, /\.humanSteps>li\{\s*display:grid;grid-template-columns:44px 1fr;/, 'the human steps keep the ledger grid');
2208
- assert.match(block, /#panel-done \.humanSteps>li\{grid-template-columns:24px 1fr;/, 'which part 03 draws as the mockup\'s checklist: a dot, then the step');
2209
- assert.match(block, /\.humanSteps>li>\*\{grid-column:2;/, 'every child of a step sits in the text column, never under the counter (the sheet\'s bug)');
2210
- assert.match(block, /#panel-done \.humanSteps>li\.sdone::before\{content:"✓";/, 'a verified step wears the tick in the ok ink');
2211
- assert.match(block, /\.prog\{background:none;border:0;border-radius:0;padding:0;/, 'the standup strip is drawn as the rail');
2212
- assert.match(block, /\.prog \.stages li\.now \.sdot::after\{content:none;animation:none;\}/, 'no pulse on the rail');
2213
- assert.match(block, /\.doneHead \.tick\{display:none;\}/, 'the tick leaves; the rail says filed');
2207
+ // task 1004683 (docs/design/mocks/day-night-rooms/setup-status.html): part 03 is one list
2208
+ // of facts on hairlines — a dot, the fact, when it was checked — and no stage strip at all
2209
+ assert.match(block, /\.setFact\{display:grid;grid-template-columns:24px minmax\(0,1fr\) auto;[^}]*border-top:1px solid var\(--rule\);\}/, 'each fact is dot · fact · when, on its own hairline');
2210
+ assert.match(block, /\.setFact\.done \.d\{background:color-mix\(in srgb,var\(--ok\) 12%,transparent\);color:var\(--ok\);border-color:var\(--ok\);\}/, 'a verified fact wears the tick in the ok ink');
2211
+ assert.match(block, /\.setFact\.bad \.d\{[^}]*color:var\(--err\);/, 'a stopped fact wears the error ink');
2212
+ assert.doesNotMatch(block, /\.prog\b|\.humanSteps|\.doneHead|\.stepsHead|\.ghbtn/, 'the standup strip, the old checklist and their sheet are gone');
2214
2213
  assert.match(block, /\.addrBig code\{display:block;font-family:var\(--font-display\);/, 'the address stands in the display voice');
2215
2214
  assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
2216
2215
  // the one exception is drawn, not chrome: the night room's moon glows in the window
@@ -2221,7 +2220,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2221
2220
  assert.doesNotMatch(HUB, /data-back="|id="wizBack"/, 'no back door is left');
2222
2221
  assert.match(HUB, /<div class="wizTop">\s*<span class="wizEyebrow" id="wizEyebrow">New project<\/span>\s*<\/div>/, 'the eyebrow alone in the top row');
2223
2222
  assert.match(HUB, /<div class="createBar">\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>\s*<span class="wizRecap" id="wizRecap"><\/span>/, 'Create, with the one-line recap beside it');
2224
- assert.match(HUB, /<h2 id="done-part-h"><span class="n">03<\/span>Connect GitHub <span class="state" id="ghState">opens once your project exists<\/span><\/h2>/, 'part 03 names itself and where it is');
2223
+ assert.match(HUB, /<h2 id="done-part-h"><span class="n">03<\/span><span id="donePartT">Connect GitHub<\/span> <span class="state" id="ghState">opens once your project exists<\/span><\/h2>/, 'part 03 names itself and where it is');
2225
2224
  // what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
2226
2225
  assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
2227
2226
  assert.match(SCRIPT, /hosting_shape: 'cloud-host',/, 'the shape is unchanged');
@@ -1,21 +1,21 @@
1
1
  // tests/projects_hub_cloud_hop.mjs
2
2
  //
3
- // The create wizard's done panel in two moments (task 1004578, from idea 1001205):
4
- // WAIT — one status line, a thin bar and the cloud hop, a little game with one
5
- // cloud per PUBLIC project on the platform — then ARRIVE — the steps only the
6
- // founder can play, all on show as one checklist (part 03, task 1004648). The owner set the game's rules on a played
7
- // prototype, and the nausea ones are the ones a refactor would quietly lose: the
8
- // clouds stand still, the sky stands still, the view does not follow a jump.
3
+ // The cloud hop (task 1004578, from idea 1001205): a little game with one cloud per
4
+ // PUBLIC project on the platform, played while a project is being set up. Since task
5
+ // 1004683 (the design of record docs/design/mocks/day-night-rooms/setup-status.html) it
6
+ // waits behind a "Play cloud hop while you wait" link and opens in place only when
7
+ // asked: the wait never depends on it, and nothing on a timer stands in for a fact. The
8
+ // owner set the game's rules on a played prototype, and the nausea ones are the ones a
9
+ // refactor would quietly lose: the clouds stand still, the sky stands still, the view
10
+ // does not follow a jump.
9
11
  //
10
- // EXECUTED by the vm-slice idiom: momentFor/setMoment ride the same region the DNS
11
- // test runs (renderProgress); the physics (hopStep), the score line (hopScore),
12
- // the status sync (hopSync) and the checklist's state line (stepsPaint) are sliced function by
13
- // function and run under narrow stubs. Every slice is asserted to exist, so a
14
- // rename fails loudly instead of testing nothing. vm-born values are compared as
15
- // primitives or through JSON.
12
+ // EXECUTED by the vm-slice idiom: the link (hopOpen), the arrival (hopArrive), the
13
+ // physics (hopStep) and the score line (hopScore) are sliced function by function and
14
+ // run under narrow stubs. Every slice is asserted to exist, so a rename fails loudly
15
+ // instead of testing nothing. vm-born values are compared as primitives or through JSON.
16
+ // Part 03's facts themselves are tests/setup_status_facts.mjs.
16
17
  //
17
18
  // Run: node --test tests/projects_hub_cloud_hop.mjs
18
-
19
19
  import { test } from 'node:test';
20
20
  import { strict as assert } from 'node:assert';
21
21
  import fs from 'node:fs';
@@ -47,69 +47,71 @@ const HOP_CONSTS = (SCRIPT.match(/\n {4}var HOP_GAP = [^\n]*\n/) || [''])[0];
47
47
  assert.ok(HOP_CONSTS.includes('HOP_JUMP'), 'the hop constants line must be findable');
48
48
  const HOP_REGION = between('/* ══ THE CLOUD HOP (task 1004578)', '/* ── the hub\'s three doors ── */');
49
49
 
50
- // ── the moment ──────────────────────────────────────────────────────────────────
51
- const PROGRESS = between('var PROG_FLOWS = {', 'function startProgress(');
52
- const OVERLAY = between('function verifiedAt(inst)', 'function chipMarkup(');
53
50
 
54
- function bootMoment() {
55
- const attrs = {};
56
- const panel = { setAttribute: (k, v) => { attrs[k] = v; } };
51
+ // ── behind its link (task 1004683) ──────────────────────────────────────────────
52
+ // The wait never depends on the game: it opens in place only when asked, and puts
53
+ // itself away (after the curtain, if it was running) once every required fact is done.
54
+ function bootLink({ reduced = false, running = false, since = 0 } = {}) {
55
+ const box = { hidden: true, classList: { remove() {}, add() {} } };
56
+ const link = { textContent: 'Play cloud hop while you wait', attrs: {}, setAttribute(k, v) { this.attrs[k] = v; } };
57
+ const calls = { start: 0, stop: 0, curtain: 0, raf: 0, done: null };
57
58
  const sandbox = {
58
- $: (id) => (id === 'panel-done' ? panel : null),
59
- esc: (s) => String(s), projectPageLink: (l) => l, relTime: () => 'now', canRestart: () => false,
60
- FRESH_MS: 1, LIVE_MOMENT_MS: 1, state: {}, paintInviteGate() {}, lastProgHtml: '',
59
+ $: (id) => ({ hop: box, hopLink: link })[id] || null, REDUCED: reduced,
60
+ performance: { now: () => 10000 },
61
+ hopStart: () => { calls.start += 1; }, hopStop: () => { calls.stop += 1; },
62
+ hopCurtain: (done) => { calls.curtain += 1; calls.done = done; }, hopFrame() {},
63
+ requestAnimationFrame: () => { calls.raf += 1; return 1; }, cancelAnimationFrame() {},
61
64
  };
62
65
  vm.createContext(sandbox);
63
- vm.runInContext(PROGRESS + '\n' + OVERLAY, sandbox);
64
- return { sandbox, attrs };
66
+ vm.runInContext(`var hop = { target: '', raf: ${running ? 1 : 0}, since: ${since}, tr: 0 };\n${fn('hopOpen')}\n${fn('hopArrive')}`, sandbox);
67
+ return { sandbox, box, link, calls };
65
68
  }
66
- const NOW = () => new Date().toISOString();
67
- const live = { status: 'active', domain: 'mercury.cloudbongos.com', dns_resolves: true, last_verified_up: true, last_verified_at: NOW(), open_intent: null };
68
-
69
- test('the moment follows the same branches renderProgress speaks', () => {
70
- const { sandbox } = bootMoment();
71
- const m = (inst) => sandbox.momentFor(inst);
72
- assert.equal(m({ status: 'requested' }), 'wait', 'filed: the hop');
73
- assert.equal(m({ status: 'provisioning' }), 'wait', 'standing up: the hop');
74
- assert.equal(m({ ...live, dns_resolves: false, last_verified_up: null }), 'wait', 'address still being set up: keep playing');
75
- assert.equal(m({ ...live, last_verified_up: null }), 'wait', 'standing, not checked yet: keep playing');
76
- assert.equal(m({ ...live, last_verified_up: false }), 'wait', 'not answering right now: keep playing');
77
- assert.equal(m(live), 'arrive', 'answering on a resolved address: arrive');
78
- assert.equal(m({ ...live, open_intent: { action: 'standup', state: 'running' } }), 'arrive',
79
- 'an intent renderProgress does not speak for (a running standup) falls through to the status');
80
- assert.equal(m({ ...live, open_intent: { action: 'provision' } }), 'wait', 'a queued standup is still a wait');
81
- assert.equal(m({ status: 'torn_down', open_intent: { action: 'provision' } }), 'wait', 'a queued revive is a wait');
82
- });
83
69
 
84
- test('a snag, an ending, a settings change or an addressless project keeps the plain panel', () => {
85
- const { sandbox } = bootMoment();
86
- const m = (inst) => sandbox.momentFor(inst);
87
- assert.equal(m({ status: 'error' }), 'plain');
88
- assert.equal(m({ status: 'tearing_down' }), 'plain');
89
- assert.equal(m({ status: 'torn_down' }), 'plain');
90
- assert.equal(m({ ...live, open_intent: { action: 'teardown' } }), 'plain');
91
- assert.equal(m({ ...live, open_intent: { action: 'settings-apply', state: 'running' } }), 'plain');
92
- assert.equal(m({ ...live, domain: null }), 'plain', 'no address will never answer — nothing to arrive at');
70
+ test('the game opens in place only when asked, and its link says so', () => {
71
+ const h = bootLink();
72
+ h.sandbox.hopOpen(true);
73
+ assert.equal(h.box.hidden, false);
74
+ assert.equal(h.link.attrs['aria-expanded'], 'true');
75
+ assert.equal(h.sandbox.hop.target, 'wait', 'only an opened game may run (hopRunning reads it)');
76
+ assert.equal(h.calls.start, 1);
77
+ h.sandbox.hopOpen(false);
78
+ assert.equal(h.box.hidden, true);
79
+ assert.equal(h.link.attrs['aria-expanded'], 'false');
80
+ assert.equal(h.link.textContent, 'Play cloud hop while you wait');
81
+ assert.equal(h.sandbox.hop.target, '');
82
+ assert.equal(h.calls.stop, 1);
83
+ assert.match(HUB, /<button type="button" class="hopLink" id="hopLink" aria-expanded="false" aria-controls="hop">Play cloud hop while you wait<\/button>\s*<div class="hop" id="hop" hidden>/,
84
+ 'baked shut, behind the mockup\'s link');
85
+ assert.match(SCRIPT, /\$\('hopLink'\)\.addEventListener\('click', function \(\) \{ hopOpen\(\$\('hop'\)\.hidden\); \}\);/, 'the link is the only way in');
86
+ assert.match(fn('hopStart'), /if \(!hop \|\| !hopEl \|\| hopEl\.hidden\) return;/, 'a closed game never starts, not on a resize nor a tab coming back');
93
87
  });
94
88
 
95
- test('renderProgress sets the moment before it writes the strip, and only for a real read', () => {
96
- const { sandbox, attrs } = bootMoment();
97
- const nodes = { liveProgress: { innerHTML: '', querySelector: () => null }, 'done-h': { textContent: '' }, doneLede: { innerHTML: '' } };
98
- sandbox.$ = ((orig) => (id) => nodes[id] || orig(id))(sandbox.$);
99
- sandbox.renderProgress({ status: 'provisioning' });
100
- assert.equal(attrs['data-moment'], 'wait');
101
- sandbox.renderProgress(live);
102
- assert.equal(attrs['data-moment'], 'arrive');
103
- const body = fn('renderProgress');
104
- assert.ok(body.indexOf('setMoment(momentFor(inst))') < body.indexOf('setProgress('), 'the moment is decided first');
105
- assert.match(body, /if \(inst\) setMoment\(momentFor\(inst\)\);/, 'a null read never moves the panel');
89
+ test('every required fact done: a running game gets the curtain, then puts itself away; a closed one stays away', () => {
90
+ const run = bootLink({ running: true, since: 1000 });
91
+ run.box.hidden = false;
92
+ run.sandbox.hopArrive();
93
+ assert.equal(run.calls.curtain, 1, 'the curtain plays from a running game');
94
+ assert.equal(run.box.hidden, false, 'and the game stays until the curtain lands');
95
+ run.calls.done();
96
+ assert.equal(run.box.hidden, true, 'then it is put away');
97
+ const shut = bootLink();
98
+ shut.sandbox.hopArrive();
99
+ assert.equal(shut.calls.curtain, 0, 'a game nobody opened plays nothing');
100
+ assert.equal(shut.box.hidden, true);
101
+ const still = bootLink({ reduced: true, running: true, since: 1000 });
102
+ still.box.hidden = false;
103
+ still.sandbox.hopArrive();
104
+ assert.equal(still.calls.curtain, 0, 'reduced motion: no curtain');
105
+ assert.equal(still.box.hidden, true);
106
+ assert.match(fn('paintAlive'), /if \(on\) hopArrive\(\);/, 'the facts decide the arrival, never the game');
106
107
  });
107
108
 
108
- test('the panel leaves the hop on a stopped watch and starts on it at renderDone', () => {
109
- const sp = between('function startProgress(', 'function resetForNewProject(');
110
- assert.equal((sp.match(/setMoment\('plain'\);/g) || []).length, 2, 'the ~33-minute hand-off and a gone instance both drop to plain');
111
- assert.match(fn('renderDone'), /setMoment\('wait'\);/, 'a fresh done panel opens on the hop');
109
+ test('nothing on a timer stands in for a fact: no tick counter, no moment, no bar', () => {
110
+ assert.doesNotMatch(SCRIPT, /ticks > 400|ticks \+= 1/, 'the ~33-minute tick counter is gone');
111
+ assert.doesNotMatch(SCRIPT, /setMoment|momentFor|data-moment|hopMoment|hopSync/, 'no moment switch');
112
+ assert.doesNotMatch(HUB, /hopBar|id="hopStatus"/, 'no bar and no second status line');
112
113
  assert.match(fn('resetForNewProject'), /hopReset\(\);/, 'a second project starts with a fresh hop');
114
+ assert.match(fn('renderDone'), /hopOpen\(false\);/, 'a fresh part 03 opens with the game put away');
113
115
  });
114
116
 
115
117
  // ── the physics — the owner's rules ─────────────────────────────────────────────
@@ -199,7 +201,7 @@ test('the loop never runs under reduced motion; the rest frame is a full still s
199
201
  'the start, the frame (playing and curtain) and the curtain\'s kick-off — no other loop');
200
202
  assert.match(fn('hopFrame'), /if \(!hopRunning\(\)\) \{ hopDraw\(now\); return; \}/,
201
203
  'every frame re-asks the gate, so turning reduced motion on stops it');
202
- assert.match(fn('hopMoment'), /!REDUCED && hop\.raf/, 'the curtain plays only from a running game');
204
+ assert.match(fn('hopArrive'), /!REDUCED && hop\.raf/, 'the curtain plays only from a running game');
203
205
  assert.match(STYLE, /\.hop\.isStill \.hopRow\{display:none;\}/, 'reduced motion has nothing to steer');
204
206
  });
205
207
 
@@ -213,67 +215,17 @@ test('the game reads its colours off the page tokens — no literal but white or
213
215
  assert.match(fn('hopColors'), /hopRead\('var\(--' \+ nm \+ '\)'\)/, 'every colour resolves from a token');
214
216
  });
215
217
 
216
- // ── the status line and the arrival pager ───────────────────────────────────────
217
- function el(cls = []) {
218
- const set = new Set(cls);
219
- const attrs = {};
220
- return {
221
- classList: { contains: (c) => set.has(c), toggle: (c, on) => (on ? set.add(c) : set.delete(c)), add: (c) => set.add(c), remove: (c) => set.delete(c) },
222
- setAttribute: (k, v) => { attrs[k] = v; }, getAttribute: (k) => attrs[k], attrs, hidden: false, textContent: '', style: {},
223
- };
224
- }
225
-
226
- test('the status line and the bar read what the strip says', () => {
227
- const st = { textContent: '' }, fill = { style: {} };
228
- const sandbox = { $: (id) => ({ hopStatus: st, hopBarFill: fill })[id] || null, Math };
229
- vm.createContext(sandbox);
230
- vm.runInContext(fn('hopSync'), sandbox);
231
- const strip = {
232
- querySelector: (q) => (q === '.pnote' ? { textContent: 'Raising the server, database, and address — nothing needed from you.' } : q === '.stages li.now' ? {} : null),
233
- querySelectorAll: (q) => ({ length: q === '.stages li' ? 3 : 1 }),
234
- };
235
- sandbox.hopSync(strip);
236
- assert.equal(st.textContent, 'Raising the server, database, and address — nothing needed from you.');
237
- assert.equal(fill.style.width, '50%', 'one done and one under way of three');
238
- assert.match(fn('setProgress'), /hopSync\(el\)/, 'every strip write syncs the line');
239
- });
240
-
241
- // ── part 03's checklist (task 1004648): every step on show, the state line from it ──
242
- function bootSteps(items, { step = 2, inFlight = false } = {}) {
243
- const list = { children: items };
244
- const ghState = { textContent: '' };
245
- const sandbox = { $: (id) => ({ humanSteps: list, ghState })[id] || null, Array, state: { step }, manifestInFlight: () => inFlight };
246
- vm.createContext(sandbox);
247
- vm.runInContext(fn('stepsPaint'), sandbox);
248
- sandbox.stepsPaint();
249
- return ghState.textContent;
250
- }
251
- function li(key, cls = []) { const e = el(cls); e.key = key; return e; }
252
-
253
- test('part 03\'s state line reads the checklist: ready, how many done, waiting on GitHub, connected', () => {
254
- assert.equal(bootSteps([li('github-repo'), li('oauth-app')], { step: 1 }), 'opens once your project exists', 'before Create');
255
- assert.equal(bootSteps([li('github-repo'), li('oauth-app')]), 'ready');
256
- assert.equal(bootSteps([li('github-repo', ['sdone']), li('oauth-app'), li('enter-hall', ['slocked'])]), '1 of 3 done');
257
- assert.equal(bootSteps([li('github-repo', ['sdone']), li('oauth-app')], { inFlight: true }), 'waiting on GitHub…', 'a GitHub leg in flight says so');
258
- assert.equal(bootSteps([li('github-repo', ['sdone']), li('oauth-app', ['sdone'])]), 'connected ✓');
259
- assert.doesNotMatch(fn('stepsPaint'), /innerHTML|\.cur|data-n/, 'no paging, and no markup from strings');
260
- });
261
-
262
- // ── the markup and the sheet ────────────────────────────────────────────────────
263
- test('the wait stands the strip and the later lines down, the checklist stays on show, and nothing is paged', () => {
264
- for (const id of ['hop', 'hopStatus', 'hopBarFill', 'hopCanvas', 'hopLeft', 'hopJump', 'hopRight', 'hopScore', 'humanSteps', 'ghState']) {
218
+ // ── the markup ──────────────────────────────────────────────────────────────────
219
+ test('the hop is baked into part 03 below the facts and above "is alive"; the old wait is gone', () => {
220
+ for (const id of ['hop', 'hopLink', 'hopCanvas', 'hopLeft', 'hopJump', 'hopRight', 'hopScore']) {
265
221
  assert.ok(HUB.includes(`id="${id}"`), `#${id} is baked into part 03`);
266
222
  }
267
- for (const id of ['hopWhile', 'hopSignin', 'stepPager', 'stepCount', 'stepBack', 'stepNext']) {
268
- assert.ok(!HUB.includes(`id="${id}"`), `#${id} is gone: the checklist is on show the whole time (task 1004648)`);
223
+ for (const id of ['hopWhile', 'hopSignin', 'stepPager', 'stepCount', 'stepBack', 'stepNext', 'hopStatus', 'hopBarFill', 'humanSteps', 'liveProgress']) {
224
+ assert.ok(!HUB.includes(`id="${id}"`), `#${id} is gone (tasks 1004648, 1004683)`);
269
225
  }
270
226
  const panel = HUB.slice(HUB.indexOf('id="panel-done"'), HUB.indexOf('</section>', HUB.indexOf('id="panel-done"')));
271
- assert.ok(panel.indexOf('id="humanSteps"') < panel.indexOf('id="hop"') && panel.indexOf('id="hop"') < panel.indexOf('id="wizInvite"'),
272
- 'the checklist first, then the hop, then the invite');
273
- assert.match(panel, /id="hopStatus" role="status" aria-live="polite"/, 'the line is the live region while the strip is hidden');
274
- assert.match(STYLE, /#panel-done\[data-moment="wait"\] #liveProgress,/);
275
- assert.doesNotMatch(STYLE, /#panel-done\[data-moment="wait"\][^{]*#humanSteps\{display:none;\}/, 'the wait no longer hides the checklist');
276
- assert.doesNotMatch(STYLE, /humanSteps>li:not\(\.cur\)/, 'and the arrival does not page it');
277
- assert.match(STYLE, /#panel-done\[data-moment="arrive"\] #liveProgress \.stages\{display:none;\}/, 'the arrival keeps the note with the address, not the dots');
278
- assert.match(STYLE, /#panel-done \.humanSteps>li\.sdone::before\{content:"✓";/, 'a verified step wears the tick');
227
+ assert.ok(panel.indexOf('id="ghFacts"') < panel.indexOf('id="hopLink"') && panel.indexOf('id="hop"') < panel.indexOf('id="doneAlive"'),
228
+ 'the facts first, then the game behind its link, then "is alive"');
229
+ assert.doesNotMatch(panel, /id="hopScore"[^>]*aria-live/, 'the score is not a live region: part 03 speaks through one');
230
+ assert.match(STYLE, /\.hop\[hidden\]\{display:none;\}/, 'the game stays out of the layout until asked');
279
231
  });