@bongos/core 1.25.2 → 1.25.4

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 (37) hide show
  1. package/.bongos-core.json +55 -40
  2. package/docs/copy-inventory.md +30 -29
  3. package/docs/copy-registry.json +78 -58
  4. package/docs/module-api-changelog.md +4 -0
  5. package/docs/page-inventory.json +5 -3
  6. package/docs/page-readings.json +44 -44
  7. package/modules/hall-ui/public/builders.js +3 -27
  8. package/modules/hall-ui/public/genesis-home.js +54 -4
  9. package/modules/hall-ui/public/hall-render.js +5 -119
  10. package/modules/hall-ui/public/hall-widgets.js +3 -3
  11. package/modules/hall-ui/public/index-genesis-votes.states.json +61 -0
  12. package/modules/hall-ui/public/index.html +0 -7
  13. package/modules/hall-ui/public/shell-game.js +22 -4
  14. package/modules/hall-ui/public/style.css +3 -73
  15. package/modules/hall-ui/public/visor.css +31 -1
  16. package/modules/hall-ui/public/visor.js +53 -3
  17. package/modules/hall-ui/records/home-reshuffle.md +1 -1
  18. package/modules/hall-ui/records/visor.md +10 -0
  19. package/modules/onboarding/founding-mode.js +4 -1
  20. package/modules/onboarding/genesis-home.js +57 -2
  21. package/modules/onboarding/routes/onboarding.js +2 -0
  22. package/modules/ui-design/kit/fixtures/founding-genesis-votes.json +193 -0
  23. package/modules/ui-design/kit/fixtures/me-founding-demo.json +0 -1
  24. package/modules/ui-design/kit/fixtures/me-founding.json +0 -1
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +12 -0
  28. package/src/module-api.js +1 -1
  29. package/tests/founding_mode.mjs +13 -3
  30. package/tests/genesis_home.mjs +92 -4
  31. package/tests/hall_board_world.mjs +7 -35
  32. package/tests/hall_home_ships_bounded.mjs +11 -27
  33. package/tests/hall_landing_boot.mjs +7 -8
  34. package/tests/hall_nav.mjs +2 -2
  35. package/tests/hall_visor.mjs +9 -2
  36. package/tests/hall_visor_profile.mjs +150 -0
  37. package/tests/leaderboard_roles.mjs +3 -17
@@ -7,9 +7,8 @@
7
7
  // table and a count. This pins the bounded shape:
8
8
  //
9
9
  // 1. the route answers a small page plus `total`, counting only when it must;
10
- // 2. hall-render.js keeps Home on that small page, and fetches the full ledger
11
- // only when the Accomplishments view is open — and again only when the
12
- // newest ships or the count moved.
10
+ // 2. Home asks for no ships at all (task 1005249 removed the last count it
11
+ // showed); the Profile page reads its own ledger.
13
12
  //
14
13
  // (builders.js's own request is pinned in tests/hall_landing_boot.mjs, which
15
14
  // already boots the real page.) DB-free: vm + stubs.
@@ -120,31 +119,16 @@ function loadRender({ view = 'home' } = {}) {
120
119
  return { r, el, fetched, showView };
121
120
  }
122
121
 
