@bongos/core 1.24.91 → 1.24.93

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 (36) hide show
  1. package/.bongos-core.json +54 -39
  2. package/.claude/skills/builder-reauth/SKILL.md +23 -25
  3. package/clients/bongos-client/index.d.ts +2 -1
  4. package/docs/adr/0396-google-sign-in-a-stand-in-login-and-a-browser-approved-cli-sign-in.md +8 -0
  5. package/docs/api/openapi.json +22 -2
  6. package/docs/api-reference.md +1 -1
  7. package/docs/copy-inventory.md +3 -3
  8. package/docs/copy-registry.json +3 -3
  9. package/docs/module-api-changelog.md +4 -2
  10. package/docs/page-inventory.json +4 -2
  11. package/docs/page-readings.json +6 -6
  12. package/modules/hall-ui/public/city.css +6 -2
  13. package/modules/hall-ui/public/city.js +44 -2
  14. package/modules/hall-ui/public/goal-map.html +2 -0
  15. package/modules/hall-ui/public/sky-interaction.js +4 -9
  16. package/modules/hall-ui/public/sky-visor.js +96 -0
  17. package/modules/hall-ui/public/thinking.css +8 -0
  18. package/modules/hall-ui/public/thinking.html +2 -0
  19. package/modules/hall-ui/public/visor.js +20 -4
  20. package/modules/hall-ui/records/visor.md +16 -2
  21. package/package-lock.json +2 -2
  22. package/package.json +1 -1
  23. package/release-notes.json +15 -1
  24. package/scripts/bongos/cli-lib.js +7 -6
  25. package/scripts/bongos/login.js +14 -3
  26. package/scripts/bongos/plain-cards.js +1 -1
  27. package/scripts/bongos/reauth.js +100 -50
  28. package/src/bongos/routes/auth-google.js +5 -3
  29. package/src/bongos/routes/auth.js +3 -3
  30. package/src/bongos/sign-in-pages.js +1 -2
  31. package/src/module-api.js +1 -1
  32. package/tests/cli_reauth_browser.mjs +157 -0
  33. package/tests/hall_city.mjs +41 -1
  34. package/tests/hall_city_living.mjs +2 -0
  35. package/tests/hall_sky_visor.mjs +150 -0
  36. package/tests/hall_visor.mjs +28 -2
