@bongos/core 1.24.30 → 1.24.31

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 (101) hide show
  1. package/.bongos-core.json +153 -103
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +8 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +139 -1
  8. package/docs/api-reference.md +7 -3
  9. package/docs/copy-inventory.md +192 -187
  10. package/docs/copy-registry.json +530 -458
  11. package/docs/module-api-changelog.md +2 -2
  12. package/docs/page-inventory.json +15 -35
  13. package/docs/page-readings.json +226 -263
  14. package/modules/copy-desk/module.json +1 -1
  15. package/modules/copy-desk/routes/copy-desk-studio.js +103 -0
  16. package/modules/copy-desk/tests/copy_no_cms.mjs +2 -2
  17. package/modules/hall-ui/public/ai-studio.html +1 -1
  18. package/modules/hall-ui/public/atlas.html +1 -1
  19. package/modules/hall-ui/public/blockers.html +1 -1
  20. package/modules/hall-ui/public/board-room.html +1 -1
  21. package/modules/hall-ui/public/city.html +1 -1
  22. package/modules/hall-ui/public/collab.html +1 -1
  23. package/modules/hall-ui/public/commands.html +1 -1
  24. package/modules/hall-ui/public/copy-desk.html +1 -1
  25. package/modules/hall-ui/public/craft-mode.js +1 -1
  26. package/modules/hall-ui/public/deploy.html +1 -1
  27. package/modules/hall-ui/public/diagrams.html +1 -1
  28. package/modules/hall-ui/public/drachmae.html +1 -1
  29. package/modules/hall-ui/public/fleet.html +1 -1
  30. package/modules/hall-ui/public/gate.html +1 -1
  31. package/modules/hall-ui/public/goal-map.html +1 -1
  32. package/modules/hall-ui/public/goals.html +1 -1
  33. package/modules/hall-ui/public/government.html +1 -1
  34. package/modules/hall-ui/public/hall-render.js +62 -86
  35. package/modules/hall-ui/public/home-city-draw.js +360 -52
  36. package/modules/hall-ui/public/home-city.css +47 -11
  37. package/modules/hall-ui/public/home-city.js +156 -40
  38. package/modules/hall-ui/public/home-gallery.css +129 -0
  39. package/modules/hall-ui/public/home-gallery.js +349 -0
  40. package/modules/hall-ui/public/home-studio.css +72 -0
  41. package/modules/hall-ui/public/home-studio.js +203 -0
  42. package/modules/hall-ui/public/idea.html +1 -1
  43. package/modules/hall-ui/public/ideas.html +1 -1
  44. package/modules/hall-ui/public/index.html +4 -1
  45. package/modules/hall-ui/public/market-watch-item.html +1 -1
  46. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  47. package/modules/hall-ui/public/market-watch-settings.js +6 -1
  48. package/modules/hall-ui/public/market-watch.html +141 -54
  49. package/modules/hall-ui/public/market-watch.js +54 -0
  50. package/modules/hall-ui/public/market-watch.states.json +2 -1
  51. package/modules/hall-ui/public/modules.html +1 -1
  52. package/modules/hall-ui/public/my-agents.html +1 -1
  53. package/modules/hall-ui/public/primer.html +1 -1
  54. package/modules/hall-ui/public/profile.html +1 -1
  55. package/modules/hall-ui/public/project-settings.html +1 -1
  56. package/modules/hall-ui/public/ranks.html +1 -1
  57. package/modules/hall-ui/public/roadmap.html +1 -1
  58. package/modules/hall-ui/public/roster.html +1 -1
  59. package/modules/hall-ui/public/sessions.html +1 -1
  60. package/modules/hall-ui/public/settings.html +1 -1
  61. package/modules/hall-ui/public/shell.js +2 -3
  62. package/modules/hall-ui/public/street.html +1 -1
  63. package/modules/hall-ui/public/studio.html +9 -1
  64. package/modules/hall-ui/public/style.css +74 -27
  65. package/modules/hall-ui/public/task.html +1 -1
  66. package/modules/hall-ui/public/thinking.html +1 -1
  67. package/modules/hall-ui/public/tweak-editor.html +1 -1
  68. package/modules/hall-ui/public/watch.html +1 -1
  69. package/modules/hall-ui/public/work.html +1 -1
  70. package/modules/hall-ui/records/home-city.md +123 -0
  71. package/modules/hall-ui/records/loader.md +1 -1
  72. package/modules/hall-ui/records/market-watch-settings.md +16 -3
  73. package/modules/hall-ui/records/market-watch.md +3 -1
  74. package/modules/lifecycle/home-city.js +58 -10
  75. package/modules/lifecycle/home-gallery.js +320 -0
  76. package/modules/lifecycle/migrations/lifecycle_020_work_appreciations.sql +36 -0
  77. package/modules/lifecycle/module.json +1 -0
  78. package/modules/lifecycle/routes/home-gallery.js +37 -0
  79. package/modules/market-watch/routes/settings.js +1 -1
  80. package/package-lock.json +2 -2
  81. package/package.json +1 -1
  82. package/release-notes.json +29 -1
  83. package/scripts/bongos/leak-scan-allowlist.js +8 -0
  84. package/scripts/hall-preview/README.md +1 -0
  85. package/scripts/hall-preview/server.js +10 -0
  86. package/src/bongos/serve-internal.js +17 -3
  87. package/src/module-api.js +1 -1
  88. package/tests/hall_builders_page_pass.mjs +18 -27
  89. package/tests/hall_loading.mjs +1 -1
  90. package/tests/hall_nav.mjs +11 -4
  91. package/tests/hall_page_gate_map.mjs +7 -4
  92. package/tests/home_city.mjs +200 -5
  93. package/tests/home_city_widget.mjs +167 -27
  94. package/tests/home_gallery.mjs +255 -0
  95. package/tests/home_gallery_widget.mjs +220 -0
  96. package/tests/home_studio.mjs +169 -0
  97. package/tests/home_studio_widget.mjs +157 -0
  98. package/tests/market_watch_pages.mjs +162 -0
  99. package/tests/nav_permission_atoms.mjs +2 -2
  100. package/modules/hall-ui/public/market-watch-settings.html +0 -115
  101. package/modules/hall-ui/public/market-watch-settings.states.json +0 -14