123
- const homePage = (total, firstId = 3000) => ({ ships: [ship(firstId), ship(firstId - 1)], total });
124
-
125
- test('Home: the Standing chip\'s count comes from the small page, with no ledger fetch', async () => {
126
- // task 1005054: the Shipped tile and the five-row table left Home with the stats
127
- // strip and "Your work"; the small page still feeds the Standing chip's count.
128
- const { r, el, fetched } = loadRender();
129
- assert.equal(r.HOME_SHIPS_LIMIT, 5);
130
- r.setRecentShips(homePage(3000));
131
- await new Promise((done) => setTimeout(done, 0));
132
- assert.deepEqual(fetched, [], 'Home never asks for the full history');
133
- assert.equal(el('standing-shipped').textContent, '3000', 'the chip shows the server total, not rows held');
134
- assert.equal(el('recent-ships tbody').innerHTML, '', 'the ledger is untouched until its view opens');
135
- });
136
-
137
- // task 1005050: the full ledger was the Accomplishments view's, and that view
138
- // folded into the Profile page (whose Works ledger reads its own record). Home
139
- // now NEVER asks for the whole history — not on a view change, not on a refresh.
140
- test('Home never downloads the whole history, whatever view event or refresh arrives', async () => {
141
- const { r, el, fetched, showView } = loadRender({ view: 'standing' });
142
- r.setRecentShips(homePage(3000));
122
+ // task 1005249: the Standing chip, the last thing on Home that showed a shipped
123
+ // count, is gone, so Home keeps no ships at all — not a small page, not a count —
124
+ // and asks for none, whatever view event arrives.
125
+ test('Home keeps and asks for no ships at all, whatever view event arrives', async () => {
126
+ const { r, fetched, showView } = loadRender({ view: 'home' });
127
+ assert.equal(r.setRecentShips, undefined, 'no ships setter');
128
+ assert.equal(r.HOME_SHIPS_LIMIT, undefined, 'no small page size');
129
+ assert.equal(r.loadShipLedger, undefined, 'no ledger loader');
143
130
  showView('standing');
144
- r.setRecentShips(homePage(3001, 3001)); // this builder shipped
145
131
  await new Promise((done) => setTimeout(done, 0));
146
- assert.deepEqual(fetched, [], 'no ?limit=all read from Home');
147
- assert.equal(el('standing-shipped').textContent, '3001', 'the chip follows the newest total');
148
- assert.equal(r.loadShipLedger, undefined, 'the ledger loader went with the view');
132
+ assert.deepEqual(fetched, [], 'no ships read from Home');
149
133
  });
150
134
 
@@ -91,10 +91,9 @@ async function boot({ meStatus, settle = false }) {
91
91
  wirePager: () => {},
92
92
  },
93
93
  OTBHallRender: () => ({
94
- analyticsState: {}, lbState: {},
94
+ analyticsState: {},
95
95
  shipsPager: { page: 1 },
96
- // task 1004854: Home asks for this many ships; the ledger's full read lives in hall-render.js.
97
- HOME_SHIPS_LIMIT: 5, setRecentShips: spy('setRecentShips'), populateShipVersionFilter: spy('populateShipVersionFilter'),
96
+ populateShipVersionFilter: spy('populateShipVersionFilter'),
98
97
  mountHallWidgets: spy('mountHallWidgets'),
99
98
  renderLanding: spy('renderLanding'),
100
99
  renderCurveBody: spy('renderCurveBody'), renderGreeting: spy('renderGreeting'),
@@ -154,10 +153,10 @@ test('a signed-in builder still gets the hall', async () => {
154
153
 
155
154
  // task 1004854: Home used to download the builder's WHOLE ship history here, on
156
155
  // every load and every live refresh — ~3.4 s of database time for 3,000 ships, to
157
- // fill a five-row table and a count. It asks for the small page only.
158
- test('Home asks for its few newest ships, never the full history', async () => {
156
+ // fill a five-row table and a count. Since task 1005249 it shows no ship count at
157
+ // all (the Standing chip went), so it asks for no ships and no leaderboard.
158
+ test('Home asks for no ships and no leaderboard', async () => {
159
159
  const calls = await boot({ meStatus: 200, settle: true });
160
- const ships = calls.requests.filter((r) => r.includes('/me/recent-ships'));
161
- assert.deepEqual(ships, ['GET /api/bongos/me/recent-ships?limit=5'], `ship requests were: ${ships.join(', ') || 'none'}`);
162
- assert.ok(calls.rendered.includes('setRecentShips'), 'the small page is handed to the renderer');
160
+ const reads = calls.requests.filter((r) => r.includes('/me/recent-ships') || r.includes('/leaderboard'));
161
+ assert.deepEqual(reads, [], `Home still read: ${reads.join(', ')}`);
163
162
  });
@@ -905,9 +905,9 @@ test('Accomplishments is gone from the sidebar, and every old link lands on your
905
905
  }
906
906
  // No view is left for them to open on the index.
907
907
  assert.ok(!/id="view-standing"/.test(fs.readFileSync(path.join(HALL, 'index.html'), 'utf8')), 'the view is gone');
908
- // Home's Standing card still names the shipped count and links to the profile.
908
+ // Home's Standing card went in task 1005249: the visor's you-block opens the profile now
909
+ // (tests/hall_visor_profile.mjs runs it).
909
910
  const render = fs.readFileSync(path.join(HALL, 'hall-render.js'), 'utf8');
910
- assert.match(render, /View full profile →/);
911
911
  assert.ok(!/\$\('#profile-body'\)/.test(render), 'the Accomplishments band is not drawn any more');
912
912
  const fn = render.slice(render.indexOf(' function renderProfile(builder) {'), render.indexOf(' function renderSystemNotices('));
913
913
  assert.match(fn, /renderSystemNotices\(\{ rebaseTasks, need, actionNeeds, cost \}\)/, 'Home still gets every notice');
@@ -503,9 +503,16 @@ test('the glass is the kit\'s: the cut accent button, ice hints with no box, the
503
503
  assert.equal(g.placeShadow, 'none', 'type on a plate needs no halo');
504
504
  assert.equal(g.radius, '0px', 'no round thing on the glass');
505
505
  assert.match(g.spriteClip, /^polygon\(/, 'the sprite sits in a cut box');
506
+ // task 1005249: you (left of the foot row) are a link to your profile now, so the
507
+ // stops run in reading order: you, then the accent button. Both take the inset ring.
508
+ const focused = () => page.evaluate(() => { const a = document.activeElement; const s = getComputedStyle(a); return { cls: a.className, outline: s.outlineStyle, ring: s.boxShadow }; });
506
509
  await page.keyboard.press('Tab');
507
- const f = await page.evaluate(() => { const a = document.activeElement; const s = getComputedStyle(a); return { cls: a.className, outline: s.outlineStyle, ring: s.boxShadow }; });
508
- assert.equal(f.cls, 'visor__main', 'the accent button is the first stop');
510
+ const me = await focused();
511
+ assert.equal(me.cls, 'visor__me', 'you are the first stop, reading left to right');
512
+ assert.match(me.ring, /inset/, 'you take the inset ring too');
513
+ await page.keyboard.press('Tab');
514
+ const f = await focused();
515
+ assert.equal(f.cls, 'visor__main', 'the accent button is the next stop');
509
516
  assert.equal(f.outline, 'none');
510
517
  assert.match(f.ring, /inset/, 'focus is the inset two-tone ring (--press-focus)');
511
518
  await ctx.close();
@@ -0,0 +1,150 @@
1
+ // tests/hall_visor_profile.mjs — you on the visor (task 1005249).
2
+ //
3
+ // The owner's ask (2026-11-12): the Standing chip that sat top right of Home is gone;
4
+ // the visor's bottom-left you-block shows your details on hover and opens your whole
5
+ // profile, in the game's window over the place, on click. This runs the real visor,
6
+ // the real shell-game window and the real dialog in a browser, plus the pure facts in
7
+ // a vm, and checks Home's page no longer carries the chip.
8
+ //
9
+ // Run: node --test tests/hall_visor_profile.mjs
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 vm from 'node:vm';
16
+ import { fileURLToPath } from 'node:url';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+ const HALL = path.join(ROOT, 'modules/hall-ui/public');
21
+ const read = (f) => fs.readFileSync(path.join(HALL, f), 'utf8');
22
+
23
+ // ---------- the facts, in a vm ----------
24
+ function loadVisor(me) {
25
+ const doc = {
26
+ readyState: 'complete', body: null, documentElement: { dataset: {}, classList: { contains: () => false, toggle() {}, remove() {} } },
27
+ addEventListener() {}, querySelector: () => null, createElement: () => ({}),
28
+ };
29
+ const win = { document: doc, location: { pathname: '/', assign() {} }, addEventListener() {}, OTBCraftMode: { me: () => me } };
30
+ win.window = win;
31
+ vm.runInNewContext(read('visor.js'), win);
32
+ return win.OTBVisor;
33
+ }
34
+
35
+ test('the card\'s facts come from /me: name, handle, rank, credits, karma, preferred work', () => {
36
+ const me = { builder: { display_name: 'Percy Jackson', github_login: 'percy', rank: 'metic', total_credits: 14441, karma: 96.4, preferred_disciplines: ['artisan', 'ui'] } };
37
+ const f = JSON.parse(JSON.stringify(loadVisor(me).meFacts(me)));
38
+ assert.deepEqual(f, { name: 'Percy Jackson', handle: '@percy', rank: 'Metic', credits: 14441, karma: 96, prefs: 'artisan, ui' });
39
+ const bare = JSON.parse(JSON.stringify(loadVisor(null).meFacts({ builder: { github_login: 'x' } })));
40
+ assert.equal(bare.name, 'x', 'no display name: the login');
41
+ assert.equal(bare.karma, 0);
42
+ assert.equal(bare.prefs, '', 'no preference: empty, and the card says "Not set yet"');
43
+ assert.equal(loadVisor(null).meFacts(null), null, 'before /me answers there is nothing to show');
44
+ });
45
+
46
+ test('Home carries no Standing chip any more, and no sheet styles one', () => {
47
+ assert.doesNotMatch(read('index.html'), /hall-standing|home-standing-body/);
48
+ assert.doesNotMatch(read('style.css'), /\.hall-standing|\.home-who|\.home-facts/);
49
+ assert.doesNotMatch(read('hall-render.js'), /home-standing-body|hall-standing|leaderboardPlace/);
50
+ });
51
+
52
+ // ---------- in a browser ----------
53
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
54
+ let browser = null;
55
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
56
+ after(() => browser && browser.close());
57
+ const skip = !browser && 'no system Edge or Chrome';
58
+
59
+ const ORIGIN = 'http://hall.test';
60
+ const TYPES = { '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png', '.svg': 'image/svg+xml' };
61
+ const SCENE = `<script>
62
+ window.OTBCraftMode = { character() { return 'artisan'; }, current() { return null; },
63
+ me() { return { builder: { display_name: 'Percy Jackson', github_login: 'percy', rank: 'metic', total_credits: 14441, karma: 96, preferred_disciplines: [] } }; },
64
+ spriteSrc(id) { return '/builders/characters/' + id + '.png'; } };
65
+ window.__visorScene = { name: 'test', place: 'Test city', actions: [], keys: [] };
66
+ </script>`;
67
+ const PAGE = (withWindow) => `<!doctype html><html data-theme="dark" data-faces="helm"><head><meta charset="utf-8">
68
+ <link rel="stylesheet" href="/builders/style.css"><link rel="stylesheet" href="/builders/visor.css"></head>
69
+ <body>${SCENE}<script src="/builders/hall-dialog.js"></script>${withWindow ? '<script src="/builders/shell-game.js"></script>' : ''}<script src="/builders/visor.js"></script></body></html>`;
70
+
71
+ async function open({ withWindow = true } = {}) {
72
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
73
+ await ctx.route(ORIGIN + '/**', (route) => {
74
+ const p = new URL(route.request().url()).pathname;
75
+ if (p === '/') return route.fulfill({ contentType: 'text/html', body: PAGE(withWindow) });
76
+ if (p === '/profile') return route.fulfill({ contentType: 'text/html', body: '<!doctype html><title>profile</title><p id="pp">your profile</p><script src="/builders/shell-game.js"></script>' }); // the real page carries the game shell, which hands an Escape up
77
+ const f = path.join(HALL, p.replace(/^\/builders\//, ''));
78
+ if (p.startsWith('/builders/') && TYPES[path.extname(p)] && fs.existsSync(f)) return route.fulfill({ contentType: TYPES[path.extname(p)], body: fs.readFileSync(f) });
79
+ return route.fulfill({ status: 404, body: '' });
80
+ });
81
+ const page = await ctx.newPage();
82
+ const errors = [];
83
+ page.on('pageerror', (e) => errors.push(String(e.message)));
84
+ await page.addInitScript(() => { try { localStorage.setItem('hall-game', 'on'); document.documentElement.dataset.game = 'on'; window.OTB = { escapeHtml: (s) => String(s) }; } catch (_) { /* none */ } });
85
+ await page.goto(ORIGIN + '/');
86
+ await page.waitForFunction(() => !!document.querySelector('.visor__me'));
87
+ return { ctx, page, errors };
88
+ }
89
+
90
+ test('hovering you on the visor shows your details; at rest they are hidden', { skip }, async () => {
91
+ const { ctx, page, errors } = await open();
92
+ const shown = () => page.evaluate(() => getComputedStyle(document.querySelector('.visor__card')).visibility);
93
+ assert.equal(await shown(), 'hidden', 'nothing over the scene at rest');
94
+ await page.hover('.visor__me');
95
+ await page.waitForFunction(() => getComputedStyle(document.querySelector('.visor__card')).opacity === '1');
96
+ assert.equal(await shown(), 'visible');
97
+ const text = await page.evaluate(() => [...document.querySelectorAll('.visor__card > span')].map((s) => s.innerText.replace(/\s+/g, ' ').trim()));
98
+ assert.match(text[0], /Percy Jackson @percy/i);
99
+ assert.match(text[1], /Rank Metic/i);
100
+ assert.match(text[1], /Credits 14,441/i);
101
+ assert.match(text[1], /Karma 96/i);
102
+ assert.match(text[1], /Preferred work Not set yet/i);
103
+ assert.match(text[2], /Open your profile/i);
104
+ const box = await page.evaluate(() => { const r = document.querySelector('.visor__card').getBoundingClientRect(); return { top: r.top, left: r.left }; });
105
+ assert.ok(box.top >= 0 && box.left >= 0, 'the card stays on screen');
106
+ assert.deepEqual(errors, []);
107
+ await ctx.close();
108
+ });
109
+
110
+ test('clicking you opens your whole profile in the window over the place; Escape closes it', { skip }, async () => {
111
+ const { ctx, page, errors } = await open();
112
+ await page.click('.visor__me');
113
+ await page.waitForFunction(() => window.OTBOptionsOverlay.isOpen());
114
+ const w = await page.evaluate(() => ({
115
+ url: location.pathname,
116
+ src: document.querySelector('.opt-window__frame').getAttribute('src'),
117
+ label: document.querySelector('.opt-window__box').getAttribute('aria-label'),
118
+ close: document.querySelector('.opt-window__x').getAttribute('aria-label'),
119
+ }));
120
+ assert.equal(w.url, '/', 'the place stays: no navigation');
121
+ assert.equal(w.src, '/profile?embed=1');
122
+ assert.equal(w.label, 'Your profile');
123
+ assert.equal(w.close, 'Close your profile');
124
+ // the frame's own shell must be up before an Escape inside it can be handed up
125
+ await page.waitForFunction(() => { const f = document.querySelector('.opt-window__frame'); try { return !!(f && f.contentDocument.readyState === 'complete' && f.contentDocument.documentElement.dataset.embed === 'on'); } catch (_) { return false; } });
126
+ await page.keyboard.press('Escape');
127
+ await page.waitForFunction(() => !window.OTBOptionsOverlay.isOpen());
128
+ // the same window still opens Options under its own name
129
+ await page.evaluate(() => window.OTBOptionsOverlay.open(''));
130
+ assert.equal(await page.evaluate(() => document.querySelector('.opt-window__box').getAttribute('aria-label')), 'Options');
131
+ assert.equal(await page.evaluate(() => document.querySelector('.opt-window__frame').getAttribute('src')), '/settings?embed=1');
132
+ assert.deepEqual(errors, []);
133
+ await ctx.close();
134
+ });
135
+
136
+ test('with no window to draw, you are still a link to your profile', { skip }, async () => {
137
+ const { ctx, page, errors } = await open({ withWindow: false });
138
+ const r = await page.evaluate(() => {
139
+ const me = document.querySelector('.visor__me');
140
+ const ev = new MouseEvent('click', { bubbles: true, cancelable: true });
141
+ // stop the browser before it leaves, once the visor has had its say
142
+ window.addEventListener('click', (e) => { window.__left = !e.defaultPrevented; e.preventDefault(); });
143
+ me.querySelector('.visor__name').dispatchEvent(ev);
144
+ return { href: me.getAttribute('href'), followed: window.__left };
145
+ });
146
+ assert.equal(r.href, '/profile');
147
+ assert.equal(r.followed, true, 'the visor lets the link take you to the profile page');
148
+ assert.deepEqual(errors, []);
149
+ await ctx.close();
150
+ });
@@ -16,7 +16,7 @@
16
16
  // that craft earned, and a dash, not a 0, for a builder with no AI sessions.
17
17
  // (Two crafts since task 1005141: the engineer is the artisan, and the
18
18
  // ideator folded into the artist, so idea credits rank as artist credits.)
19
- // 4. THE STANDING CARD and 5. THE STATUS PAGE: the real functions, run.
19
+ // 5. THE STATUS PAGE: the real function, run. (4, the Standing chip, went in task 1005249.)
20
20
 
21
21
  import { strict as assert } from 'node:assert';
22
22
  import { createRequire } from 'node:module';
@@ -234,7 +234,7 @@ await test('the Artists toggle shows only artists, ranked by the credits that cr
234
234
  assert.equal(r.rows.length, FIX.leaderboard.length, 'Everyone brings every builder back');
235
235
  });
236
236
 
237
- // ---- 4. the standing card ---------------------------------------------------------
237
+ // ---- 4. (the standing card, gone; extract() serves section 5) ---------------------
238
238
 
239
239
  function extract(src, start, end) {
240
240
  const a = src.indexOf(start);
@@ -243,21 +243,7 @@ function extract(src, start, end) {
243
243
  return src.slice(a, b);
244
244
  }
245
245
 
246
- await test('the standing card adds the place inside the builder\'s main role, only when it says something new', () => {
247
- const src = read('modules/hall-ui/public/hall-render.js');
248
- const code = extract(src, 'const lbState = { rows: [] };', ' return { lbState');
249
- const sandbox = { rows: FIX.leaderboard };
250
- vm.createContext(sandbox);
251
- vm.runInContext(`${code}; lbState.rows = rows; this.place = leaderboardPlace; this.text = rolePlaceText;`, sandbox);
252
- const art = sandbox.place('builder-7');
253
- assert.equal(art.role.craft, 'artist');
254
- assert.equal(sandbox.text(art), `#2 as an artist`, 'fourth overall, second among artists: something new, so it is said');
255
- const top = sandbox.place(FIX.leaderboard[0].github_login);
256
- assert.equal(sandbox.text(top), '', 'the top artisan is #1 either way, so nothing is added');
257
- // task 1005054: the stats strip (and its rank tile) left Home; the Standing chip's card carries the role place
258
- assert.match(src, /\$\{rolePlaceText\(rollPlace\) \? `<span title="Your place among the/);
259
- assert.match(read('modules/hall-ui/public/builders.js'), /getJSON\(`\$\{API\}\/public\/leaderboard`\)/, 'the hall reads the list with the roles');
260
- });
246
+ // (Its test of the Standing chip's role place went with the chip in task 1005249.)
261
247
 
262
248
  // ---- 5. the status page -----------------------------------------------------------
263
249