@bongos/core 1.21.164 → 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.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.164",
3
+ "version": "1.21.165",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.164",
9
+ "version": "1.21.165",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.164",
3
+ "version": "1.21.165",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -10034,5 +10034,11 @@
10034
10034
  "id": "1004774",
10035
10035
  "text": "Home got the owner's reshuffle: a slimmer banner with the version's progress bar under it; your picture and rank at the top right, opening on hover into your standing card with a View full profile link; every Needs-attention s"
10036
10036
  }
10037
+ ],
10038
+ "1.21.165": [
10039
+ {
10040
+ "id": "1004771",
10041
+ "text": "Artist review is now a small tab on the right edge of every hall page in artist mode. Hover the tab and the review panel slides out; move the cursor back onto the page and it tucks away, keeping your draft. The page stays scro"
10042
+ }
10037
10043
  ]
10038
10044
  }
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.164'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.165'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -1,9 +1,10 @@
1
1
  // tests/craft_review_mode.mjs — artist review mode (task 1004686, goal 1000095).
2
2
  //
3
- // Owner ask, 2026-10-09: hovering the Review button in Artistry opens a menu of
4
- // the four experiences (Ideator, Governing, Building, Artistry); picking one
5
- // puts the artist into review mode on that mode's view, and graffiti is painted
6
- // across the top bar's empty space while it lasts. These cases run the REAL
3
+ // Owner ask, 2026-10-09: the artist picks one of the four experiences (Ideator,
4
+ // Governing, Building, Artistry) and reviews the hall in that mode's view, with
5
+ // graffiti across the top bar's empty space while it lasts. Since task 1004771
6
+ // the choice lives in the docked review panel's own Review mode row
7
+ // (page-notes.js; tests/page_notes_panel.mjs runs that row), not a top-bar menu. These cases run the REAL
7
8
  // craft-mode.js (and, when it is loaded, the real craft-review.js) over a small
8
9
  // DOM, and read the real craft-review.css for the motion rules.
9
10
  import assert from 'node:assert/strict';