@@ -0,0 +1,150 @@
1
+ // tests/hall_sky_visor.mjs — the sky as the visor's scene in game mode (task 1005210; ADR 0403).
2
+ //
3
+ // In game mode the visor is the control and the sky is the view: the sky's top row, its jump
4
+ // field, its thought composer and its foot's clock and legend leave the scene and come back as
5
+ // visor actions (sky-visor.js, thinking.css). Driven in a real browser over the page's OWN
6
+ // markup (goal-map.html's and thinking.html's <main>, word for word) with the real visor and
7
+ // the hall's real sheets. The pills are wired here the way sky-interaction.js wires them (a
8
+ // press flips aria-pressed), because what is under test is the visor's side: that each pill is
9
+ // a row, that a row presses its pill exactly once, and that the rows follow the pills.
10
+ import assert from 'node:assert/strict';
11
+ import { test, after } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
20
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
21
+
22
+ test('source: both sky pages load sky-visor.js after every other sky file', () => {
23
+ for (const p of ['goal-map.html', 'thinking.html']) {
24
+ const html = read(p);
25
+ const at = html.indexOf('/builders/sky-visor.js');
26
+ assert.ok(at > 0, `${p} loads it`);
27
+ for (const f of ['sky-interaction.js', 'sky-panel.js', 'sky-composer.js', 'sky-tour.js']) assert.ok(html.indexOf('/builders/' + f) < at, `${p}: after ${f}`);
28
+ }
29
+ assert.doesNotMatch(read('sky-interaction.js'), /window\.__visorScene = /, 'one registration, in sky-visor.js');
30
+ });
31
+
32
+ test('source: the sky keeps its controls in classic mode; in game mode they open from the visor', () => {
33
+ const css = read('thinking.css');
34
+ for (const rule of [
35
+ 'html.visor-on .top .tools, html.visor-on .top .lens { display: none; }',
36
+ 'html.visor-on .composer:not(.is-open), html.visor-on .jump:not(.is-open) { display: none; }',
37
+ 'html.visor-on .time:not(.is-open), html.visor-on .legend:not(.is-open) { display: none; }',
38
+ ]) assert.ok(css.includes('\n' + rule), rule);
39
+ });
40
+
41
+ // ---------- in a browser ----------
42
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
43
+ let browser = null;
44
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
45
+ after(() => browser && browser.close());
46
+ const skip = !browser && 'no system Edge or Chrome';
47
+
48
+ const ORIGIN = 'http://hall.test';
49
+ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png' };
50
+ const mainOf = (page) => { const html = read(page); return html.slice(html.indexOf('<main'), html.indexOf('</main>') + 7); };
51
+ // The sky's own wiring, as far as the visor needs it: SKY.$ / on / S, and a press that flips.
52
+ const STUB = `<script>
53
+ window.OTB = { escapeHtml: (s) => String(s) };
54
+ window.OTBCraftMode = { character() { return 'artist'; }, current() { return null; }, me() { return { builder: { total_credits: 10 } }; }, spriteSrc() { return ''; }, openTool() {} };
55
+ const $ = (id) => document.getElementById(id);
56
+ window.OTBSky = { $, on: (id, ev, fn) => { const el = $(id); if (el) el.addEventListener(ev, fn); }, S: { clockHidden: false } };
57
+ window.presses = {};
58
+ document.querySelectorAll('#tools button, #lensGroup button').forEach((b) => b.addEventListener('click', () => {
59
+ window.presses[b.id] = (window.presses[b.id] || 0) + 1;
60
+ if (b.hasAttribute('aria-pressed')) b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));
61
+ }));
62
+ </script>`;
63
+ const PAGE = (page, game) => `<!doctype html><html data-theme="dark" data-faces="helm"${game ? ' data-game="on"' : ''}><head><meta charset="utf-8">
64
+ <link rel="stylesheet" href="/builders/style.css"><link rel="stylesheet" href="/builders/visor.css"><link rel="stylesheet" href="/builders/thinking.css"></head>
65
+ <body data-page="${page.replace('.html', '')}">${mainOf(page)}${STUB}
66
+ <script src="/builders/shell-game.js"></script><script src="/builders/visor.js"></script><script src="/builders/sky-visor.js"></script></body></html>`;
67
+
68
+ async function open(page, { game = true } = {}) {
69
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
70
+ await ctx.route(ORIGIN + '/**', (route) => {
71
+ const p = new URL(route.request().url()).pathname;
72
+ if (p === '/') return route.fulfill({ contentType: 'text/html', body: PAGE(page, game) });
73
+ const f = path.join(HALL, p.replace(/^\/builders\//, ''));
74
+ if (p.startsWith('/builders/') && TYPES[path.extname(p)] && fs.existsSync(f)) return route.fulfill({ contentType: TYPES[path.extname(p)], body: fs.readFileSync(f) });
75
+ return route.fulfill({ status: 404, body: '' });
76
+ });
77
+ const pg = await ctx.newPage();
78
+ const errors = [];
79
+ pg.on('pageerror', (e) => errors.push(String(e.message)));
80
+ await pg.addInitScript((g) => { try { localStorage.setItem('hall-game', g ? 'on' : 'off'); } catch (_) { /* none */ } }, game);
81
+ await pg.goto(ORIGIN + '/');
82
+ await pg.waitForFunction(() => !!window.OTBVisor && !!window.__visorScene);
83
+ return { ctx, page: pg, errors };
84
+ }
85
+ // every button and field on the sky a person can see (dialogs and side panels are pop-ups)
86
+ const sceneControls = () => [...document.querySelectorAll('.sky-stage button, .sky-stage input')]
87
+ .filter((e) => e.getClientRects().length && !e.closest('#start, #tour, #panel, #room, #menu')).map((e) => e.id || e.className);
88
+ const labels = () => window.__visorScene.actions.map((a) => a.label + (a.key ? ' [' + a.key + ']' : ''));
89
+
90
+ test('game mode, the project sky: no control of its own, and every one is a visor action', { skip }, async () => {
91
+ const { ctx, page, errors } = await open('goal-map.html');
92
+ assert.deepEqual(await page.evaluate(sceneControls), [], 'the sky shows no button or field');
93
+ assert.deepEqual(await page.evaluate(labels), [
94
+ 'Write a thought [/]', 'Jump to a goal or a thought [j]', 'List · off [L]', 'Explore · on', 'My ideas · off',
95
+ 'Clock · off', 'How to read the sky [?]', 'Fit the whole sky [0]', 'Legend · off',
96
+ ]);
97
+ assert.equal(await page.textContent('.visor__hint--place'), '/Write a thought', 'writing a thought is the lead, on the glass');
98
+ assert.deepEqual(errors, []);
99
+ await ctx.close();
100
+ });
101
+
102
+ test('game mode, your sky: the lens switch and its own pills are rows too', { skip }, async () => {
103
+ const { ctx, page } = await open('thinking.html');
104
+ assert.deepEqual(await page.evaluate(sceneControls), []);
105
+ const got = await page.evaluate(labels);
106
+ for (const want of ['Simple · off', 'Your thinking · on', 'My influence · off', 'Claude layer · off', 'What if · off']) assert.ok(got.includes(want), want);
107
+ await ctx.close();
108
+ });
109
+
110
+ test('a row presses its pill once, and the row follows the pill', { skip }, async () => {
111
+ const { ctx, page } = await open('goal-map.html');
112
+ await page.keyboard.press('a');
113
+ await page.click('.visor__sheet [data-visor-scene] >> text=My ideas · off');
114
+ assert.deepEqual(await page.evaluate(() => [window.presses.ideasBtn, document.getElementById('ideasBtn').getAttribute('aria-pressed')]), [1, 'true'], 'one press, not two');
115
+ await page.waitForFunction(() => window.__visorScene.actions.some((a) => a.label === 'My ideas · on'));
116
+ await ctx.close();
117
+ });
118
+
119
+ test('J opens the jump field and / is the composer; each closes again when left empty', { skip }, async () => {
120
+ const { ctx, page } = await open('goal-map.html');
121
+ await page.keyboard.press('j');
122
+ assert.deepEqual(await page.evaluate(() => [getComputedStyle(document.querySelector('.jump')).display, document.activeElement.id]), ['block', 'jump']);
123
+ await page.evaluate(() => window.__visorScene.actions[0].run());
124
+ assert.deepEqual(await page.evaluate(() => [getComputedStyle(document.querySelector('.jump')).display, getComputedStyle(document.getElementById('composerWrap')).display, document.activeElement.id]), ['none', 'flex', 'thought'], 'one field at a time');
125
+ await page.keyboard.type('half a thought');
126
+ await page.evaluate(() => document.activeElement.blur());
127
+ await page.waitForTimeout(20);
128
+ assert.equal(await page.evaluate(() => getComputedStyle(document.getElementById('composerWrap')).display), 'flex', 'words in it: it stays');
129
+ await page.evaluate(() => { document.getElementById('thought').value = ''; document.getElementById('thought').focus(); document.activeElement.blur(); });
130
+ await page.waitForTimeout(20);
131
+ assert.equal(await page.evaluate(() => getComputedStyle(document.getElementById('composerWrap')).display), 'none', 'left empty: it closes');
132
+ await ctx.close();
133
+ });
134
+
135
+ test('the clock and the legend open from the visor', { skip }, async () => {
136
+ const { ctx, page } = await open('goal-map.html');
137
+ const shown = () => page.evaluate(() => ['time', 'legend'].map((id) => getComputedStyle(document.getElementById(id)).display));
138
+ assert.deepEqual(await shown(), ['none', 'none']);
139
+ await page.evaluate(() => { for (const a of window.__visorScene.actions) if (/^(Clock|Legend)/.test(a.label)) a.run(); });
140
+ assert.deepEqual(await shown(), ['flex', 'flex']);
141
+ await page.waitForFunction(() => window.__visorScene.actions.some((a) => a.label === 'Clock · on'));
142
+ await ctx.close();
143
+ });
144
+
145
+ test('classic mode: the sky keeps its own top row, fields and foot', { skip }, async () => {
146
+ const { ctx, page } = await open('goal-map.html', { game: false });
147
+ const ids = await page.evaluate(sceneControls);
148
+ for (const id of ['roomBtn', 'exploreBtn', 'ideasBtn', 'jump', 'thought', 'fileGo', 'play', 'clock']) assert.ok(ids.includes(id), id);
149
+ await ctx.close();
150
+ });
@@ -66,6 +66,17 @@ test('a key means an action only when nothing else spent it', () => {
66
66
  assert.equal(V.keyFor('artisan', ev('Escape')), null, 'the ambient glass never takes Escape (ADR 0399 §2.4)');
67
67
  });
68
68
 
69
+ // task 1005209: a scene action the visor binds (bind: true) answers its key after the character's.
70
+ test("a place's own key: the visor binds it only when the scene asks, and the character's keys win", () => {
71
+ const V = loadPure();
72
+ const ev = (key) => ({ key, defaultPrevented: false, ctrlKey: false, metaKey: false, altKey: false });
73
+ const run = () => {};
74
+ V.scene({ name: 'x', place: 'X', actions: [{ label: 'Decide', key: 'D', bind: true, lead: true, run }, { label: 'Back to centre', key: '0', run }, { label: 'Claim here', key: 'c', bind: true, run }] });
75
+ assert.equal(V.keyFor('artisan', ev('d')).label, 'Decide', 'bound, and case does not matter');
76
+ assert.equal(V.keyFor('artisan', ev('0')), null, "a key the scene binds itself stays the scene's");
77
+ assert.equal(V.keyFor('artisan', ev('c')).id, 'claim', "the character's key wins a clash");
78
+ });
79
+
69
80
  // task 1005203: the four segments are the version the project is building, in quarters.
70
81
  test('progress: the starred version while open, else the one building, else none', () => {
71
82
  const V = loadPure();
@@ -109,7 +120,7 @@ test('source: the visor loads after the shell on every scene page, with its shee
109
120
  assert.ok(at('/builders/visor.js') > at('/builders/shell.js?') && at('/builders/shell.js?') > at('/builders/shell-game.js'), `${f}: shell-game, shell, then the visor`);
110
121
  assert.match(html, /<link rel="stylesheet" href="\/builders\/visor\.css/, `${f}: links visor.css`);
111
122
  }
112
- for (const f of ['sky-interaction.js', 'city.js', 'home-city.js']) assert.match(read(f), /window\.__visorScene = /, `${f} registers its scene`);
123
+ for (const f of ['sky-visor.js', 'city.js', 'home-city.js']) assert.match(read(f), /window\.__visorScene = /, `${f} registers its scene`);
113
124
  assert.doesNotMatch(VISOR, /cam\.(x|y|k)\s*=/, 'the visor writes no camera (ADR 0399 §2.2)');
114
125
  assert.doesNotMatch(VISOR, /setAttribute\('hidden'|\.hidden = true;[^\n]*nav/, 'it hides nothing but itself');
115
126
  assert.match(read('visor.css'), /html\[data-game="on"\] \.visor/, 'game mode only');
@@ -146,7 +157,7 @@ test('source: the minimal visor spends the Helm tokens and types in no colour, s
146
157
  assert.ok(phone, 'the phone rules sit in the shared phone block');
147
158
  assert.match(phone[0], /\.visor__hint:not\(\[data-visor-sheet\]\) \{ display: none; \}/, 'a phone keeps only Actions of the hints');
148
159
  assert.doesNotMatch(read('home-city.css'), /visor-on/, 'Home has no top-right pill to clear any more');
149
- assert.match(read('city.css'), /\nhtml\.visor-on \.city-door \{ bottom: 150px; \}/, 'the city\'s door clears the centred button at every width');
160
+ assert.match(read('city.css'), /\nhtml\.visor-on \.city-door \{ bottom: 224px; \}/, 'the city\'s door clears the centred button at every width');
150
161
  });
151
162
 
152
163
  // ---------- in a browser: the glass over a stand-in scene ----------
@@ -260,6 +271,21 @@ test('Escape closes one layer, top first: the sheet, then the scene\'s pick, the
260
271
  await ctx.close();
261
272
  });
262
273
 
274
+ test('a lead scene action is the first key hint, and its click and its key run it (task 1005209)', { skip }, async () => {
275
+ const { ctx, page, errors } = await open();
276
+ await page.evaluate(() => OTBVisor.scene({ name: 'test', place: 'Test city', actions: [{ label: 'Decide 2 in a row', key: 'd', bind: true, lead: true, run() { window.decided = (window.decided || 0) + 1; } }] }));
277
+ const hints = await page.$$eval('.visor__hints .visor__hint', (h) => h.map((x) => x.textContent));
278
+ assert.deepEqual(hints, ['DDecide 2 in a row', 'TYour thinking', 'AActions', 'EscOptions']);
279
+ await page.click('.visor__hint--place');
280
+ await page.keyboard.press('d');
281
+ assert.equal(await page.evaluate(() => window.decided), 2, 'the hint and D both press it');
282
+ await page.evaluate(() => document.documentElement.removeAttribute('data-game'));
283
+ await page.keyboard.press('d');
284
+ assert.equal(await page.evaluate(() => window.decided), 2, 'outside game mode the glass binds no key');
285
+ assert.deepEqual(errors, []);
286
+ await ctx.close();
287
+ });
288
+
263
289
  test('the sheet lists the character\'s actions, then this place, and runs a scene action', { skip }, async () => {
264
290
  const { ctx, page } = await open();
265
291
  await page.click('[data-visor-sheet]');