@@ -0,0 +1,220 @@
1
+ // tests/home_gallery_widget.mjs — the artist's Home, the real gallery in a browser
2
+ // (task 1005060, Home v16 step 3).
3
+ //
4
+ // Runs modules/hall-ui/public/home-gallery.js (and, for the craft switch, the city's
5
+ // three files) in a real page with the reads stubbed by the hall-preview fixture: the
6
+ // wall is asked for once, only in Artistry, Most popular first; the arrows and keys walk
7
+ // it; the label opens closer on a click; the heart is the viewer's own, given and taken
8
+ // back, and never changes size; Latest asks again in that order; full screen keeps the
9
+ // signature and heart and drops the label; the full gallery hangs every work and a click
10
+ // sits you in front of one; at phone width nothing scrolls sideways. Skips where no
11
+ // system Edge or Chrome is installed.
12
+ //
13
+ // Run: node --test tests/home_gallery_widget.mjs
14
+ import assert from 'node:assert/strict';
15
+ import { test, after } from 'node:test';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
24
+ const pub = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', f), 'utf8');
25
+ const GALLERY = pub('home-gallery.js');
26
+ const Lib = require(path.join(ROOT, 'modules', 'hall-ui', 'public', 'home-gallery.js'));
27
+ const CSS = pub('home-city.css') + pub('home-gallery.css');
28
+ const FIXTURE = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'home__gallery.json'), 'utf8');
29
+ const CITY = fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', 'home__city.json'), 'utf8');
30
+ const N = JSON.parse(FIXTURE).works.length;
31
+
32
+ let browser = null;
33
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
34
+ after(() => browser && browser.close());
35
+ const skip = !browser && 'no system Edge or Chrome';
36
+
37
+ const stubs = `
38
+ window.__asked = [];
39
+ window.__GALLERY = ${FIXTURE};
40
+ window.__CITY = ${CITY};
41
+ window.BongosClient = { createClient: () => ({ request: async (m, p, o) => {
42
+ window.__asked.push(m + ' ' + p + (o && o.body ? ' ' + JSON.stringify(o.body) : ''));
43
+ if (p.startsWith('/api/bongos/home/gallery?')) {
44
+ const g = JSON.parse(JSON.stringify(window.__GALLERY));
45
+ if (p.endsWith('order=latest')) g.works.sort((a, b) => b.shipped.localeCompare(a.shipped));
46
+ return { ok: true, status: 200, data: g };
47
+ }
48
+ const h = /^\\/api\\/bongos\\/home\\/gallery\\/(\\d+)\\/heart$/.exec(p);
49
+ if (h && m === 'PUT') { const w = window.__GALLERY.works.find((x) => x.id === h[1]); return { ok: true, status: 200, data: { id: h[1], hearts: w.hearts + (o.body.on ? 1 : 0), mine: o.body.on } }; }
50
+ if (p.startsWith('/api/bongos/home/city')) return { ok: true, status: 200, data: window.__CITY };
51
+ return { ok: false, status: 404, data: null };
52
+ } }) };
53
+ window.OTBCraftMode = { iconHtml: () => '', choose: () => {} };
54
+ window.OTB = { contributeHallWidget(w) { window.__widget = w; } };`;
55
+
56
+ async function boot({ width = 1440, height = 900, city = false, theme = 'dark' } = {}) {
57
+ const ctx = await browser.newContext({ viewport: { width, height }, reducedMotion: 'reduce' });
58
+ const page = await ctx.newPage();
59
+ await page.setContent(`<!doctype html><html data-theme="${theme}"><head><style>${CSS}</style></head><body><section id="home-city" class="scroll home-city" hidden></section></body></html>`);
60
+ await page.addScriptTag({ content: stubs });
61
+ await page.addScriptTag({ content: GALLERY });
62
+ if (city) {
63
+ for (const f of ['city-draw.js', 'home-city-draw.js', 'home-city.js']) await page.addScriptTag({ content: pub(f) });
64
+ } else {
65
+ await page.evaluate(() => { const h = document.getElementById('home-city'); h.classList.add('hc--gallery'); window.__g = window.OTBHomeGallery.attach(h); h.hidden = false; window.__g.show(true); });
66
+ await page.waitForFunction(() => document.querySelectorAll('.hg-hang').length > 0);
67
+ }
68
+ return { ctx, page };
69
+ }
70
+
71
+ const state = (page) => page.evaluate(() => ({
72
+ asked: window.__asked,
73
+ count: document.querySelector('.hg-count').textContent,
74
+ pressed: [...document.querySelectorAll('.hg-sort button')].map((b) => b.dataset.order + ':' + b.getAttribute('aria-pressed')),
75
+ first: document.querySelector('.hg-hang .hg-title i').textContent,
76
+ }));
77
+
78
+ test('the wall is asked for once, Most popular first, and hangs every work with its label, signature and heart', { skip }, async () => {
79
+ const { ctx, page } = await boot();
80
+ const s = await state(page);
81
+ const label = await page.evaluate(() => {
82
+ const h = document.querySelector('.hg-hang');
83
+ return { who: h.querySelector('.hg-who').textContent, bio: h.querySelector('.hg-bio').textContent, sig: h.querySelector('.hg-sig').textContent, heart: h.querySelector('.hg-heart').getAttribute('aria-pressed'), src: h.querySelector('img').getAttribute('src') };
84
+ });
85
+ await ctx.close();
86
+ assert.deepEqual(s.asked, ['GET /api/bongos/home/gallery?order=popular']);
87
+ assert.equal(s.count, `1 of ${N}`);
88
+ assert.deepEqual(s.pressed, ['popular:true', 'latest:false']);
89
+ assert.equal(label.who, 'Sana Okafor');
90
+ assert.match(label.bio, /^On this project since \w+ 2026$/);
91
+ assert.match(label.sig, /^Sana O\. ’26$/, 'signed in the artist\'s hand, a name, an initial and the year');
92
+ assert.equal(label.heart, 'false');
93
+ assert.match(label.src, /^\/api\/bongos\/task-visuals\/task-\d+-[0-9a-f]{16}\.jpg$/, 'the dark render in dark mode');
94
+ });
95
+
96
+ test('in light mode each work hangs its light render, and a theme change re-hangs it', { skip }, async () => {
97
+ const { ctx, page } = await boot({ theme: 'light' });
98
+ const w = JSON.parse(FIXTURE).works[0];
99
+ const light = await page.evaluate(() => document.querySelector('.hg-hang img').getAttribute('src'));
100
+ await page.evaluate(() => { document.documentElement.dataset.theme = 'dark'; });
101
+ await page.waitForFunction((d) => document.querySelector('.hg-hang img').getAttribute('src') === d, w.art.dark);
102
+ await ctx.close();
103
+ assert.equal(light, w.art.light);
104
+ assert.notEqual(w.art.light, w.art.dark);
105
+ });
106
+
107
+ test('the arrows and the arrow keys walk the wall, and stop at its ends', { skip }, async () => {
108
+ const { ctx, page } = await boot();
109
+ assert.equal(await page.evaluate(() => document.querySelector('.hg-prev').disabled), true);
110
+ await page.click('.hg-next');
111
+ assert.equal((await state(page)).count, `2 of ${N}`);
112
+ await page.focus('.hg-room');
113
+ await page.keyboard.press('ArrowRight');
114
+ await page.keyboard.press('ArrowLeft');
115
+ await page.keyboard.press('ArrowLeft');
116
+ const s = await state(page);
117
+ const hidden = await page.evaluate(() => [...document.querySelectorAll('.hg-hang')].map((h) => h.getAttribute('aria-hidden')).slice(0, 2));
118
+ await ctx.close();
119
+ assert.equal(s.count, `1 of ${N}`);
120
+ assert.deepEqual(hidden, ['false', 'true'], 'only the work in front of you is read out');
121
+ });
122
+
123
+ test('the label opens closer on a click and closes again; the heart is one fixed size, given and taken back', { skip }, async () => {
124
+ const { ctx, page } = await boot();
125
+ const box = () => page.evaluate(() => { const r = document.querySelector('.hg-hang .hg-heart').getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height)]; });
126
+ const before = await box();
127
+ await page.click('.hg-hang .hg-text');
128
+ const reading = await page.evaluate(() => ({ on: document.querySelector('.hg-hang').classList.contains('is-reading'), exp: document.querySelector('.hg-text').getAttribute('aria-expanded'), credit: getComputedStyle(document.querySelector('.hg-credit')).display }));
129
+ assert.deepEqual(reading, { on: true, exp: 'true', credit: 'block' });
130
+ assert.deepEqual(await box(), before, 'reading the label closer does not grow the heart');
131
+ const id = await page.evaluate(() => document.querySelector('.hg-hang .hg-heart').dataset.id);
132
+ const hearts = JSON.parse(FIXTURE).works[0].hearts;
133
+ await page.click('.hg-hang .hg-heart');
134
+ await page.waitForFunction(() => document.querySelector('.hg-hang .hg-heart').getAttribute('aria-pressed') === 'true');
135
+ const credit = await page.evaluate(() => document.querySelector('.hg-hang .hg-credit').textContent);
136
+ assert.match(credit, new RegExp(`Appreciated by ${hearts + 1} builders\\.$`));
137
+ assert.deepEqual(await box(), before, 'a given heart is the same size');
138
+ await page.click('.hg-hang .hg-heart');
139
+ await page.waitForFunction(() => document.querySelector('.hg-hang .hg-heart').getAttribute('aria-pressed') === 'false');
140
+ await page.click('.hg-hang .hg-text');
141
+ const closed = await page.evaluate(() => document.querySelector('.hg-hang').classList.contains('is-reading'));
142
+ const asked = (await state(page)).asked;
143
+ await ctx.close();
144
+ assert.equal(closed, false);
145
+ assert.deepEqual(asked.slice(1), [`PUT /api/bongos/home/gallery/${id}/heart {"on":true}`, `PUT /api/bongos/home/gallery/${id}/heart {"on":false}`]);
146
+ assert.ok(before[0] >= 24 && before[1] >= 24, 'the heart clears the 24px floor');
147
+ });
148
+
149
+ test('Latest asks for the wall again in that order', { skip }, async () => {
150
+ const { ctx, page } = await boot();
151
+ await page.click('.hg-sort [data-order="latest"]');
152
+ await page.waitForFunction(() => window.__asked.length === 2);
153
+ await page.waitForTimeout(50);
154
+ const s = await state(page);
155
+ await ctx.close();
156
+ const latest = JSON.parse(FIXTURE).works.slice().sort((a, b) => b.shipped.localeCompare(a.shipped))[0];
157
+ assert.equal(s.asked[1], 'GET /api/bongos/home/gallery?order=latest');
158
+ assert.deepEqual(s.pressed, ['popular:false', 'latest:true']);
159
+ assert.equal(s.count, `1 of ${N}`, 'the wall starts again at its first work');
160
+ assert.equal(s.first, Lib.fillHoles(latest.title, {}), 'the newest ship hangs first');
161
+ });
162
+
163
+ test('full screen (F) is the art alone with its signature and heart; Escape returns', { skip }, async () => {
164
+ const { ctx, page } = await boot();
165
+ await page.focus('.hg-room');
166
+ await page.keyboard.press('f');
167
+ await page.waitForFunction(() => document.fullscreenElement || document.querySelector('.hg-room.is-fs'));
168
+ const fs = await page.evaluate(() => {
169
+ const vis = (s) => { const e = document.querySelector(s); return !!e && getComputedStyle(e).display !== 'none' && e.getBoundingClientRect().width > 0; };
170
+ return { sig: vis('.hg-hang .hg-sig'), heart: vis('.hg-hang .hg-heart'), text: vis('.hg-hang .hg-text'), vinyl: vis('.hg-vinyl'), cap: vis('.hg-cap') };
171
+ });
172
+ await page.keyboard.press('Escape');
173
+ await page.waitForFunction(() => !document.fullscreenElement && !document.querySelector('.hg-room.is-fs'));
174
+ await ctx.close();
175
+ assert.deepEqual(fs, { sig: true, heart: true, text: false, vinyl: false, cap: true });
176
+ });
177
+
178
+ test('the full gallery hangs every work at once; a click sits you in front of that one', { skip }, async () => {
179
+ const { ctx, page } = await boot();
180
+ await page.click('.hg-way-all');
181
+ const all = await page.evaluate(() => ({ pieces: document.querySelectorAll('.hg-piece').length, salon: !document.querySelector('.hg-salon').hidden, way: document.querySelector('.hg-way-all').textContent.trim(), said: document.querySelector('.hg-room').getAttribute('aria-label') }));
182
+ assert.deepEqual(all, { pieces: N, salon: true, way: 'Back to the bench →', said: 'The full gallery: every work at once. Choose one to sit in front of it.' }, 'the room says what is on screen');
183
+ await page.click('.hg-piece[data-i="3"]');
184
+ const s = await state(page);
185
+ const back = await page.evaluate(() => document.querySelector('.hg-salon').hidden);
186
+ await ctx.close();
187
+ assert.equal(back, true);
188
+ assert.equal(s.count, `4 of ${N}`);
189
+ });
190
+
191
+ test('at phone width the gallery fits: nothing scrolls sideways and every control clears 24px', { skip }, async () => {
192
+ const { ctx, page } = await boot({ width: 390, height: 844 });
193
+ const m = await page.evaluate(() => ({
194
+ over: document.documentElement.scrollWidth - document.documentElement.clientWidth,
195
+ small: [...document.querySelectorAll('.hg button, .hg a, .hg-text')].filter((e) => e.getBoundingClientRect().width > 0 && !e.closest('[aria-hidden="true"]')).map((e) => { const r = e.getBoundingClientRect(); return [e.className, Math.round(r.width), Math.round(r.height)]; }).filter((x) => x[1] < 24 || x[2] < 24),
196
+ }));
197
+ await ctx.close();
198
+ assert.equal(m.over, 0);
199
+ assert.deepEqual(m.small, []);
200
+ });
201
+
202
+ test('Home is the gallery only in Artistry: the city\'s widget switches on the craft and asks for the wall only then', { skip }, async () => {
203
+ const { ctx, page } = await boot({ city: true });
204
+ await page.evaluate(() => window.__widget.mount(document.getElementById('home-city')));
205
+ await page.waitForFunction(() => document.querySelectorAll('.hc-rung').length > 0);
206
+ const building = await page.evaluate(() => ({ asked: window.__asked.slice(), gallery: getComputedStyle(document.querySelector('.hg')).display, stage: getComputedStyle(document.querySelector('.hc-stage')).display }));
207
+ assert.deepEqual(building.asked, ['GET /api/bongos/home/city?weeks=10'], 'the builder\'s Home never asks for the wall');
208
+ assert.equal(building.gallery, 'none');
209
+ await page.evaluate(() => { document.documentElement.dataset.craft = 'artistry'; document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: 'artistry' } })); });
210
+ await page.waitForFunction(() => document.querySelectorAll('.hg-hang').length > 0);
211
+ const art = await page.evaluate(() => ({ asked: window.__asked.slice(), gallery: getComputedStyle(document.querySelector('.hg')).display, stage: getComputedStyle(document.querySelector('.hc-stage')).display, hidden: document.getElementById('home-city').hidden }));
212
+ await page.evaluate(() => { document.documentElement.dataset.craft = 'building'; document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: 'building' } })); });
213
+ const back = await page.evaluate(() => getComputedStyle(document.querySelector('.hc-stage')).display);
214
+ await ctx.close();
215
+ assert.deepEqual(art.asked.slice(1), ['GET /api/bongos/home/gallery?order=popular']);
216
+ assert.equal(art.stage, 'none', 'the city steps aside');
217
+ assert.notEqual(art.gallery, 'none');
218
+ assert.equal(art.hidden, false);
219
+ assert.notEqual(back, 'none', 'and comes back in another craft');
220
+ });
@@ -0,0 +1,169 @@
1
+ // tests/home_studio.mjs — the artist's studio behind the gallery (task 1005061, Home v16
2
+ // step 4).
3
+ //
4
+ // Its two reads with no database: the copy-desk half (modules/copy-desk/routes/
5
+ // copy-desk-studio.js composeStudio: the easel, the pages waiting for an artist, the
6
+ // words to tune, all from the Tweak/CopyWrite Flow's own answer) and the lifecycle half
7
+ // (modules/lifecycle/home-gallery.js readStudioWorks: the works the viewer pinned and
8
+ // signed, by the wall's own rule, bounded) against a recording fake pool, and the page's
9
+ // pure words (modules/hall-ui/public/home-studio.js). The real room in a browser is
10
+ // tests/home_studio_widget.mjs.
11
+ //
12
+ // Run: node tests/home_studio.mjs
13
+ import assert from 'node:assert/strict';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+ import { createRequire } from 'node:module';
17
+ import { makeRunner } from './helpers.mjs';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ process.env.NODE_ENV = 'test';
22
+ const G = require(path.join(ROOT, 'modules/lifecycle/home-gallery.js'));
23
+ const Desk = require(path.join(ROOT, 'modules/copy-desk/routes/copy-desk-studio.js'));
24
+ const Lib = require(path.join(ROOT, 'modules/hall-ui/public/home-studio.js'));
25
+
26
+ const { test, summary } = makeRunner();
27
+
28
+ const cand = (id, tier, changed, over = {}) => Object.assign({ page_id: id, title: id.split(':')[1], surface: id.split(':')[0], tier, changed_lines: changed, why: tier }, over);
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // 1. the copy-desk half
32
+ // ---------------------------------------------------------------------------
33
+
34
+ await test('the easel is the round you hold, opened in the tweak editor', () => {
35
+ const s = Desk.composeStudio({ next: { resume: { page_id: 'builders:studio', title: 'Studio', surface: 'builders', round: 2 }, recommended: cand('landing:index', 'asked', 0) }, flagRows: [] });
36
+ assert.deepEqual(s.easel, { held: true, page_id: 'builders:studio', title: 'Studio', place: 'The builders’ hall', round: 2, open: '/tweak-editor?page=builders%3Astudio' });
37
+ });
38
+
39
+ await test('with nothing held, the easel offers the page the flow recommends next', () => {
40
+ const s = Desk.composeStudio({ next: { resume: null, recommended: cand('landing:index', 'asked', 0, { why: '2 people asked for it' }) }, flagRows: [] });
41
+ assert.equal(s.easel.held, false);
42
+ assert.equal(s.easel.why, '2 people asked for it');
43
+ assert.equal(Desk.composeStudio({ next: null, flagRows: [] }).easel, null, 'no flow, no easel');
44
+ });
45
+
46
+ await test('waiting for new inspiration is the pages changed since their last design, most changed first, bounded', () => {
47
+ const next = { recommended: cand('landing:index', 'asked', 0), alternatives: [
48
+ cand('builders:goals', 'changed', 3), cand('builders:home', 'untweaked', 0), cand('builders:settings', 'changed', 12),
49
+ cand('status:index', 'changed', 1), cand('builders:collab', 'changed', 2), cand('landing:privacy', 'changed', 5), cand('builders:atlas', 'tweaked', 0),
50
+ ] };
51
+ const s = Desk.composeStudio({ next, flagRows: [] });
52
+ assert.equal(s.waiting.total, 5, 'an untweaked page is not waiting on a change; a tweaked one with no drift is not either');
53
+ assert.deepEqual(s.waiting.pages.map((p) => p.page_id), ['builders:settings', 'landing:privacy', 'builders:goals', 'builders:collab']);
54
+ assert.equal(s.waiting.pages.length, Desk.MAX_WAITING);
55
+ assert.equal(s.waiting.pages[0].place, 'The builders’ hall');
56
+ });
57
+
58
+ await test('words to tune are the newest open flags, as text, never more than three', () => {
59
+ const rows = [1, 2, 3, 4].map((i) => ({ id: i, text_at_flag: i === 2 ? null : `Line ${i}`, reason: `why ${i}`, surface: 'builders', resolution_note: 'x' }));
60
+ const s = Desk.composeStudio({ next: null, flagRows: rows });
61
+ assert.equal(s.words.length, Desk.MAX_WORDS);
62
+ assert.deepEqual(s.words[0], { id: '1', text: 'Line 1', reason: 'why 1', place: 'The builders’ hall' });
63
+ assert.equal(s.words[1].text, null, 'a whole-surface flag has no line, only its reason');
64
+ });
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // 2. the lifecycle half: the two shelves of works
68
+ // ---------------------------------------------------------------------------
69
+
70
+ function fakePool({ table = true } = {}) {
71
+ const calls = [];
72
+ return {
73
+ calls,
74
+ async query(sql, params) {
75
+ calls.push({ sql, params });
76
+ if (/to_regclass/.test(sql)) return { rows: [{ ok: table }] };
77
+ return { rows: [] };
78
+ },
79
+ };
80
+ }
81
+
82
+ await test('pinned is the viewer\'s hearts, newest first; yours is what the viewer signed, most appreciated first; both by the wall\'s rule', async () => {
83
+ const pool = fakePool();
84
+ const out = await G.readStudioWorks({ viewer: { id: 7, rank: 'thetes' } }, { pool, exists: async () => true });
85
+ const reads = pool.calls.filter((c) => !/to_regclass/.test(c.sql));
86
+ assert.equal(reads.length, 2);
87
+ const [pin, yours] = reads;
88
+ assert.match(pin.sql, /JOIN lifecycle_work_appreciations pin ON pin\.task_id = t\.id AND pin\.builder_id = \$3::bigint/);
89
+ assert.match(pin.sql, /ORDER BY pin\.at DESC/);
90
+ assert.match(yours.sql, /AND t\.shipped_by = \$3::bigint/);
91
+ assert.match(yours.sql, /ORDER BY hearts DESC/);
92
+ for (const q of reads) {
93
+ assert.match(q.sql, /LIMIT 6\b/);
94
+ assert.match(q.sql, /t\.security_sensitive IS NOT TRUE/);
95
+ assert.match(q.sql, /goal_members/);
96
+ assert.deepEqual(q.params, [['artist', 'ui'], false, '7'], 'the shelves are the signed-in viewer\'s');
97
+ }
98
+ assert.deepEqual(out, { pinned: [], yours: [], easel: null });
99
+ });
100
+
101
+ await test('the easel\'s page is asked for by its round ref, newest first, one work, and only a well-formed page id', async () => {
102
+ const pool = fakePool();
103
+ await G.readStudioWorks({ viewer: { id: 7 }, page: 'builders:studio' }, { pool, exists: async () => true });
104
+ const q = pool.calls.find((c) => /starts_with\(t\.source_ref, \$4\)/.test(c.sql));
105
+ assert.ok(q, 'the easel read');
106
+ assert.match(q.sql, /t\.source = 'page-tweak'/);
107
+ assert.match(q.sql, /ORDER BY t\.shipped_at DESC, t\.id DESC\s+LIMIT 1/);
108
+ assert.match(q.sql, /goal_members/, 'by the wall\'s own rule');
109
+ assert.equal(q.params[3], 'page-tweak/builders:studio/r');
110
+ for (const bad of ['builders', 'builders:studio/r1', "x:y' OR 1=1", '']) {
111
+ const p2 = fakePool();
112
+ await G.readStudioWorks({ viewer: { id: 7 }, page: bad }, { pool: p2, exists: async () => true });
113
+ assert.ok(!p2.calls.some((c) => /starts_with/.test(c.sql)), `no easel read for ${JSON.stringify(bad)}`);
114
+ }
115
+ });
116
+
117
+ await test('with the hearts table absent there is nothing pinned and your works hang with no hearts', async () => {
118
+ const pool = fakePool({ table: false });
119
+ await G.readStudioWorks({ viewer: { id: 7 } }, { pool, exists: async () => true });
120
+ const reads = pool.calls.filter((c) => !/to_regclass/.test(c.sql));
121
+ assert.equal(reads.length, 1);
122
+ assert.doesNotMatch(reads[0].sql, /lifecycle_work_appreciations/);
123
+ });
124
+
125
+ await test('GET /home/studio/works shapes both shelves for the caller', async () => {
126
+ let asked = null;
127
+ const row = { id: '9', title: 'Tweak: Studio (builders:studio)', source: 'page-tweak', source_ref: 'page-tweak/builders:studio/r1', light: '/l', dark: '/d', artist_id: '7', display_name: 'Ana', hearts: 4, mine: true, total: 8, shipped_at: '2026-10-01T00:00:00Z' };
128
+ const h = G.studioWorksHandler({ read: async (a) => { asked = a; return { pinned: [row], yours: [], easel: row }; } });
129
+ const res = { set() { return res; }, json(b) { res.body = b; return res; } };
130
+ await h({ builder: { id: 7, rank: 'metic' }, query: { viewer: '1', page: 'builders:studio' } }, res);
131
+ assert.deepEqual(asked, { viewer: { id: 7, rank: 'metic' }, page: 'builders:studio' });
132
+ assert.equal(res.body.easel.page, 'builders:studio');
133
+ assert.equal(res.body.pinned.total, 8);
134
+ assert.equal(res.body.pinned.works[0].page, 'builders:studio', 'a tweak work knows its page, so the easel can find it');
135
+ assert.deepEqual(res.body.yours, { total: 0, works: [] });
136
+ });
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // 3. the room's words (pure)
140
+ // ---------------------------------------------------------------------------
141
+
142
+ await test('the easel says what is on it and the one way back in', () => {
143
+ assert.deepEqual(Lib.easelWords({ held: true, title: 'Studio', round: 2, place: 'The builders’ hall' }),
144
+ { title: 'Studio · round 2', line: 'Your page, waiting for your words · The builders’ hall.', act: 'Pick up where you left off' });
145
+ assert.equal(Lib.easelWords({ held: true, title: 'Studio', round: 1 }).title, 'Studio');
146
+ assert.deepEqual(Lib.easelWords({ held: false, title: 'Privacy', why: 'untweaked' }), { title: 'Nothing on your easel', line: 'Next up: Privacy, untweaked.', act: 'Begin Privacy' });
147
+ assert.equal(Lib.easelWords(null).act, null);
148
+ assert.equal(Lib.linesChanged(1), '1 line changed');
149
+ assert.equal(Lib.linesChanged(12), '12 lines changed');
150
+ });
151
+
152
+ await test('a work on a shelf links to its place on the bench, and the gallery reads that link back', () => {
153
+ const Gal = require(path.join(ROOT, 'modules/hall-ui/public/home-gallery.js'));
154
+ assert.equal(Lib.benchPath('1004387'), '/?work=1004387');
155
+ assert.equal(Lib.benchPath('1 OR 1'), '/');
156
+ assert.deepEqual(Gal.landing('?work=1004387'), { work: '1004387', all: false });
157
+ assert.deepEqual(Gal.landing('?gallery=all'), { work: null, all: true });
158
+ assert.deepEqual(Gal.landing('?work=abc&gallery=allx'), { work: null, all: false });
159
+ });
160
+
161
+ await test('a studio link goes only to the tweak editor or the copy desk', () => {
162
+ assert.equal(Lib.hallPath('/tweak-editor?page=builders%3Astudio'), '/tweak-editor?page=builders%3Astudio');
163
+ assert.equal(Lib.hallPath('/copy-desk'), '/copy-desk');
164
+ assert.equal(Lib.hallPath('https://evil.example/'), null);
165
+ assert.equal(Lib.hallPath('javascript:alert(1)'), null);
166
+ assert.equal(Lib.hallPath('/tweak-editor?page="><x>'), null);
167
+ });
168
+
169
+ summary();
@@ -0,0 +1,157 @@
1
+ // tests/home_studio_widget.mjs — the artist's studio, the real shelves in a browser
2
+ // (task 1005061, Home v16 step 4).
3
+ //
4
+ // Runs home-gallery.js (the label words and picture rule) and home-studio.js on a page
5
+ // that carries the /studio page's host, with the reads stubbed by the hall-preview
6
+ // fixtures: the shelves mount by themselves and ask the copy-desk read, then the works
7
+ // read for the easel's page; the easel shows that page as it hangs and links into the
8
+ // tweak editor; the waiting pages, the flagged words and both shelves of works are
9
+ // drawn, each work linking back to its place on the bench; the doors lead to the bench
10
+ // and the full gallery; each shelf's empty state says so in words; without the copy
11
+ // desk its shelves say so; at phone width nothing scrolls sideways. And the gallery
12
+ // arriving from those links: ?work=<id> sits you in front of it, ?gallery=all opens the
13
+ // salon. Skips where no system Edge or Chrome is installed.
14
+ //
15
+ // Run: node --test tests/home_studio_widget.mjs
16
+ import assert from 'node:assert/strict';
17
+ import { test, after } from 'node:test';
18
+ import { createRequire } from 'node:module';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
26
+ const pub = (f) => fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', f), 'utf8');
27
+ const fx = (f) => fs.readFileSync(path.join(ROOT, 'scripts', 'hall-preview', 'fixtures', f), 'utf8');
28
+ const CSS = pub('home-city.css') + pub('home-gallery.css') + pub('home-studio.css');
29
+ const GALLERY = fx('home__gallery.json');
30
+ const DESK = fx('copy-desk__studio.json');
31
+ const WORKS = fx('home__studio__works.json');
32
+ const EMPTY_DESK = JSON.stringify({ easel: null, waiting: { total: 0, pages: [] }, words: [] });
33
+ const EMPTY_WORKS = JSON.stringify({ pinned: { total: 0, works: [] }, yours: { total: 0, works: [] }, easel: null });
34
+
35
+ let browser = null;
36
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
37
+ after(() => browser && browser.close());
38
+ const skip = !browser && 'no system Edge or Chrome';
39
+
40
+ const stubs = ({ desk, works }) => `
41
+ window.__asked = [];
42
+ window.BongosClient = { createClient: () => ({ request: async (m, p) => {
43
+ window.__asked.push(m + ' ' + p);
44
+ if (p.startsWith('/api/bongos/home/gallery?')) return { ok: true, status: 200, data: ${GALLERY} };
45
+ if (p === '/api/bongos/copy-desk/studio') return ${desk ? `{ ok: true, status: 200, data: ${desk} }` : '{ ok: false, status: 404, data: null }'};
46
+ if (p.startsWith('/api/bongos/home/studio/works')) return { ok: true, status: 200, data: ${works} };
47
+ return { ok: false, status: 404, data: null };
48
+ } }) };`;
49
+
50
+ // The studio page is served at a real http origin so its links resolve like the hall's.
51
+ async function boot({ width = 1440, desk = DESK, works = WORKS } = {}) {
52
+ const ctx = await browser.newContext({ viewport: { width, height: 900 }, reducedMotion: 'reduce' });
53
+ const page = await ctx.newPage();
54
+ await page.route('http://hall.test/**', (r) => r.fulfill({ contentType: 'text/html', body: `<!doctype html><html data-theme="dark"><head><style>${CSS}</style></head><body><section class="hs-page" id="studio-shelves"></section></body></html>` }));
55
+ await page.goto('http://hall.test/studio');
56
+ await page.addScriptTag({ content: stubs({ desk, works }) });
57
+ await page.addScriptTag({ content: pub('home-gallery.js') });
58
+ await page.addScriptTag({ content: pub('home-studio.js') });
59
+ await page.waitForFunction(() => document.querySelector('.hs-pinned') && document.querySelector('.hs-pinned').children.length > 0);
60
+ return { ctx, page };
61
+ }
62
+
63
+ const room = (page) => page.evaluate(() => ({
64
+ asked: window.__asked.slice(),
65
+ easel: document.querySelector('.hs-what b').textContent,
66
+ pill: [document.querySelector('.hs-pill').textContent, document.querySelector('.hs-pill').getAttribute('href'), document.querySelector('.hs-pill').hidden],
67
+ art: (document.querySelector('.hs-art img') || {}).src || null,
68
+ waiting: [...document.querySelectorAll('.hs-waiting .hs-row')].map((r) => [r.querySelector('b').textContent, r.querySelector('.hs-dot').textContent, r.getAttribute('href')]),
69
+ words: [...document.querySelectorAll('.hs-words .hs-row q')].map((q) => q.textContent),
70
+ pinned: [...document.querySelectorAll('.hs-pinned .hs-work')].map((b) => [b.querySelector('.hs-sub').textContent, b.getAttribute('href')]),
71
+ yours: [...document.querySelectorAll('.hs-yours .hs-work')].map((b) => b.querySelector('.hs-sub').textContent),
72
+ quiet: [...document.querySelectorAll('.hs-quiet')].map((p) => p.textContent),
73
+ doors: [...document.querySelectorAll('.hs-ways a')].map((a) => a.getAttribute('href')),
74
+ }));
75
+
76
+ test('the shelves mount on the studio page: the copy-desk read, then the works read for the easel\'s page', { skip }, async () => {
77
+ const { ctx, page } = await boot();
78
+ const s = await room(page);
79
+ const desk = JSON.parse(DESK), works = JSON.parse(WORKS);
80
+ await ctx.close();
81
+ assert.deepEqual(s.asked, ['GET /api/bongos/copy-desk/studio', `GET /api/bongos/home/studio/works?page=${encodeURIComponent(desk.easel.page_id)}`]);
82
+ assert.equal(s.easel, `${desk.easel.title} · round ${desk.easel.round}`);
83
+ assert.deepEqual(s.pill, ['Pick up where you left off', desk.easel.open, false]);
84
+ assert.match(s.art || '', new RegExp(works.easel.art.dark.replace(/[.?]/g, '\\$&') + '$'), 'the easel shows the page as it hangs on the wall');
85
+ assert.deepEqual(s.waiting.map((w) => w[0]), desk.waiting.pages.map((p) => p.title));
86
+ assert.equal(s.waiting[0][1], String(desk.waiting.pages[0].changed_lines));
87
+ assert.equal(s.waiting[0][2], desk.waiting.pages[0].open, 'each waiting page opens where its words are worked');
88
+ assert.deepEqual(s.words, desk.words.map((w) => w.text));
89
+ assert.deepEqual(s.pinned.map((p) => p[0]), works.pinned.works.map(() => 'You appreciated this'));
90
+ assert.deepEqual(s.pinned.map((p) => p[1]), works.pinned.works.map((w) => `/?work=${w.id}`), 'a pinned work links to its place on the bench');
91
+ assert.deepEqual(s.yours, works.yours.works.map((w) => String(w.hearts)), 'your works carry their heart counts');
92
+ assert.deepEqual(s.doors, ['/', '/?gallery=all']);
93
+ });
94
+
95
+ test('every shelf says in words when it is empty, and an empty easel offers no way in', { skip }, async () => {
96
+ const { ctx, page } = await boot({ desk: EMPTY_DESK, works: EMPTY_WORKS });
97
+ const s = await room(page);
98
+ await ctx.close();
99
+ assert.deepEqual(s.asked, ['GET /api/bongos/copy-desk/studio', 'GET /api/bongos/home/studio/works'], 'no easel page, no page asked for');
100
+ assert.equal(s.easel, 'Nothing on your easel');
101
+ assert.equal(s.pill[2], true);
102
+ assert.equal(s.art, null);
103
+ assert.deepEqual(s.quiet, [
104
+ 'Nothing is waiting. Every page’s words match its last design.',
105
+ 'No words are flagged. The copy desk is quiet.',
106
+ 'Nothing pinned yet. A heart on the bench pins a work here.',
107
+ 'Nothing of yours hangs in the collection yet.',
108
+ ]);
109
+ });
110
+
111
+ test('without the copy desk its two shelves say so, and the works still hang', { skip }, async () => {
112
+ const { ctx, page } = await boot({ desk: null });
113
+ const s = await room(page);
114
+ const desk = await page.evaluate(() => document.querySelector('.hs-desk').hidden);
115
+ await ctx.close();
116
+ assert.equal(s.easel, 'Nothing on your easel');
117
+ assert.deepEqual(s.quiet, ['The copy desk is not switched on in this project.', 'The copy desk is not switched on in this project.']);
118
+ assert.equal(desk, true);
119
+ assert.ok(s.yours.length > 0);
120
+ });
121
+
122
+ test('at phone width the studio fits: nothing scrolls sideways and every control clears 24px', { skip }, async () => {
123
+ const { ctx, page } = await boot({ width: 390 });
124
+ const m = await page.evaluate(() => ({
125
+ over: document.documentElement.scrollWidth - document.documentElement.clientWidth,
126
+ small: [...document.querySelectorAll('.hs button, .hs a')].filter((e) => !e.hidden && e.getBoundingClientRect().width > 0).map((e) => { const r = e.getBoundingClientRect(); return [e.className, Math.round(r.width), Math.round(r.height)]; }).filter((x) => x[1] < 24 || x[2] < 24),
127
+ }));
128
+ await ctx.close();
129
+ assert.equal(m.over, 0);
130
+ assert.deepEqual(m.small, []);
131
+ });
132
+
133
+ // ---- the gallery, arriving from the studio's links ----
134
+ async function arrive(search) {
135
+ const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, reducedMotion: 'reduce' });
136
+ const page = await ctx.newPage();
137
+ await page.route('http://hall.test/**', (r) => r.fulfill({ contentType: 'text/html', body: `<!doctype html><html data-theme="dark"><head><style>${CSS}</style></head><body><section id="home-city" class="scroll home-city hc--gallery"></section></body></html>` }));
138
+ await page.goto('http://hall.test/' + search);
139
+ await page.addScriptTag({ content: stubs({ desk: DESK, works: WORKS }) });
140
+ await page.addScriptTag({ content: pub('home-gallery.js') });
141
+ await page.evaluate(() => window.OTBHomeGallery.attach(document.getElementById('home-city')).show(true));
142
+ await page.waitForFunction(() => document.querySelectorAll('.hg-hang').length > 0);
143
+ const out = await page.evaluate(() => ({ count: document.querySelector('.hg-count').textContent, salon: !document.querySelector('.hg-salon').hidden, url: location.pathname + location.search }));
144
+ await ctx.close();
145
+ return out;
146
+ }
147
+
148
+ test('the gallery opens where the studio sent you: a work on the bench, or the full gallery; then the address is plain again', { skip }, async () => {
149
+ const works = JSON.parse(GALLERY).works;
150
+ const id = works[4].id;
151
+ const sat = await arrive(`?work=${id}`);
152
+ assert.deepEqual(sat, { count: `5 of ${works.length}`, salon: false, url: '/' });
153
+ const all = await arrive('?gallery=all');
154
+ assert.deepEqual(all, { count: `1 of ${works.length}`, salon: true, url: '/' });
155
+ const plain = await arrive('');
156
+ assert.deepEqual(plain, { count: `1 of ${works.length}`, salon: false, url: '/' });
157
+ });