@bongos/core 1.21.163 → 1.21.165

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 (41) hide show
  1. package/.bongos-core.json +60 -45
  2. package/.gitattributes +6 -0
  3. package/docs/copy-inventory.md +127 -127
  4. package/docs/copy-registry.json +205 -185
  5. package/docs/module-api-changelog.md +4 -0
  6. package/docs/page-readings.json +297 -462
  7. package/modules/copy-desk/page-status.js +3 -0
  8. package/modules/copy-desk/pages.js +5 -0
  9. package/modules/hall-ui/public/approval-queue-lib.js +4 -0
  10. package/modules/hall-ui/public/approval-queue.css +2 -0
  11. package/modules/hall-ui/public/approval-queue.js +1 -0
  12. package/modules/hall-ui/public/builders.js +6 -4
  13. package/modules/hall-ui/public/craft-mode.js +32 -158
  14. package/modules/hall-ui/public/craft-review.css +7 -120
  15. package/modules/hall-ui/public/goal-inbox.js +100 -7
  16. package/modules/hall-ui/public/goals.js +2 -4
  17. package/modules/hall-ui/public/hall-render.js +39 -9
  18. package/modules/hall-ui/public/hall-widgets.js +20 -10
  19. package/modules/hall-ui/public/index.html +7 -0
  20. package/modules/hall-ui/public/page-notes.css +64 -1
  21. package/modules/hall-ui/public/page-notes.js +96 -11
  22. package/modules/hall-ui/public/style.css +95 -0
  23. package/modules/hall-ui/public/task-detail.js +8 -0
  24. package/modules/hall-ui/records/home-reshuffle.md +31 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +20 -0
  28. package/scripts/gds/copy-apply.js +65 -2
  29. package/scripts/gds/git-merge-regen.js +14 -0
  30. package/scripts/gds/page-reader.js +46 -0
  31. package/src/module-api.js +1 -1
  32. package/tests/conflict_resolve.mjs +6 -0
  33. package/tests/craft_review_mode.mjs +21 -104
  34. package/tests/hall_approval_queue.mjs +16 -0
  35. package/tests/hall_board_world.mjs +7 -6
  36. package/tests/home_attention_cards.mjs +119 -0
  37. package/tests/home_bounded_reads.mjs +1 -1
  38. package/tests/page_notes_panel.mjs +161 -27
  39. package/tests/promote_goal_id.mjs +8 -0
  40. package/tests/tweak_batch_apply.mjs +40 -0
  41. package/tests/tweak_land_heal.mjs +139 -0
@@ -380,4 +380,20 @@ await test('the dialog fits the viewport at every width: capped by the screen, t
380
380
  assert.match(rule('.aq-line__text'), /overflow-wrap: anywhere/, 'a long word breaks inside its column');
381
381
  });
382
382
 
