@bongos/core 1.21.71 → 1.21.72

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.
@@ -167,9 +167,9 @@
167
167
  { "name": "the review (tasks 1004504, 1004550): Edit opens a row in place, Done confirms it, and Continue is the one confirmation for the rest", "auth": true, "width": 1440, "url": "/projects?view=new",
168
168
  "steps": [["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"team\"] [data-row-chg]"],["wait",200],["click","[data-phys-solo=\"many\"]"],["wait",600],["click","#reviewRows .setRow[data-row=\"team\"] [data-row-chg]"],["wait",200]],
169
169
  "expect": {"visible":["#panel-review","#reviewRows .setRow[data-row=\"rewards\"]","#reviewRows .setRow.done[data-row=\"team\"]"]} },
170
- { "name": "the wizard driver (tasks 1003346 and 1004504): the fork, what, the checks, every review row confirmed (the stub grants repo access, so the new repository is named from the project; the dotless stub host takes a domain the founder owns), the look, then the filing — the done panel with the standup strip and the human steps", "auth": true, "width": 1440, "url": "/projects?view=new",
170
+ { "name": "the wizard driver (tasks 1003346 and 1004504): the fork, what, the checks, every review row confirmed (the stub grants repo access, so the new repository is named from the project; the dotless stub host takes a domain the founder owns), the look, then the filing — the done panel waits with the cloud hop and its status line (task 1004578), the strip and the locked checklist standing down", "auth": true, "width": 1440, "url": "/projects?view=new",
171
171
  "steps": [["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500]],
172
- "expect": {"visible":["#panel-done","#liveProgress .prog","#liveProgress .prog .stages li.now","#humanSteps li"],"hidden":["#panel-look","#panel-review"],"text":[["#done-h","Setting up Mercury"]]} },
172
+ "expect": {"visible":["#panel-done","#hop","#hopCanvas","#hopStatus","#hopJump"],"hidden":["#panel-look","#panel-review","#liveProgress","#humanSteps"],"text":[["#done-h","Setting up Mercury"],["#hopStatus","Raising the server, database, and address — nothing needed from you."]]} },
173
173
  { "name": "the front door at 390: the fork's two doors stack inside one pill", "auth": true, "width": 390, "url": "/projects?view=new",
174
174
  "steps": [["wait",600],["click","#termsAck"],["wait",100]],
175
175
  "expect": {"visible":["#panel-fork",".doorPill","#wizDemo","#wizStart"]} },
@@ -57,7 +57,7 @@
57
57
  "in-new-review-run": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"run\"] [data-row-chg]"],["wait",300],["click","[data-phys=\"framing\"][data-val=\"hybrid\"]"],["wait",600]],"expect":{"visible":["#physSuite"]}},
58
58
  "in-new-review-confirmed": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200]],"expect":{"visible":["#reviewGo","#reviewRows .setRow.done"],"text":[["#reviewGo","Looks right, continue"]]}},
59
59
  "in-new-look": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500]],"expect":{"visible":["#panel-look","#wizLook","#wizRecap","#doCreate"],"hidden":["#panel-review"],"attr":[["#wizRoom","data-screen","look"]]}},
60
- "in-new-done": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500]],"expect":{"visible":["#panel-done","#liveProgress .prog","#humanSteps li","#wizInviteLater"],"hidden":["#panel-look","#qWrap","#wizBack","#wizInvite"]}},
60
+ "in-new-done": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500]],"expect":{"visible":["#panel-done","#hop","#hopCanvas","#hopStatus","#hopJump"],"hidden":["#panel-look","#qWrap","#wizBack","#wizInvite","#liveProgress","#humanSteps","#wizInviteLater","#stepPager"]}},
61
61
  "in-new-resume-offer": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#doCreate"],["wait",1500],["click","#projTrig"],["wait",350],["click","#menuProjects [data-nav=\"new\"]"],["wait",500]],"expect":{"visible":["#panel-fork","#wizResume","#wizResumeGo"],"hidden":["#panel-done"],"text":[["#wizResumeName","Mercury"]]}},