@@ -83,7 +84,7 @@ function boot({ session = {}, store = {}, narrow = false } = {}) {
83
84
  head.onAppend = (tag) => {
84
85
  loaded.push(tag.src);
85
86
  if (/craft-review\.js/.test(tag.src)) vm.runInContext(REVIEW, sandbox, { filename: 'craft-review.js' });
86
- else window.OTBPageNotes = { open: () => opened.push('open'), openNote: () => {} };
87
+ else window.OTBPageNotes = { open: () => opened.push('open'), openNote: () => {}, dock: () => opened.push('dock'), close: () => opened.push('close') };
87
88
  tag.onload();
88
89
  };
89
90
  vm.runInContext(CRAFT, sandbox, { filename: 'craft-mode.js' });
@@ -93,107 +94,43 @@ function boot({ session = {}, store = {}, narrow = false } = {}) {
93
94
  return {
94
95
  craft: window.OTBCraftMode, root, topbar, side, sw, title, loaded, opened, session, byId, docListeners,
95
96
  tools: () => all(side).filter((n) => n.hasAttribute('data-craft-tool')).map((n) => n.id.replace('craft-tool-', '')),
96
- menu: () => byId('craft-review-menu'),
97
- menuOpen: () => !!byId('craft-review-menu') && !byId('craft-review-menu').hasAttribute('hidden'),
98
- // A click on a menu item: the menu's own listener reads the button off the target.
99
- pick: (attrs) => byId('craft-review-menu').fire('click', { target: { closest: () => ({ getAttribute: (k) => (k in attrs ? attrs[k] : null) }) } }),
100
97
  };
101
98
  }
102
99
  const artist = () => ({ 'hall-craft': 'artistry', 'hall-craft-asked': '1' });
103
100
 
104
- test('THE SPLIT PILL: in Artistry, Review rides a group with a review-mode chevron and a closed menu', () => {
101
+ test('NO TOP-BAR REVIEW BUTTON: in Artistry the bar holds Edit words alone, and no review-mode menu', async () => {
105
102
  const p = boot({ session: artist() });
106
- const pill = p.byId('craft-review');
107
- assert.ok(pill, 'the group exists');
108
- assert.equal(p.byId('craft-tool-palette').parentNode, pill, 'Review sits in it');
109
- const more = p.byId('craft-review-more');
110
- assert.equal(more.parentNode, pill);
111
- assert.equal(more.getAttribute('aria-haspopup'), 'menu');
112
- assert.equal(more.getAttribute('aria-expanded'), 'false');
113
- assert.equal(more.getAttribute('aria-controls'), 'craft-review-menu');
114
- assert.equal(p.menu().getAttribute('role'), 'menu');
115
- assert.ok(p.menu().hasAttribute('hidden'), 'the menu starts closed');
103
+ await flush();
104
+ assert.deepEqual(p.tools(), ['paintbrush'], 'the review panel docks at the edge instead (task 1004771)');
105
+ assert.equal(p.byId('craft-review'), null, 'no split pill');
106
+ assert.equal(p.byId('craft-review-menu'), null, 'no menu');
107
+ assert.deepEqual(p.opened, ['dock']);
116
108
  assert.equal(p.byId('craft-graffiti'), null, 'no wall until a review starts');
117
109
  assert.equal(p.root.getAttribute('data-craft-review'), null);
118
- for (const m of ['building', 'ideating', 'governing']) {
119
- p.craft.choose(m);
120
- assert.equal(p.byId('craft-review'), null, `no Review pill in ${m}`);
121
- }
122
- });
123
-
124
- test('THE MENU: write feedback first, then the four experiences in the owner\'s order and words', () => {
125
- const p = boot({ session: artist() });
126
- p.byId('craft-review-more').click({ detail: 1 });
127
- assert.ok(p.menuOpen(), 'the chevron opens it');
128
- assert.equal(p.byId('craft-review-more').getAttribute('aria-expanded'), 'true');
129
- const html = p.menu().innerHTML;
130
- const labels = [...html.matchAll(/<span>([^<]+)<\/span>/g)].map((m) => m[1]);
131
- assert.deepEqual(labels, ['Write feedback on this page', 'Ideator experience', 'Governing experience', 'Building experience', 'Artistry experience']);
132
- assert.deepEqual([...html.matchAll(/data-review-pick="([a-z]+)"/g)].map((m) => m[1]), ['ideating', 'governing', 'building', 'artistry']);
133
- assert.equal((html.match(/role="menuitemradio" aria-checked="true"/g) || []).length, 0, 'nothing is checked before a review');
134
- assert.doesNotMatch(html, /Stop reviewing/, 'no way out of a review that has not started');
135
- p.byId('craft-review-more').click({ detail: 1 });
136
- assert.ok(!p.menuOpen(), 'the chevron closes it again');
137
- });
138
-
139
- test('HOVER opens it for a mouse, never for a touch, and leaving closes it after a beat', async () => {
140
- const p = boot({ session: artist() });
141
- const pill = p.byId('craft-review');
142
- pill.fire('pointerenter', { pointerType: 'touch' });
143
- assert.ok(!p.menuOpen(), 'a touch is not a hover');
144
- pill.fire('pointerenter', { pointerType: 'mouse' });
145
- assert.ok(p.menuOpen(), 'a mouse over the pill opens the menu');
146
- pill.fire('pointerleave', { pointerType: 'mouse' });
147
- assert.ok(p.menuOpen(), 'it stays a beat, so the pointer can reach the menu');
148
- pill.fire('pointerenter', { pointerType: 'mouse' });
149
- await new Promise((r) => setTimeout(r, 260));
150
- assert.ok(p.menuOpen(), 'coming back in cancels the close');
151
- pill.fire('pointerleave', { pointerType: 'mouse' });
152
- await new Promise((r) => setTimeout(r, 260));
153
- assert.ok(!p.menuOpen(), 'and leaving for good closes it');
154
- });
155
-
156
- test('CLICKING Review still opens the feedback panel; on a phone a tap opens the menu instead', async () => {
157
- const p = boot({ session: artist() });
158
- p.byId('craft-tool-palette').click({ detail: 1 });
159
- await flush();
160
- assert.deepEqual(p.opened, ['open'], 'the feedback doc opens, as before');
161
- assert.ok(!p.menuOpen());
162
- const phone = boot({ session: artist(), narrow: true });
163
- phone.byId('craft-tool-palette').click({ detail: 1 });
164
- await flush();
165
- assert.deepEqual(phone.opened, [], 'no panel on the tap');
166
- assert.ok(phone.menuOpen(), 'the menu instead (a phone has no hover)');
167
- phone.pick({ 'data-review-act': 'write' });
168
- await flush();
169
- assert.deepEqual(phone.opened, ['open'], 'and its first line opens the feedback doc');
110
+ assert.deepEqual(JSON.parse(JSON.stringify(p.craft.experiences().map((x) => x.label))), ['Ideator experience', 'Governing experience', 'Building experience', 'Artistry experience'], 'the four, in the owner\'s order and words, for the review panel row');
170
111
  });
171
112
 
172
113
  test('PICKING an experience: the hall wears that mode, the artist keeps the Review tools, and the wall goes up', async () => {
173
114
  const p = boot({ session: artist() });
174
- p.byId('craft-review-more').click({ detail: 1 });
175
- p.pick({ 'data-review-pick': 'governing' });
176
- assert.ok(!p.menuOpen(), 'the menu closes on a pick');
115
+ p.craft.review('governing');
177
116
  assert.equal(p.root.dataset.craft, 'governing', 'the hall wears Governing: its tabs, look and tallies follow');
178
117
  assert.equal(p.root.getAttribute('data-craft-review'), 'governing');
179
118
  assert.equal(p.craft.current(), 'governing');
180
119
  assert.equal(p.craft.reviewing(), 'governing');
181
120
  assert.equal(p.session['hall-craft'], 'artistry', 'the artist\'s own mode stays Artistry');
182
121
  assert.equal(p.session['hall-craft-review'], 'governing', 'the review lasts the session');
183
- assert.deepEqual(p.tools(), ['palette', 'paintbrush'], 'Review and Edit words stay in the bar');
184
- assert.ok(p.byId('craft-tool-palette').classList.contains('craft-tool--reviewing'));
122
+ assert.deepEqual(p.tools(), ['paintbrush'], 'Edit words stays in the bar');
123
+ await flush();
124
+ assert.ok(p.opened.filter((x) => x === 'dock').length >= 2, 'the docked panel is told again, so its mode row repaints');
185
125
  const wall = p.byId('craft-graffiti');
186
126
  assert.ok(wall, 'the graffiti wall is up');
187
127
  assert.equal(wall.parentNode, p.topbar, 'in the top bar');
188
128
  assert.equal(wall.nextSibling, p.side, 'in the empty space before the controls');
189
129
  assert.equal(wall.getAttribute('aria-hidden'), 'true', 'decorative');
190
130
  await flush();
191
- assert.deepEqual(p.loaded, ['/builders/craft-review.js?v=2026-10-09-test'], 'the wall\'s script loads on demand, at craft-mode.js\'s version');
131
+ assert.ok(p.loaded.includes('/builders/craft-review.js?v=2026-10-09-test'), 'the wall\'s script loads on demand, at craft-mode.js\'s version');
192
132
  assert.equal((wall.innerHTML.match(/class="gx-tag /g) || []).length, 6, 'six doodles');
193
133
  assert.doesNotMatch(wall.innerHTML, /<text/, 'scribbles, not words (owner, 2026-10-09)');
194
- p.byId('craft-review-more').click({ detail: 1 });
195
- assert.match(p.menu().innerHTML, /aria-checked="true" data-review-pick="governing"/, 'the menu checks the live experience');
196
- assert.match(p.menu().innerHTML, /data-review-act="stop"/, 'and offers a way out');
197
134
  });
198
135
 
199
136
  test('IT LASTS THE SESSION: a reload keeps the review; a new session starts without one', () => {
@@ -215,7 +152,7 @@ test('IT LASTS THE SESSION: a reload keeps the review; a new session starts with
215
152
  test('REVIEWING Ideating shows its own tool too, marked a guest so a tiny phone can fold it away', () => {
216
153
  const p = boot({ session: artist() });
217
154
  p.craft.review('ideating');
218
- assert.deepEqual(p.tools(), ['palette', 'paintbrush', 'lightbulb'], 'the artist\'s tools, then the experience\'s own, in order');
155
+ assert.deepEqual(p.tools(), ['paintbrush', 'lightbulb'], 'the artist\'s tool, then the experience\'s own, in order');
219
156
  assert.ok(p.byId('craft-tool-lightbulb').classList.contains('craft-tool--guest'));
220
157
  assert.ok(!p.byId('craft-tool-paintbrush').classList.contains('craft-tool--guest'));
221
158
  p.craft.choose('ideating');
@@ -227,13 +164,11 @@ test('ENDING it: Stop reviewing, or any mode from the switch, puts the artist ba
227
164
  const p = boot({ session: artist() });
228
165
  p.craft.review('building');
229
166
  assert.equal(p.root.dataset.craft, 'building');
230
- p.byId('craft-review-more').click({ detail: 1 });
231
- p.pick({ 'data-review-act': 'stop' });
167
+ p.craft.stopReview();
232
168
  assert.equal(p.root.dataset.craft, 'artistry', 'Stop reviewing is back in Artistry');
233
169
  assert.equal(p.root.getAttribute('data-craft-review'), null);
234
170
  assert.equal(p.byId('craft-graffiti'), null, 'the wall comes down');
235
171
  assert.equal(p.session['hall-craft-review'], undefined, 'and the session forgets it');
236
- assert.ok(!p.byId('craft-tool-palette').classList.contains('craft-tool--reviewing'));
237
172
  p.craft.review('governing');
238
173
  p.craft.choose('artistry');
239
174
  assert.equal(p.craft.reviewing(), null, 'choosing a mode from the switch, even Artistry, ends a review');
@@ -242,25 +177,7 @@ test('ENDING it: Stop reviewing, or any mode from the switch, puts the artist ba
242
177
  p.craft.choose('governing');
243
178
  assert.equal(p.root.dataset.craft, 'governing');
244
179
  assert.equal(p.root.getAttribute('data-craft-review'), null, 'Governing chosen for real is not a review');
245
- assert.equal(p.byId('craft-review'), null, 'and carries no Review pill');
246
- });
247
-
248
- test('THE KEYS: arrows move through the menu, Escape closes it, an outside click closes it', () => {
249
- const p = boot({ session: artist() });
250
- p.byId('craft-review-more').click({ detail: 1 });
251
- assert.equal(p.docListeners.click && p.docListeners.click.size, 1, 'an open menu listens for a click outside');
252
- p.menu().fire('keydown', { key: 'Escape' });
253
- assert.ok(!p.menuOpen(), 'Escape closes it');
254
- assert.equal(p.docListeners.click.size, 0, 'and stops listening');
255
- p.byId('craft-review-more').fire('keydown', { key: 'ArrowDown' });
256
- assert.ok(p.menuOpen(), 'ArrowDown on the chevron opens it');
257
- [...p.docListeners.click][0]({ target: p.title });
258
- assert.ok(!p.menuOpen(), 'a click elsewhere closes it');
259
- p.byId('craft-tool-palette').fire('keydown', { key: 'ArrowDown' });
260
- assert.ok(p.menuOpen(), 'ArrowDown on Review opens it too');
261
- assert.match(CRAFT, /e\.key === 'ArrowUp'/);
262
- assert.match(CRAFT, /e\.key === 'Home'/);
263
- assert.match(CRAFT, /e\.key === 'End'/);
180
+ assert.ok(p.opened.includes('close'), 'and the review panel leaves with Artistry');
264
181
  });
265
182
 
266
183
  test('THE WALL is scribbled in CSS only, in ink alone, and stands still under reduced motion', () => {
@@ -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
+ });