383
+ // task 1004775: a test the applier found still asserting a line's old words is
384
+ // shown on that line, so the artist knows before approving, not CI after.
385
+ await test('a line whose old words a test still checks says so, through the real composer', () => {
386
+ const description = [
387
+ pages.composeBatchBlock({ page_id: 'landing:index', reading_hash: 'h', submitted_at: '2026-09-19T00:00:00Z', lines: [
388
+ { key: 'L0003', section: 'hero', before: 'AI agents claim tasks and ship the code.', after: 'Agents write the code.', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' },
389
+ ] }),
390
+ pages.composeAppliedBlock({ page_id: 'landing:index', applied_at: '2026-09-21T00:00:00Z', reading_hash_after: 'h2', lines_after: ['Agents write the code.'], refused: [], pinned_tests: [{ key: 'L0003', tests: ['tests/landing_page.mjs'] }] }),
391
+ ].join('\n\n');
392
+ const tasks = [{ id: 51, status: 'completed', grade_passed: true, description, source_ref: pages.roundSourceRef('landing:index', 1), holder_id: null, artist_id: ME, artist_login: 'me' }];
393
+ const q = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'] });
394
+ const line = q.queue[0].lines[0];
395
+ assert.deepEqual(line.pinned_by, ['tests/landing_page.mjs']);
396
+ assert.equal(lib.lineRow(line, 'after').pinned, 'A test still checks the old words: tests/landing_page.mjs. It needs updating when this ships.');
397
+ });
398
+
383
399
  summary();
@@ -266,7 +266,7 @@ test('the Standing card is the mock\'s: an identity line and a three-row fact le
266
266
  assert.match(block[0], /class="home-who"/, 'the identity line');
267
267
  assert.match(block[0], /<dt>\$\{escapeHtml\(curLabel\(true\)\)\}<\/dt>/,
268
268
  'the currency row takes its label from the branding contract at render time — a data-currency-label attribute is rebranded once at load, before this row exists');
269
- for (const label of ['On the board', 'Preferred work']) {
269
+ for (const label of ['Leaderboard place', 'Preferred work']) {
270
270
  assert.match(block[0], new RegExp(`<dt>${label}</dt>`), `the ledger row: ${label}`);
271
271
  }
272
272
  assert.match(block[0], /: PREFS_EMPTY\}/, 'the empty-preference copy is the one string both Standing surfaces share');
@@ -278,7 +278,8 @@ test('the Standing card is the mock\'s: an identity line and a three-row fact le
278
278
  });
279
279
 
280
280
  test('the system notices are inbox LINES, first in the card, and counted by its refresh()', () => {
281
- const mount = goalInboxJs.match(/host\.innerHTML =([\s\S]*?);/);
281
+ // Anchored on the statement AFTER the markup, so a ';' inside the markup or its comments cannot cut it short.
282
+ const mount = goalInboxJs.match(/host\.innerHTML =([\s\S]*?);\n host\.addEventListener/);
282
283
  assert.ok(mount, 'the inbox mounts its slices');
283
284
  const sys = mount[1].indexOf('id="attention-system"');
284
285
  const goals = mount[1].indexOf('id="attention-goals"');
@@ -378,7 +379,7 @@ test('RUN: the greeting names the builder, and the sub-line says how far the ver
378
379
  const { api, el } = bootHallRender({ me: ME, progress: PROGRESS, claimable: 5 });
379
380
  api.renderGreeting();
380
381
  assert.match(el('hall-h1').textContent, /^Good (morning|afternoon|evening), Percy Jackson$/);
381
- assert.equal(el('hall-sub').textContent, 'BONGOS-V1 is 75 percent shipped. Five tasks are claimable right now.');
382
+ assert.equal(el('hall-sub').textContent, 'BONGOS-V1 is 75 percent complete. Five tasks are claimable right now.');
382
383
  assert.equal(el('hall-sub').hidden, false);
383
384
  });
384
385
 
@@ -388,7 +389,7 @@ test('RUN: each half of the sentence renders as its data lands, and the line hid
388
389
  assert.equal(none.el('hall-sub').hidden, true, 'nothing to say yet');
389
390
  const onlyProgress = bootHallRender({ me: ME, progress: PROGRESS });
390
391
  onlyProgress.api.renderGreeting();
391
- assert.equal(onlyProgress.el('hall-sub').textContent, 'BONGOS-V1 is 75 percent shipped.');
392
+ assert.equal(onlyProgress.el('hall-sub').textContent, 'BONGOS-V1 is 75 percent complete.');
392
393
  const zero = bootHallRender({ me: ME, progress: PROGRESS, claimable: 0 });
393
394
  zero.api.renderGreeting();
394
395
  assert.match(zero.el('hall-sub').textContent, /Nothing is claimable right now\.$/);
@@ -409,13 +410,13 @@ test('RUN: the Standing card carries the three facts, the place as "2nd of 4", a
409
410
  // written from curLabel(true), not a data-currency-label attribute: brand-apply
410
411
  // rebrands those once at load, and this row is stamped after /me answers
411
412
  assert.match(html, /<dt>Credits<\/dt><dd>14,441<\/dd>/);
412
- assert.match(html, /<dt>On the board<\/dt><dd>2nd of 4<\/dd>/);
413
+ assert.match(html, /<dt>Leaderboard place<\/dt><dd>2nd of 4<\/dd>/);
413
414
  assert.match(html, /<dt>Preferred work<\/dt><dd>Not set yet<\/dd>/, 'the mock\'s copy for an empty preference');
414
415
  assert.doesNotMatch(html, /profile__(needs|budget|rebase)/, 'the banners are not in the card');
415
416
  const withPrefs = bootHallRender({ me: ME, leaderboard: [] });
416
417
  withPrefs.api.renderProfile({ ...ME.builder, preferred_disciplines: ['engineer', 'ui'] });
417
418
  assert.match(withPrefs.el('home-standing-body').innerHTML, /<dd>engineer, ui<\/dd>/);
418
- assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>On the board<\/dt><dd>—<\/dd>/, 'no roll yet: a dash, not a wrong place');
419
+ assert.match(withPrefs.el('home-standing-body').innerHTML, /<dt>Leaderboard place<\/dt><dd>—<\/dd>/, 'no roll yet: a dash, not a wrong place');
419
420
  });
420
421
 
421
422
  test('RUN: the three notices are inbox lines with a kind, a line and — where one exists — a door', () => {
@@ -0,0 +1,119 @@
1
+ // tests/home_attention_cards.mjs — the Needs-attention CARDS and their window (task 1004774).
2
+ //
3
+ // WHY. Home's attention inbox became one card per slice, and a click moves the slice itself into a
4
+ // <dialog> so its buttons keep working through the one delegated handler. That is DOM behaviour —
5
+ // moving nodes, the native dialog's open/close, a card derived from a heading — so it runs the REAL
6
+ // goal-inbox.js in a real browser (the ui-design kit's launcher) over stubbed feeds, rather than
7
+ // grepping the source. Skips when the machine has no system Edge or Chrome, as ui_design_kit_fit does.
8
+ import assert from 'node:assert/strict';
9
+ import { test, after } from 'node:test';
10
+ import { createRequire } from 'node:module';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ const require = createRequire(import.meta.url);
16
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
+ const lib = require(path.join(ROOT, 'modules', 'ui-design', 'kit', 'lib.js'));
18
+ const GOAL_INBOX = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'goal-inbox.js'), 'utf8');
19
+
20
+ let browser = null;
21
+ try { browser = await lib.launchBrowser(); } catch (e) { if (!(e instanceof lib.NoBrowserError)) throw e; }
22
+ after(() => browser && browser.close());
23
+
24
+ // Two feeds answer: the blockers queue (2 rows shown of a server total of 7) and the rot feed
25
+ // (1 row, with its timer). Every other feed 404s, which the inbox treats as "nothing here".
26
+ const STUBS = `
27
+ window.__FEEDS = {
28
+ '/api/bongos/blockers?limit=5': { blockers: [{ id: 1, title: 'Key needed' }, { id: 2, title: 'Pick a host' }], page: { total: 7 } },
29
+ '/api/bongos/inbox/rotting': { timer: { rot_days: 30 }, rows: [{ tier: 'task', id: 9, title: 'Old task', status: 'ready', goal_id: 3,
30
+ verbs: [{ verb: 'water', label: 'Water', path: '/api/bongos/tasks/:id/water' }] }] },
31
+ };
32
+ window.BongosClient = { createClient: () => ({ request: async (m, p) => {
33
+ const d = window.__FEEDS[p];
34
+ return d ? { ok: true, status: 200, data: d } : { ok: false, status: 404, data: null };
35
+ } }) };
36
+ window.OTBKit = { esc: (s) => String(s), fmtAbsDate: () => 'Oct 1' };
37
+ window.OTBBlockersLib = { blockerFacts: (b) => ({ titleHtml: '<a class="ginbox__goal">' + b.title + '</a>', factsHtml: '', noteHtml: '' }) };
38
+ window.OTB = {
39
+ escapeHtml: (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'),
40
+ toast() {}, rankLabel: (r) => r,
41
+ contributeHallWidget(w) { window.__widget = w; },
42
+ };`;
43
+
44
+ const PAGE = '<!doctype html><html><body><section id="attention-scroll"></section></body></html>';
45
+
46
+ async function bootInbox() {
47
+ const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
48
+ const page = await ctx.newPage();
49
+ await page.setContent(PAGE);
50
+ await page.addScriptTag({ content: STUBS });
51
+ await page.addScriptTag({ content: GOAL_INBOX });
52
+ await page.evaluate(() => window.__widget.mount(document.getElementById('attention-scroll')));
53
+ await page.waitForFunction(() => document.querySelectorAll('.attn-card').length === 2);
54
+ return { ctx, page };
55
+ }
56
+
57
+ const cards = (page) => page.evaluate(() => [...document.querySelectorAll('.attn-card')].map((c) => ({
58
+ open: c.getAttribute('data-attn-open'),
59
+ n: c.querySelector('.attn-card__n').textContent,
60
+ t: c.querySelector('.attn-card__t').textContent,
61
+ s: c.querySelector('.attn-card__s') ? c.querySelector('.attn-card__s').textContent : null,
62
+ })));
63
+
64
+ test('one card per non-empty slice, titled from its heading, carrying the SERVER total', { skip: !browser && 'no system Edge or Chrome' }, async () => {
65
+ const { ctx, page } = await bootInbox();
66
+ const got = await cards(page);
67
+ await ctx.close();
68
+ // Slice order is the card order: blockers sit before rot in the mount.
69
+ assert.deepEqual(got, [
70
+ { open: 'attention-blockers', n: '7', t: 'Requires project owner fix', s: null },
71
+ { open: 'attention-rot', n: '1', t: 'Things not moving', s: 'untouched 30+ days' },
72
+ ], 'the blockers card says 7 (page.total), not the 2 rows the slice drew; empty slices get no card');
73
+ });
74
+
75
+ test('a card opens its slice in the window, and the X puts it back', { skip: !browser && 'no system Edge or Chrome' }, async () => {
76
+ const { ctx, page } = await bootInbox();
77
+ await page.click('[data-attn-open="attention-rot"]');
78
+ const opened = await page.evaluate(() => ({
79
+ open: document.getElementById('attention-dialog').open,
80
+ title: document.getElementById('attention-dialog-h').textContent,
81
+ inDialog: !!document.querySelector('#attention-dialog-body > #attention-rot'),
82
+ verb: !!document.querySelector('#attention-dialog [data-rot-verb="water"]'),
83
+ }));
84
+ assert.deepEqual(opened, { open: true, title: 'Things not moving', inDialog: true, verb: true },
85
+ 'the SAME slice node moved into the dialog, with its action buttons');
86
+ await page.click('#attention-dialog-x');
87
+ const closed = await page.evaluate(() => ({
88
+ open: document.getElementById('attention-dialog').open,
89
+ back: !!document.querySelector('#attention-slices > #attention-rot'),
90
+ cards: document.querySelectorAll('.attn-card').length,
91
+ }));
92
+ await ctx.close();
93
+ assert.deepEqual(closed, { open: false, back: true, cards: 2 });
94
+ });
95
+
96
+ test('closing natively (Escape) also returns the slice, so a reopen still finds it', { skip: !browser && 'no system Edge or Chrome' }, async () => {
97
+ const { ctx, page } = await bootInbox();
98
+ await page.click('[data-attn-open="attention-blockers"]');
99
+ await page.keyboard.press('Escape');
100
+ await page.waitForFunction(() => !document.getElementById('attention-dialog').open);
101
+ const back = await page.evaluate(() => !!document.querySelector('#attention-slices > #attention-blockers'));
102
+ assert.equal(back, true, 'the dialog close event moves the slice home');
103
+ await page.click('[data-attn-open="attention-blockers"]');
104
+ const rows = await page.evaluate(() => document.querySelectorAll('#attention-dialog-body .ginbox__item').length);
105
+ await ctx.close();
106
+ assert.equal(rows, 2, 'the reopened window shows the slice rows again');
107
+ });
108
+
109
+ test('a window whose slice empties while open closes itself', { skip: !browser && 'no system Edge or Chrome' }, async () => {
110
+ const { ctx, page } = await bootInbox();
111
+ await page.click('[data-attn-open="attention-rot"]');
112
+ await page.evaluate(() => { document.getElementById('attention-rot').innerHTML = ''; window.OTBAttention.refresh(); });
113
+ const state = await page.evaluate(() => ({
114
+ open: document.getElementById('attention-dialog').open,
115
+ cards: [...document.querySelectorAll('.attn-card')].map((c) => c.getAttribute('data-attn-open')),
116
+ }));
117
+ await ctx.close();
118
+ assert.deepEqual(state, { open: false, cards: ['attention-blockers'] });
119
+ });
@@ -60,7 +60,7 @@ await test('no Home read downloads a feed and slices it in the browser', () => {
60
60
  });
61
61
 
62
62
  await test('the two cards ask for exactly the rows they draw', () => {
63
- assert.match(BUILDERS_JS, /\$\{API\}\/tasks\/claimable\?limit=2/, 'Next up draws two rows');
63
+ assert.match(BUILDERS_JS, /\$\{API\}\/tasks\/claimable\?limit=5/, 'Next up draws five rows, level with Recently shipped');
64
64
  assert.match(BUILDERS_JS, /\$\{API\}\/inbox\?limit=5/, 'the triage card draws five');
65
65
  // …and no longer re-slices what came back.
66
66
  const src = noComments(BUILDERS_JS);
@@ -1,10 +1,10 @@
1
1
  // tests/page_notes_panel.mjs — the hall side of the artist's page feedback doc
2
- // (task 1004496): the palette tool craft-mode.js puts in the top bar, and the
3
- // reader in page-notes.js. The server half is tests/page_notes.mjs.
2
+ // (task 1004496): the docked review panel craft-mode.js puts at the right edge
3
+ // in Artistry (task 1004771: no top-bar button), and the reader in page-notes.js. The server half is tests/page_notes.mjs.
4
4
  //
5
- // Runs the REAL craft-mode.js over a small DOM: the palette shows in Artistry
6
- // alone and leaves when the mode changes, pressing it loads page-notes.js once
7
- // (same ?v as craft-mode.js) and a ?note= link opens the doc in any mode. Then
5
+ // Runs the REAL craft-mode.js over a small DOM: the panel docks in Artistry
6
+ // alone and closes when the mode changes, page-notes.js loads once (same ?v as
7
+ // craft-mode.js) and a ?note= link opens the doc in any mode. Then
8
8
  // the REAL page-notes.js reader: it escapes the doc and shows only this module's
9
9
  // own images, which is what keeps a doc from pulling a picture off another site.
10
10
  import assert from 'node:assert/strict';
@@ -63,7 +63,7 @@ function boot({ session = {}, search = '' } = {}) {
63
63
  // "Loading" page-notes.js: record the tag, then define the API it would define.
64
64
  head.onAppend = (tag) => {
65
65
  loaded.push(tag.src);
66
- window.OTBPageNotes = { open: () => opened.push('open'), openNote: (id) => opened.push('note ' + id) };
66
+ window.OTBPageNotes = { open: () => opened.push('open'), openNote: (id) => opened.push('note ' + id), dock: () => opened.push('dock'), close: () => opened.push('close') };
67
67
  tag.onload();
68
68
  };
69
69
  const sandbox = { window, document, Promise, CustomEvent: class { constructor(t, i) { this.type = t; Object.assign(this, i); } } };
@@ -74,36 +74,31 @@ function boot({ session = {}, search = '' } = {}) {
74
74
  }
75
75
  const flush = () => new Promise((r) => setTimeout(r, 0));
76
76
 
77
- test('THE PALETTE: in the top bar in Artistry alone, right before the switch, and gone when the mode changes', () => {
77
+ test('THE PANEL: docked at the edge in Artistry alone, with no top-bar button, and closed when the mode changes', async () => {
78
78
  const p = boot({ session: { 'hall-craft': 'artistry' } });
79
- assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-palette', 'craft-tool-paintbrush'], 'the palette, then the paintbrush (task 1004497)');
80
- // Task 1004686: the palette sits in its split pill (Review + the review-mode chevron).
81
- const pill = p.tools()[0].parentNode;
82
- assert.equal(pill.id, 'craft-review', 'the palette rides the Review split pill');
83
- assert.equal(pill.nextSibling, p.tools()[1], 'the palette comes first');
84
- assert.equal(p.tools()[1].nextSibling, p.sw, 'and the pair sits right before the mode switch');
85
- assert.equal(p.tools()[0].getAttribute('aria-label'), 'Artist review: write feedback on this page', 'one name with the public site\'s button (task 1004499)');
86
- for (const m of ['building', 'governing']) {
87
- p.craft.choose(m);
88
- assert.equal(p.tools().length, 0, `no palette in ${m}`);
89
- }
79
+ await flush();
80
+ assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-paintbrush'], 'the top bar keeps Edit words alone (task 1004771)');
81
+ assert.equal(p.tools()[0].nextSibling, p.sw, 'right before the mode switch');
82
+ assert.deepEqual(p.opened, ['dock'], 'the review panel docks instead');
83
+ for (const m of ['building', 'governing']) p.craft.choose(m);
84
+ assert.deepEqual(p.opened, ['dock', 'close', 'close'], 'and leaves with Artistry');
90
85
  p.craft.choose('ideating');
91
- assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-lightbulb'], 'Ideating swaps them for its lightbulb alone (task 1004498)');
92
- p.craft.choose('artistry');
86
+ assert.deepEqual(p.tools().map((t) => t.id), ['craft-tool-lightbulb'], 'Ideating shows its lightbulb alone (task 1004498)');
93
87
  p.craft.choose('artistry');
94
- assert.equal(p.tools().length, 2, 'choosing Artistry twice draws one palette and one paintbrush');
95
- assert.equal(boot().tools().length, 0, 'and none with no mode at all');
88
+ await flush();
89
+ assert.equal(p.opened.filter((x) => x === 'dock').length, 2, 'back in Artistry it docks again');
90
+ const none = boot();
91
+ await flush();
92
+ assert.equal(none.tools().length, 0, 'no tools with no mode at all');
93
+ assert.deepEqual(none.loaded, [], 'and nothing loads');
96
94
  });
97
95
 
98
- test('pressing it loads page-notes.js once, at the same version as craft-mode.js, and opens it', async () => {
96
+ test('the panel script loads once, at the same version as craft-mode.js', async () => {
99
97
  const p = boot({ session: { 'hall-craft': 'artistry' } });
100
- assert.deepEqual(p.loaded, [], 'nothing loads until the palette is pressed');
101
- p.tools()[0].click();
102
98
  await flush();
103
- p.tools()[0].click();
99
+ p.craft.choose('artistry');
104
100
  await flush();
105
101
  assert.deepEqual(p.loaded, ['/builders/page-notes.js?v=2026-10-04-test']);
106
- assert.deepEqual(p.opened, ['open', 'open']);
107
102
  });
108
103
 
109
104
  test('a ?note= link opens that doc on arrival, in any mode', async () => {
@@ -152,6 +147,145 @@ test('colours live in the sheets, and the panel and tool are styled there', () =
152
147
  // the hall pulls it in through style.css, ahead of every rule.
153
148
  const pn = fs.readFileSync(path.join(HALL, 'page-notes.css'), 'utf8');
154
149
  for (const sel of ['.pn {', '.pn__panel {', '.pn__text {', '.pn-doc__img {']) assert.ok(pn.includes(sel) && !css.includes(sel), sel);
150
+ // Task 1004771: the docked, scrimless look is the review panel's alone
151
+ // (.pn--dock). The lightbulb rides plain .pn and keeps its scrim and its
152
+ // scroll lock, so docking the review panel cannot quietly change it.
153
+ assert.match(pn, /html\.pn-open \{ overflow: hidden; \}/, 'the lightbulb keeps its scroll lock');
154
+ assert.match(pn, /\.pn \{[^}]*inset: 0;[^}]*background: var\(--pn-scrim\);/, 'and its scrim');
155
+ assert.match(pn, /\.pn\.pn--dock \{[^}]*background: none;/, 'the docked review panel drops both');
156
+ assert.match(NOTES, /sheet\.className = docked \? 'pn pn--dock' : 'pn';/, 'and only a docked review panel wears it');
157
+ const bulb = fs.readFileSync(path.join(HALL, 'lightbulb.js'), 'utf8');
158
+ assert.doesNotMatch(bulb, /pn--dock/, 'the lightbulb does not');
155
159
  const at = css.indexOf('@import url("page-notes.css");');
156
160
  assert.ok(at !== -1 && at < css.indexOf('{'), 'style.css imports the sheet before any rule');
157
161
  });
162
+
163
+ // ---------- the docked panel, REAL page-notes.js (task 1004771) ----------
164
+ // A small DOM that runs the real sheet: querySelector hands back one cached
165
+ // fake per selector, so the tab, the X and the mode row are real listeners.
166
+ function dockedPanel({ reviewing = null, opened = false } = {}) {
167
+ const calls = [];
168
+ let sheet = null;
169
+ const fake = (name) => {
170
+ const attrs = {}, listeners = {}, classes = new Set();
171
+ const kids = {};
172
+ const el = {
173
+ name, innerHTML: '', parentNode: null, id: '', className: '',
174
+ setAttribute(k, v) { attrs[k] = String(v); }, getAttribute(k) { return k in attrs ? attrs[k] : null; },
175
+ hasAttribute(k) { return k in attrs; }, removeAttribute(k) { delete attrs[k]; },
176
+ classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c),
177
+ toggle: (c, on) => { if (on === undefined) on = !classes.has(c); if (on) classes.add(c); else classes.delete(c); return on; } },
178
+ addEventListener(t, fn) { (listeners[t] ||= []).push(fn); },
179
+ fire(t, ev = {}) { (listeners[t] || []).forEach((fn) => fn({ target: el, preventDefault() {}, ...ev })); },
180
+ querySelector(sel) { return (kids[sel] ||= Object.assign(fake(sel), { parentNode: el })); },
181
+ querySelectorAll() { return []; },
182
+ contains(n) { for (let x = n; x; x = x.parentNode) if (x === el) return true; return false; },
183
+ focus() { document.activeElement = el; },
184
+ };
185
+ return el;
186
+ };
187
+ const body = fake('body');
188
+ body.appendChild = (c) => { c.parentNode = body; return c; };
189
+ body.removeChild = (c) => { c.parentNode = null; return c; };
190
+ body.dataset = { page: 'studio', pageTitle: 'Studio' };
191
+ const docKeys = [];
192
+ const document = {
193
+ body, activeElement: null, title: 'Studio — hall', documentElement: fake('html'),
194
+ createElement: () => (sheet = fake('div')),
195
+ addEventListener: (t, fn) => { if (t === 'keydown') docKeys.push(fn); },
196
+ removeEventListener() {},
197
+ };
198
+ const fetch = (url, opts) => {
199
+ calls.push(opts.method + ' ' + url);
200
+ return Promise.resolve({ ok: true, json: () => Promise.resolve({ note: { id: 9, status: 'draft', body_md: '' }, images: [], notes: [] }) });
201
+ };
202
+ const craft = {
203
+ reviewing: () => reviewing,
204
+ review: (id) => { reviewing = id; calls.push('review ' + id); },
205
+ stopReview: () => { reviewing = null; calls.push('stop'); },
206
+ experiences: () => [{ id: 'ideating', label: 'Ideator experience', short: 'Ideating' }, { id: 'building', label: 'Building experience', short: 'Building' }],
207
+ iconHtml: () => '<svg></svg>',
208
+ };
209
+ const window = { location: { pathname: '/studio' }, OTBCraftMode: craft };
210
+ const sandbox = { window, document, fetch, setTimeout, clearTimeout, Promise };
211
+ vm.createContext(sandbox);
212
+ vm.runInContext(NOTES, sandbox, { filename: 'page-notes.js' });
213
+ const api = window.OTBPageNotes;
214
+ if (opened) api.open(); else api.dock();
215
+ const press = (attr, value = '') => {
216
+ const btn = fake('button');
217
+ btn.setAttribute(attr, value);
218
+ sheet.fire('click', { target: { closest: () => btn } });
219
+ };
220
+ return { api, calls, document, docKeys, body, press, html: document.documentElement, get sheet() { return sheet; }, collapsed: () => sheet.classList.contains('pn--collapsed') };
221
+ }
222
+
223
+ test('DOCKED: it waits collapsed behind its tab and asks for no draft until it is first shown', async () => {
224
+ const p = dockedPanel();
225
+ await flush();
226
+ assert.ok(p.collapsed(), 'tucked away on arrival');
227
+ assert.equal(p.sheet.parentNode, p.body, 'but on the page, so its tab shows');
228
+ assert.deepEqual(p.calls, [], 'no request while nobody has looked');
229
+ p.sheet.querySelector('[data-pn-tab]').fire('pointerenter', { pointerType: 'mouse' });
230
+ await flush();
231
+ assert.ok(!p.collapsed(), 'a mouse on the tab opens it');
232
+ assert.ok(p.calls.some((c) => /^POST /.test(c)), 'and the draft opens the first time it is shown');
233
+ const drafts = p.calls.filter((c) => /^POST /.test(c)).length;
234
+ p.sheet.fire('pointerleave', { pointerType: 'mouse' });
235
+ assert.ok(p.collapsed(), 'leaving the panel for the page tucks it away');
236
+ p.sheet.querySelector('[data-pn-tab]').fire('pointerenter', { pointerType: 'mouse' });
237
+ await flush();
238
+ assert.equal(p.calls.filter((c) => /^POST /.test(c)).length, drafts, 'showing it again opens no second draft');
239
+ });
240
+
241
+ test('DOCKED: a touch never peeks, the tab click toggles, Escape and the X tuck it away and keep the tab', async () => {
242
+ const p = dockedPanel();
243
+ p.sheet.querySelector('[data-pn-tab]').fire('pointerenter', { pointerType: 'touch' });
244
+ assert.ok(p.collapsed(), 'a touch is not a hover');
245
+ p.press('data-pn-tab');
246
+ await flush();
247
+ assert.ok(!p.collapsed(), 'the tab click opens it (touch and keyboard)');
248
+ assert.equal(p.sheet.querySelector('[data-pn-tab]').getAttribute('aria-expanded'), 'true');
249
+ p.sheet.querySelector('[data-pn-close]').focus();
250
+ p.docKeys.forEach((fn) => fn({ key: 'Escape', preventDefault() {} }));
251
+ assert.ok(p.collapsed(), 'Escape inside the panel tucks it away');
252
+ assert.equal(p.sheet.querySelector('[data-pn-tab]').getAttribute('aria-expanded'), 'false');
253
+ p.press('data-pn-tab');
254
+ p.press('data-pn-close');
255
+ assert.ok(p.collapsed(), 'docked, the X tucks it away too');
256
+ assert.equal(p.sheet.parentNode, p.body, 'and leaves the tab on the page, the only way back in');
257
+ p.api.close();
258
+ assert.equal(p.sheet.parentNode, null, 'leaving Artistry (close) is what removes it');
259
+ });
260
+
261
+ test('THE MODE ROW names each mode in the mode switch word and starts a review on a press', () => {
262
+ const p = dockedPanel();
263
+ assert.match(p.sheet.innerHTML, /<span>Ideating<\/span>/, 'the switch says Ideating, so the row does');
264
+ assert.doesNotMatch(p.sheet.innerHTML, />Ideator</);
265
+ assert.doesNotMatch(p.sheet.innerHTML, /Stop reviewing/, 'no way out of a review that has not started');
266
+ p.press('data-pn-mode', 'building');
267
+ assert.ok(p.calls.includes('review building'));
268
+ assert.match(p.sheet.querySelector('#pn-modes').innerHTML, /data-pn-mode="building" aria-pressed="true"/, 'the row repaints with the live one pressed');
269
+ assert.match(p.sheet.querySelector('#pn-modes').innerHTML, /Stop reviewing/);
270
+ p.press('data-pn-mode', '');
271
+ assert.ok(p.calls.includes('stop'), 'Stop reviewing ends it');
272
+ });
273
+
274
+ test('OPENED, not docked (the public site Artist review button): the modal sheet it always was', async () => {
275
+ const p = dockedPanel({ opened: true });
276
+ await flush();
277
+ assert.equal(p.sheet.className, 'pn', 'no docked look: the scrim and full-viewport sheet stay');
278
+ assert.ok(p.html.classList.contains('pn-open'), 'the page behind is scroll-locked');
279
+ assert.match(p.sheet.innerHTML, /role="dialog" aria-modal="true"/);
280
+ assert.doesNotMatch(p.sheet.innerHTML, /data-pn-tab/, 'no tab');
281
+ assert.ok(p.calls.some((c) => /^POST /.test(c)), 'the draft opens straight away');
282
+ p.sheet.fire('pointerleave', { pointerType: 'mouse' });
283
+ assert.ok(!p.collapsed(), 'a pointer leaving does not tuck a modal sheet');
284
+ p.docKeys.forEach((fn) => fn({ key: 'Escape', preventDefault() {} }));
285
+ assert.equal(p.sheet.parentNode, null, 'Escape closes it');
286
+ assert.ok(!p.html.classList.contains('pn-open'), 'and lifts the scroll lock');
287
+ const q = dockedPanel({ opened: true });
288
+ const s = q.sheet;
289
+ s.fire('click', { target: s });
290
+ assert.equal(s.parentNode, null, 'a click on the scrim closes it');
291
+ });
@@ -227,6 +227,14 @@ await test('BEHAVIOUR: a failed create throws with the status, it does not retur
227
227
  // STATIC — the surfaces a unit test cannot invoke (browser DOM strings, docs).
228
228
  // ==========================================================================
229
229
 
230
+ // The hall promote once sent no discipline, and an idea whose wording names no
231
+ // craft was refused "discipline was not supplied and could not be inferred".
232
+ await test('BOTH PATHS: the hall promote payload always sends a discipline', () => {
233
+ assert.match(HALL, /discipline: \(form\.discipline && form\.discipline\.value\) \|\| 'engineer'/,
234
+ 'hall: discipline must reach the create payload, defaulting to engineer');
235
+ assert.match(HALL, /<select name="discipline">/, 'hall: the promote form offers a Craft picker');
236
+ });
237
+
230
238
  // ---- 4. both paths send goal_id -------------------------------------------
231
239
 
232
240
  await test('BOTH PATHS: the CLI createTask and the hall payload each send goal_id', () => {
@@ -198,6 +198,8 @@ function harness({ batch, source = 'page-tweak', registryOverride = null, reread
198
198
  registry: REG,
199
199
  regenerateRegistry: () => ({ reg: registryOverride || registryOver(Object.fromEntries(Object.keys(tree).map((rel) => [rel, files.get(abs(rel))]))) }),
200
200
  reread: () => (rereadOk ? { ok: true } : { ok: false, code: 'no_browser' }),
201
+ // Hermetic by default: the real finder scans the repo's own tests (task 1004775's case below injects a tree).
202
+ findPinningTests: () => [],
201
203
  readingFor: () => ({ reading_hash: 'r-after', lines: [{ key: 'L0001', text: 'Skip to content', placement: 'shared' }, { key: 'L0002', text: 'Welcome to your studio', placement: 'placed' }] }),
202
204
  api: async (method, url, body) => {
203
205
  calls.push({ method, url, body });
@@ -299,4 +301,42 @@ await test("--page: the round's own page applies as usual", async () => {
299
301
  assert.ok(h.writes.length > 0);
300
302
  });
301
303
 
304
+ // task 1004775: a test that asserts a rewritten line's OLD words verbatim is named
305
+ // in the applied block at apply time, not discovered by CI after the artist approved.
306
+ await test('copy-apply names a test that pins a line\'s old text in the applied block', async () => {
307
+ const testTree = {
308
+ 'tests/landing_fixture.mjs': "assert.match(html, /x/);\nassert.ok(html.includes('Take a page and rewrite every word of it.'));\n",
309
+ 'modules/copy-desk/tests/other.mjs': "assert.ok(s.includes('nothing relevant here'));\n",
310
+ 'tests/short_label.mjs': "assert.ok(s.includes('Take next'));\n",
311
+ };
312
+ const h = harness({ batch: BATCH() });
313
+ h.deps.findPinningTests = (args) => apply.findPinningTests({ ...args, listTests: () => Object.keys(testTree), readFile: (rel) => testTree[rel] });
314
+ const logs = [];
315
+ h.deps.log = (s) => logs.push(s);
316
+ const code = await apply.batchMain({ taskId: '5001', now: () => 't' }, h.deps);
317
+ assert.equal(code, 0);
318
+ const applied = pages.parseAppliedBlock(h.calls.find((c) => c.method === 'PATCH').body.description);
319
+ assert.deepEqual(applied.applied.pinned_tests, [{ key: 'L0002', tests: ['tests/landing_fixture.mjs'] }],
320
+ 'the pinning test is named against its line; a short label (Take next) is too common to search for');
321
+ assert.ok(logs.some((l) => /L0002 the old words are still asserted by: tests\/landing_fixture\.mjs/.test(l)), 'and said on the terminal');
322
+ assert.equal(testTree['tests/landing_fixture.mjs'].includes('rewrite every word'), true, 'the test itself is never edited');
323
+ });
324
+
325
+ await test('a hole in the old text searches its longest literal run', () => {
326
+ assert.equal(apply.pinNeedle('You have {…} pages waiting for review'), 'pages waiting for review');
327
+ assert.equal(apply.pinNeedle('Take next'), null);
328
+ const hits = apply.findPinningTests({
329
+ applied: [{ key: 'L0003', before: 'You have {…} pages waiting for review' }],
330
+ listTests: () => ['tests/a.mjs'], readFile: () => '`You have ${n} pages waiting for review`',
331
+ });
332
+ assert.deepEqual(hits, [{ key: 'L0003', tests: ['tests/a.mjs'] }]);
333
+ });
334
+
335
+ await test('the real test lister walks tests/ and modules/*/tests/, skipping fixtures', () => {
336
+ const list = apply.listTestFiles();
337
+ assert.ok(list.includes('tests/tweak_batch_apply.mjs'));
338
+ assert.ok(list.includes('modules/copy-desk/tests/copy_page_status.mjs'));
339
+ assert.ok(!list.some((f) => f.includes('/fixtures/')));
340
+ });
341
+
302
342
  summary();