62
62
  "in-new-look-custom": {"auth":true,"url":"/projects?view=new","actions":[["wait",600],["click","#termsAck"],["wait",100],["click","#wizStart"],["wait",400],["fill","#newName","Mercury"],["click","#typeCards .tpill[data-type=\"game\"]"],["fill","#f-desc","A co-op farming game where the seasons never repeat."],["click","#panel-what button[type=submit]"],["wait",400],["select","#physCountry","US"],["wait",100],["click","[data-phys=\"builder_geography\"][data-val=\"global\"]"],["click","[data-phys=\"minors_on_board\"][data-val=\"false\"]"],["click","[data-phys=\"entity\"][data-val=\"intent-solo\"]"],["click","#physNext"],["wait",700],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["fill","#f-slug","mercury"],["wait",900],["fill","#f-domain","mercury.example.com"],["wait",200],["click","#reviewRows .setRow[data-row=\"address\"] [data-row-chg]"],["wait",200],["click","#reviewGo"],["wait",500],["click","#wizLook-custom"],["wait",200]],"expect":{"visible":["#panel-look","#wizAccent","#wizAccentNote"],"hidden":["#panel-review"]}},
63
63
  "out-new": {"auth":false,"url":"/projects?view=new","expect":{"visible":["#panel-gate","#wizSignin"],"hidden":["#qWrap","#panel-fork"]}},
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.71",
3
+ "version": "1.21.72",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.71",
9
+ "version": "1.21.72",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.71",
3
+ "version": "1.21.72",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -9236,5 +9236,11 @@
9236
9236
  {
9237
9237
  "text": "Hosted projects like Will's now get a working Update button in Settings, instead of being told to run a command on a server they don't have access to."
9238
9238
  }
9239
+ ],
9240
+ "1.21.72": [
9241
+ {
9242
+ "id": "1004578",
9243
+ "text": "While a new project is being set up, founders now get a little cloud-hopping game: each cloud is a public project on the platform, in its own planet colour. When the project goes live, clouds and stars fill the screen and fade"
9244
+ }
9239
9245
  ]
9240
9246
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.71'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.72'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,303 @@
1
+ // tests/projects_hub_cloud_hop.mjs
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, one at a time. 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.
9
+ //
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 pager (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.
16
+ //
17
+ // Run: node --test tests/projects_hub_cloud_hop.mjs
18
+
19
+ import { test } from 'node:test';
20
+ import { strict as assert } from 'node:assert';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import vm from 'node:vm';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const LANDING = path.join(ROOT, 'modules', 'public-landing', 'public');
28
+ const HUB = fs.readFileSync(path.join(LANDING, 'projects.html'), 'utf8').replace(/\r\n/g, '\n');
29
+ const SCRIPT = HUB.slice(HUB.lastIndexOf('<script>'), HUB.lastIndexOf('</script>'));
30
+ const STYLE = HUB.slice(HUB.indexOf('<style>'), HUB.indexOf('</style>'));
31
+ const J = (v) => JSON.parse(JSON.stringify(v));
32
+
33
+ function between(from, to) {
34
+ const a = SCRIPT.indexOf(from);
35
+ const b = SCRIPT.indexOf(to, a + 1);
36
+ assert.ok(a > 0, `slice start must exist: ${from}`);
37
+ assert.ok(b > a, `slice end must follow it: ${to}`);
38
+ return SCRIPT.slice(a, b);
39
+ }
40
+ // one wizard-scope function, closed on the first `}` at the wizard's four-space indent
41
+ function fn(name) {
42
+ const m = SCRIPT.match(new RegExp(`\\n {4}function ${name}\\([^)]*\\) \\{[\\s\\S]*?\\n {4}\\}`));
43
+ assert.ok(m, `function ${name}() must be findable in projects.html`);
44
+ return m[0];
45
+ }
46
+ const HOP_CONSTS = (SCRIPT.match(/\n {4}var HOP_GAP = [^\n]*\n/) || [''])[0];
47
+ assert.ok(HOP_CONSTS.includes('HOP_JUMP'), 'the hop constants line must be findable');
48
+ const HOP_REGION = between('/* ══ THE CLOUD HOP (task 1004578)', '/* ── the hub\'s three doors ── */');
49
+
50
+ // ── the moment ──────────────────────────────────────────────────────────────────
51
+ const PROGRESS = between('var PROG_FLOWS = {', 'function startProgress(');
52
+ const OVERLAY = between('function verifiedAt(inst)', 'function chipMarkup(');
53
+
54
+ function bootMoment() {
55
+ const attrs = {};
56
+ const panel = { setAttribute: (k, v) => { attrs[k] = v; } };
57
+ 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: '',
61
+ };
62
+ vm.createContext(sandbox);
63
+ vm.runInContext(PROGRESS + '\n' + OVERLAY, sandbox);
64
+ return { sandbox, attrs };
65
+ }
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
+
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');
93
+ });
94
+
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');
106
+ });
107
+
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');
112
+ assert.match(fn('resetForNewProject'), /hopReset\(\);/, 'a second project starts with a fresh hop');
113
+ });
114
+
115
+ // ── the physics — the owner's rules ─────────────────────────────────────────────
116
+ function bootHop({ clouds = 3, reduced = false } = {}) {
117
+ const score = { textContent: '' };
118
+ const sandbox = { $: (id) => (id === 'hopScore' ? score : null), REDUCED: reduced, Math };
119
+ vm.createContext(sandbox);
120
+ vm.runInContext(HOP_CONSTS + 'var hop = { w: 600, feed: [], clouds: [], best: 0, maxed: false, p: null, cam: 0, camT: 0, L: false, R: false };\n' +
121
+ fn('hopCloudX') + fn('hopCloudW') + fn('hopScore') + fn('hopStep'), sandbox);
122
+ const hop = sandbox.hop;
123
+ hop.clouds.push({ i: 0, base: true, y: 0 });
124
+ // one fixed column of clouds straight up the middle, so a plain jump lands
125
+ for (let i = 1; i <= clouds; i++) hop.clouds.push({ i, f: 0.5, cw: 100, y: -i * sandbox.HOP_GAP, name: 'P' + i, acc: 'sea' });
126
+ hop.p = { x: 300, y: -14, vy: 0, on: hop.clouds[0], face: 1 };
127
+ hop.cam = hop.camT = -340 * 0.8;
128
+ return { sandbox, hop, score };
129
+ }
130
+ function run(sb, frames) { for (let i = 0; i < frames; i++) sb.hopStep(1); }
131
+
132
+ test('a jump lands on the cloud above and the best height counts it', () => {
133
+ const { sandbox, hop, score } = bootHop();
134
+ const before = J(hop.clouds.map((c) => [c.f, c.y]));
135
+ hop.p.vy = -sandbox.HOP_JUMP; hop.p.on = null;
136
+ run(sandbox, 80);
137
+ assert.equal(hop.p.on && hop.p.on.i, 1, 'landed on the first project cloud');
138
+ assert.equal(hop.best, 1);
139
+ assert.equal(score.textContent, 'Best: 1 of 3 projects');
140
+ assert.deepEqual(J(hop.clouds.map((c) => [c.f, c.y])), before, 'the clouds never move — moving clouds read as nausea');
141
+ });
142
+
143
+ test('the view holds still during a jump and glides only after the landing', () => {
144
+ const { sandbox, hop } = bootHop();
145
+ const cam0 = hop.cam;
146
+ hop.p.vy = -sandbox.HOP_JUMP; hop.p.on = null;
147
+ let airborneMoved = false;
148
+ for (let i = 0; i < 80 && !hop.p.on; i++) { sandbox.hopStep(1); if (!hop.p.on && Math.abs(hop.cam - cam0) > 0.01) airborneMoved = true; }
149
+ assert.equal(airborneMoved, false, 'no camera motion while the player is in the air');
150
+ assert.ok(hop.p.on, 'and it did land');
151
+ run(sandbox, 60);
152
+ assert.ok(hop.cam < cam0 - 10, 'once landed higher, the view glides up');
153
+ });
154
+
155
+ test('a miss falls, and the full-width base cloud always catches', () => {
156
+ const { sandbox, hop } = bootHop();
157
+ hop.p.on = hop.clouds[2]; hop.p.y = hop.clouds[2].y - 14; hop.best = 2;
158
+ hop.p.x = 600 - 10; // walk off the right edge of a centred cloud
159
+ run(sandbox, 200);
160
+ assert.equal(hop.p.on && hop.p.on.base, true, 'it fell past the lower clouds and landed on Your planet');
161
+ assert.equal(hop.best, 2, 'falling never takes the best height away');
162
+ });
163
+
164
+ test('steering moves the player, held to the stage', () => {
165
+ const { sandbox, hop } = bootHop();
166
+ hop.R = true; run(sandbox, 500);
167
+ assert.equal(hop.p.x, 590, 'right stops 10px from the edge');
168
+ hop.R = false; hop.L = true; run(sandbox, 500);
169
+ assert.equal(hop.p.x, 10, 'and left at 10px');
170
+ });
171
+
172
+ test('past the last cloud: max projects reached, build more to jump more', () => {
173
+ const { sandbox, hop, score } = bootHop({ clouds: 1 });
174
+ hop.p.vy = -sandbox.HOP_JUMP; hop.p.on = null;
175
+ run(sandbox, 80);
176
+ assert.equal(hop.maxed, true);
177
+ assert.equal(score.textContent, 'Max projects reached — build more to jump more');
178
+ const jump = fn('hopJump');
179
+ assert.match(jump, /!hop\.maxed/, 'and the jumping stops');
180
+ assert.match(HOP_REGION, /'Max projects reached'/);
181
+ assert.match(HOP_REGION, /'Build more to jump more'/);
182
+ });
183
+
184
+ test('the clouds are the public planets only, in the map\'s colours', () => {
185
+ const feed = fn('hopLoadFeed');
186
+ assert.match(feed, /p\.orb === 'planet'/, 'never a private star or a stealth black hole');
187
+ assert.match(feed, /localeCompare\(String\(b\.origin\)\)/, 'the accent walk runs over the origin-sorted catalog, like assignAccents');
188
+ assert.match(feed, /ORB_ACCENT_NAMES\.indexOf\(p\.planet_accent\) !== -1 \? p\.planet_accent/, 'the owner\'s pick wins');
189
+ assert.match(fn('hopBuild'), /hop\.clouds\.length === 1/, 'no public project at all reads as max reached');
190
+ });
191
+
192
+ // ── motion and the world ────────────────────────────────────────────────────────
193
+ test('the loop never runs under reduced motion; the rest frame is a full still sky', () => {
194
+ assert.match(fn('hopRunning'), /return !REDUCED && !document\.hidden && hop\.target === 'wait'/, 'the gate, like the map\'s skyActive (C9)');
195
+ const start = fn('hopStart');
196
+ assert.ok(start.indexOf('hopRunning()') < start.indexOf('requestAnimationFrame'), 'nothing is scheduled before the gate');
197
+ assert.match(start, /hopDraw\(performance\.now\(\)\)/, 'a stopped hop draws its still frame');
198
+ assert.equal((HOP_REGION.match(/requestAnimationFrame\(/g) || []).length, 4,
199
+ 'the start, the frame (playing and curtain) and the curtain\'s kick-off — no other loop');
200
+ assert.match(fn('hopFrame'), /if \(!hopRunning\(\)\) \{ hopDraw\(now\); return; \}/,
201
+ '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');
203
+ assert.match(STYLE, /\.hop\.isStill \.hopRow\{display:none;\}/, 'reduced motion has nothing to steer');
204
+ });
205
+
206
+ test('the game reads its colours off the page tokens — no literal but white or black with alpha', () => {
207
+ const hexes = HOP_REGION.match(/#[0-9a-fA-F]{3,8}\b/g) || [];
208
+ assert.deepEqual(hexes, [], 'no hex in the hop');
209
+ // hopCss() is the builder ('rgba(' + a token's channels), not a colour
210
+ const literals = (HOP_REGION.match(/'rgba?\([^']*'/g) || []).filter((m) => m !== "'rgba('");
211
+ for (const m of literals) assert.match(m, /^'rgba\(0,0,0,/, `only a black scrim may be literal: ${m}`);
212
+ assert.match(HOP_REGION, /var HOP_WHITE = \[255, 255, 255\];/, 'white is the one other literal');
213
+ assert.match(fn('hopColors'), /hopRead\('var\(--' \+ nm \+ '\)'\)/, 'every colour resolves from a token');
214
+ });
215
+
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
+ function bootSteps(items, signin) {
242
+ const list = { children: items, querySelector: (q) => items.find((li) => q.includes(li.key) || (q === 'li.cur' && li.classList.contains('cur'))) || null };
243
+ const panel = el(signin ? ['hopSignin'] : []);
244
+ const nodes = { 'panel-done': panel, humanSteps: list, hopWhile: el(), stepCount: el(), stepBack: el(), stepNext: el() };
245
+ const sandbox = { $: (id) => nodes[id] || null, Math, Array };
246
+ vm.createContext(sandbox);
247
+ vm.runInContext('var stepIdx = -1, stepAuto = true;' + fn('stepsPaint') + fn('stepGo'), sandbox);
248
+ return { sandbox, nodes };
249
+ }
250
+ function li(key, cls = []) { const e = el(cls); e.key = key; e.focus = () => {}; return e; }
251
+
252
+ test('the arrival shows one step at a time, opening on the first not yet done', () => {
253
+ const items = [li('github-repo', ['sdone']), li('oauth-app'), li('device-flow', ['slocked']), li('enter-hall', ['slocked'])];
254
+ const { sandbox, nodes } = bootSteps(items);
255
+ sandbox.stepsPaint(true);
256
+ assert.deepEqual(items.map((i) => i.classList.contains('cur')), [false, true, false, false]);
257
+ assert.equal(nodes.stepCount.textContent, 'Step 2 of 4');
258
+ assert.equal(items[1].attrs['data-n'], '02', 'the step on show carries its own number — a hidden step never counts');
259
+ assert.equal(nodes.stepBack.hidden, false);
260
+ assert.equal(nodes.stepNext.hidden, false);
261
+ // the checklist ticks itself: the step on show finishing moves the founder on
262
+ items[1].classList.add('sdone');
263
+ sandbox.stepsPaint();
264
+ assert.equal(nodes.stepCount.textContent, 'Step 3 of 4');
265
+ // once they page for themselves, their choice holds
266
+ sandbox.stepGo(-1);
267
+ items[2].classList.add('sdone');
268
+ sandbox.stepsPaint();
269
+ assert.equal(nodes.stepCount.textContent, 'Step 2 of 4');
270
+ sandbox.stepGo(1); sandbox.stepGo(1);
271
+ assert.equal(nodes.stepNext.hidden, true, 'no Continue past the last step');
272
+ });
273
+
274
+ test('while waiting, the sign-in step is offered only when it can be done', () => {
275
+ const open = bootSteps([li('github-repo'), li('oauth-app')]);
276
+ open.sandbox.stepsPaint();
277
+ assert.equal(open.nodes.hopWhile.hidden, false, 'a domain and a public platform: offer it');
278
+ const locked = bootSteps([li('github-repo'), li('oauth-app', ['slocked'])]);
279
+ locked.sandbox.stepsPaint();
280
+ assert.equal(locked.nodes.hopWhile.hidden, true, 'locked: nothing to offer');
281
+ const done = bootSteps([li('github-repo'), li('oauth-app', ['sdone'])]);
282
+ done.sandbox.stepsPaint();
283
+ assert.equal(done.nodes.hopWhile.hidden, true, 'already wired: nothing to offer');
284
+ const opened = bootSteps([li('github-repo'), li('oauth-app', ['sdone'])], true);
285
+ opened.sandbox.stepsPaint();
286
+ assert.equal(opened.nodes.hopWhile.hidden, false, 'but an open one keeps its door so it can be closed');
287
+ });
288
+
289
+ // ── the markup and the sheet ────────────────────────────────────────────────────
290
+ test('the wait stands the strip, the locked checklist and the later lines down; the arrival pages', () => {
291
+ for (const id of ['hop', 'hopStatus', 'hopBarFill', 'hopCanvas', 'hopLeft', 'hopJump', 'hopRight', 'hopScore', 'hopWhile', 'hopSignin', 'stepPager', 'stepCount', 'stepBack', 'stepNext']) {
292
+ assert.ok(HUB.includes(`id="${id}"`), `#${id} is baked into the panel`);
293
+ }
294
+ const panel = HUB.slice(HUB.indexOf('id="panel-done"'), HUB.indexOf('</section>', HUB.indexOf('id="panel-done"')));
295
+ assert.ok(panel.indexOf('id="liveProgress"') < panel.indexOf('id="hop"') && panel.indexOf('id="hop"') < panel.indexOf('id="wizInvite"'),
296
+ 'the hop sits where the strip was, above the invite');
297
+ assert.match(panel, /id="hopStatus" role="status" aria-live="polite"/, 'the line is the live region while the strip is hidden');
298
+ assert.match(STYLE, /#panel-done\[data-moment="wait"\] #liveProgress,/);
299
+ assert.match(STYLE, /#panel-done\[data-moment="wait"\]:not\(\.hopSignin\) #humanSteps\{display:none;\}/);
300
+ assert.match(STYLE, /#panel-done\[data-moment="arrive"\] \.humanSteps>li:not\(\.cur\)\{display:none;\}/);
301
+ assert.match(STYLE, /#panel-done\[data-moment="arrive"\] #liveProgress \.stages\{display:none;\}/, 'the arrival keeps the note with the address, not the dots');
302
+ assert.match(STYLE, /@media \(prefers-reduced-motion:no-preference\)\{\n {2}#panel-done\[data-moment="arrive"\] \.humanSteps>li\.cur,/, 'the arrival\'s fade-in is motion-gated');
303
+ });