@bongos/core 1.21.164 → 1.21.166

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.
@@ -19,7 +19,7 @@
19
19
  var MAX_IMAGES = 8;
20
20
  var TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
21
21
 
22
- var sheet = null, returnFocus = null, state = null, saveTimer = null, pollTimer = null;
22
+ var sheet = null, returnFocus = null, state = null, saveTimer = null, pollTimer = null, docked = false;
23
23
 
24
24
  function esc(s) {
25
25
  return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
@@ -101,11 +101,45 @@
101
101
  }
102
102
 
103
103
  // ---------- the sheet ----------
104
+ // The tab wears the Review button's own palette glyph (craft-mode.js ICONS
105
+ // artistry), drawn here too because the public site has no craft-mode.js.
106
+ var PALETTE_SVG = '<svg class="pn__tab-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
107
+ '<path d="M8 2a6 6 0 1 0 0 12c.9 0 1.4-.6 1.4-1.3 0-.8-.6-1-.6-1.7 0-.6.5-1 1.2-1h1.6A2.4 2.4 0 0 0 14 7.6C14 4.5 11.3 2 8 2Z"/><path d="M5 6.2h.01M7.6 4.6h.01M10.4 5.2h.01M4.6 9h.01" stroke-width="2.2"/></svg>';
108
+ // The Review mode row: which experience the hall wears while the artist
109
+ // reviews it (craft-mode.js review mode). Hall only: the public site has no
110
+ // modes, so it draws nothing there.
111
+ function craft() { var c = window.OTBCraftMode; return c && c.experiences && c.review ? c : null; }
112
+ function modesHtml() {
113
+ var c = craft();
114
+ if (!c) return '';
115
+ var on = c.reviewing();
116
+ return '<div class="pn__modes" role="group" aria-label="Review mode">' +
117
+ '<span class="pn__modes-h">Review mode</span>' +
118
+ c.experiences().map(function (x) {
119
+ return '<button class="pn__mode" type="button" data-pn-mode="' + esc(x.id) + '" aria-pressed="' + (on === x.id ? 'true' : 'false') + '">' +
120
+ c.iconHtml(x.id, 'pn__mode-icon') + '<span>' + esc(x.short) + '</span></button>';
121
+ }).join('') +
122
+ (on ? '<button class="pn__mode pn__mode--stop" type="button" data-pn-mode="">Stop reviewing</button>' : '') +
123
+ '</div>';
124
+ }
125
+ function paintModes() {
126
+ var row = sheet && sheet.querySelector('#pn-modes');
127
+ if (row) row.innerHTML = modesHtml();
128
+ }
129
+ // Two shapes. Docked (dock(), the hall's artist mode, task 1004771): a
130
+ // non-modal region at the right edge behind a tab. Opened (open(), e.g. the
131
+ // public site's Artist review button): the modal sheet over a scrim it was.
104
132
  function shellHtml(p) {
105
- return '<div class="pn__panel" role="dialog" aria-modal="true" aria-labelledby="pn-title">' +
133
+ return (docked
134
+ ? '<button class="pn__tab" type="button" data-pn-tab aria-expanded="true" aria-controls="pn-panel" aria-label="Hide the review panel">' +
135
+ PALETTE_SVG +
136
+ '<svg class="pn__tab-chev" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 3l5 5-5 5"/></svg></button>' +
137
+ '<div class="pn__panel" id="pn-panel" role="region" aria-labelledby="pn-title">'
138
+ : '<div class="pn__panel" role="dialog" aria-modal="true" aria-labelledby="pn-title">') +
106
139
  '<div class="pn__head">' +
107
140
  '<p class="pn__eyebrow">Artist review · ' + esc(p.title) + '</p>' +
108
141
  '<h2 class="pn__title" id="pn-title">What should this page be?</h2>' +
142
+ '<div id="pn-modes">' + modesHtml() + '</div>' +
109
143
  '<button class="pn__close" type="button" data-pn-close aria-label="Close">' +
110
144
  '<svg viewBox="0 0 18 18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" aria-hidden="true"><path d="M5 5l8 8M13 5l-8 8"/></svg></button>' +
111
145
  '</div>' +
@@ -285,10 +319,18 @@
285
319
  }
286
320
 
287
321
  function onClick(e) {
288
- if (e.target === sheet) { close(); return; }
322
+ if (!docked && e.target === sheet) { close(); return; }
289
323
  var btn = e.target.closest && e.target.closest('button');
290
324
  if (!btn) return;
291
- if (btn.hasAttribute('data-pn-close')) close();
325
+ if (btn.hasAttribute('data-pn-tab')) toggle();
326
+ else if (btn.hasAttribute('data-pn-mode')) {
327
+ var c = craft(), id = btn.getAttribute('data-pn-mode');
328
+ if (c) { if (id) c.review(id); else c.stopReview(); }
329
+ paintModes();
330
+ }
331
+ // Docked, the X tucks the panel away like Escape: removing it would take
332
+ // the tab, the reviewer's only way back in, with it.
333
+ else if (btn.hasAttribute('data-pn-close')) { if (docked) toggle(true); else close(); }
292
334
  else if (btn.hasAttribute('data-pn-submit')) submit();
293
335
  else if (btn.hasAttribute('data-pn-note')) showNote(btn.getAttribute('data-pn-note'));
294
336
  else if (btn.hasAttribute('data-pn-back') || btn.hasAttribute('data-pn-new')) {
@@ -298,6 +340,13 @@
298
340
  }
299
341
  function onKey(e) {
300
342
  if (!sheet) return;
343
+ // Docked, the panel is not modal: the page behind it stays scrollable and
344
+ // reachable, and Escape inside it tucks it away behind its tab, draft kept.
345
+ if (docked) {
346
+ if (e.key === 'Escape' && sheet.contains(document.activeElement)) { e.preventDefault(); toggle(true); }
347
+ return;
348
+ }
349
+ // Opened, it is the modal sheet: Escape closes it, Tab stays inside it.
301
350
  if (e.key === 'Escape') { e.preventDefault(); close(); return; }
302
351
  if (e.key !== 'Tab') return;
303
352
  var f = Array.prototype.slice.call(sheet.querySelectorAll('button, textarea, input, a[href]')).filter(function (x) { return !x.disabled; });
@@ -305,20 +354,46 @@
305
354
  if (e.shiftKey && document.activeElement === f[0]) { e.preventDefault(); f[f.length - 1].focus(); }
306
355
  else if (!e.shiftKey && document.activeElement === f[f.length - 1]) { e.preventDefault(); f[0].focus(); }
307
356
  }
308
- function mountSheet() {
357
+ // Slide the panel off the right edge, leaving only its tab, or bring it back.
358
+ // `quiet` (docking) moves no focus. A docked panel opens its draft only
359
+ // the first time it is shown, so artist mode costs no request until then.
360
+ function toggle(collapse, quiet) {
361
+ if (!sheet) return;
362
+ var on = typeof collapse === 'boolean' ? collapse : !sheet.classList.contains('pn--collapsed');
363
+ sheet.classList.toggle('pn--collapsed', on);
364
+ var tab = sheet.querySelector('[data-pn-tab]');
365
+ if (tab) {
366
+ tab.setAttribute('aria-expanded', on ? 'false' : 'true');
367
+ tab.setAttribute('aria-label', on ? 'Show the review panel' : 'Hide the review panel');
368
+ if (on && !quiet) tab.focus();
369
+ }
370
+ if (on) save();
371
+ else if (state.pending) { state.pending = false; openDraft(); }
372
+ }
373
+ // `dock` mounts the docked shape quietly (no focus moved); otherwise the modal sheet.
374
+ function mountSheet(dock) {
309
375
  if (sheet) return false;
310
- returnFocus = document.activeElement;
376
+ docked = !!dock;
377
+ returnFocus = docked ? null : document.activeElement;
311
378
  state = { note: null, images: [], list: [] };
312
379
  sheet = document.createElement('div');
313
- sheet.className = 'pn';
380
+ sheet.className = docked ? 'pn pn--dock' : 'pn';
314
381
  sheet.id = 'page-notes';
315
382
  sheet.innerHTML = shellHtml(page());
316
383
  sheet.addEventListener('click', onClick);
384
+ if (docked) {
385
+ // A mouse peeks (owner, 2026-10-23): over the tab the panel opens, and the
386
+ // moment the pointer leaves the panel it tucks away again, draft kept.
387
+ // Touch and keyboard keep the tab's click, which a hover cannot replace.
388
+ sheet.querySelector('[data-pn-tab]').addEventListener('pointerenter', function (e) { if (e.pointerType === 'mouse') toggle(false, true); });
389
+ sheet.addEventListener('pointerleave', function (e) { if (e.pointerType === 'mouse') toggle(true, true); });
390
+ } else {
391
+ document.documentElement.classList.add('pn-open');
392
+ }
317
393
  document.addEventListener('keydown', onKey, true);
318
394
  document.body.appendChild(sheet);
319
- document.documentElement.classList.add('pn-open');
320
395
  var c = sheet.querySelector('[data-pn-close]');
321
- if (c) c.focus();
396
+ if (c && !docked) c.focus();
322
397
  return true;
323
398
  }
324
399
  function close() {
@@ -328,14 +403,24 @@
328
403
  document.removeEventListener('keydown', onKey, true);
329
404
  if (sheet.parentNode) sheet.parentNode.removeChild(sheet);
330
405
  sheet = null;
406
+ docked = false;
331
407
  document.documentElement.classList.remove('pn-open');
332
408
  if (returnFocus && returnFocus.focus) { try { returnFocus.focus(); } catch (_) { /* gone */ } }
333
409
  returnFocus = null;
334
410
  }
335
411
 
336
412
  window.OTBPageNotes = {
337
- open: function () { if (mountSheet()) openDraft(); },
338
- openNote: function (id) { if (mountSheet()) refreshList().then(function () { return showNote(id); }); },
413
+ open: function () { if (mountSheet()) openDraft(); else if (docked) toggle(false); },
414
+ // Artist mode (craft-mode.js, task 1004771): the panel waits at the right
415
+ // edge, tucked behind its tab; already there, it repaints its mode row.
416
+ dock: function () {
417
+ if (mountSheet(true)) { state.pending = true; toggle(true, true); }
418
+ else paintModes();
419
+ },
420
+ openNote: function (id) {
421
+ if (!mountSheet() && docked) { state.pending = false; toggle(false); }
422
+ refreshList().then(function () { return showNote(id); });
423
+ },
339
424
  close: close,
340
425
  configure: configure,
341
426
  ideaHref: ideaHref,
@@ -1030,6 +1030,10 @@
1030
1030
  renderArtKeyBanner(artKeyNeedItem(me));
1031
1031
  const res = await api.request('GET', `${API}/me/art-key/own`);
1032
1032
  if (res.status === 401) { toAuth(); return; }
1033
+ // 404: the art pipeline is not installed, so there is no key to add here —
1034
+ // keep the section (and any banner) hidden rather than show a reload error
1035
+ // (task 1004786).
1036
+ if (res.status === 404) { renderArtKeyBanner(null); return; }
1033
1037
  if (!res.ok) throw new Error(`art-key GET → ${res.status}`);
1034
1038
  renderArtKeyCard(res.data);
1035
1039
  wireArtKeyCard();
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.166",
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.166",
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.166",
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,21 @@
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
+ }
10043
+ ],
10044
+ "1.21.166": [
10045
+ {
10046
+ "id": "1004780",
10047
+ "text": "Home's greeting stands on its own: the \"BONGOS-V2 is 83 percent complete. 150 tasks are claimable right now.\" line under \"Good morning\" is gone, because the version bar and the ready-work list beside it already say both."
10048
+ },
10049
+ {
10050
+ "id": "1004786",
10051
+ "text": "Settings no longer shows a broken image-generation key card on projects without the art tools. Before, builders were told their free art coverage had ended and to add a key, but the card could never load, so there was nowhere"
10052
+ }
10037
10053
  ]
10038
10054
  }
@@ -569,6 +569,9 @@ module.exports = function buildMeRouter() {
569
569
  rank: builder?.rank,
570
570
  ownKeySet: !!(geminiKeyRow && geminiKeyRow.enc),
571
571
  sharedKeyConfigured: artSharedKeyConfigured(),
572
+ // task 1004786: the art-pipeline module provides this port when enabled;
573
+ // without it there are no key routes, so the need is not raised.
574
+ artPipeline: seams.hasProvider('art.sharedKeyConfigured'),
572
575
  // founding grace (task 1002739 / direction §5.6): a newborn instance's
573
576
  // founder gets a welcome roster, not a coverage nag. The module owns the
574
577
  // policy (window length, copy); we pass the raw facts.
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.166'; // 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', () => {
@@ -244,12 +244,15 @@ const hallRenderJs = read('hall-render.js');
244
244
  const buildersJs = read('builders.js');
245
245
  const goalInboxJs = read('goal-inbox.js');
246
246
 
247
- test('the greeting IS the page\'s one h1, in the board\'s label voice, with a sub-line beside it', () => {
247
+ test('the greeting IS the page\'s one h1, in the board\'s label voice, with no sub-line under it', () => {
248
248
  const h1s = indexHtml.match(/<h1\b/g) || [];
249
249
  assert.equal(h1s.length, 1, 'one-header-main-h1: the greeting must BECOME the h1, never add a second');
250
- assert.match(indexHtml, /<div class="page-head page-head--board page-head--hall">\s*<h1 id="hall-h1" class="page-head__h">Builders' Hall<\/h1>\s*<p id="hall-sub" class="page-head__sub" hidden><\/p>/,
251
- 'the h1 is visible (no sr-only), keeps the id brand-apply.js writes into, and the sub-line ships hidden');
250
+ assert.match(indexHtml, /<div class="page-head page-head--board page-head--hall">\s*<h1 id="hall-h1" class="page-head__h">Builders' Hall<\/h1>/,
251
+ 'the h1 is visible (no sr-only) and keeps the id brand-apply.js writes into');
252
252
  assert.doesNotMatch(indexHtml, /id="hall-h1" class="sr-only"/, 'the sr-only h1 is gone');
253
+ // task 1004780 (owner): the status sentence repeated the version tab and Next up, so it went.
254
+ assert.doesNotMatch(indexHtml, /id="hall-sub"/, 'no status line under the greeting');
255
+ assert.doesNotMatch(hallRenderJs, /hall-sub|claimable right now/, 'and nothing renders one');
253
256
  });
254
257
 
255
258
  test('the band ends level: no align-items on the grid, so the two cards stretch', () => {
@@ -300,28 +303,18 @@ test('the system notices are inbox LINES, first in the card, and counted by its
300
303
  }
301
304
  });
302
305
 
303
- test('the greeting reads /me, the public progress and the claimable feed, and builders.js feeds all three', () => {
306
+ test('the greeting reads /me and the public progress (for the version tab), and builders.js feeds both', () => {
304
307
  assert.match(buildersJs, /getJSON\(`\$\{API\}\/public\/progress`\)/, 'Home fetches the public progress once');
305
- // The count comes off the SAME Next-up response — one request, not a second
306
- // fetch. It reads `claimable_total` rather than the length of the returned array,
307
- // because that request is now bounded (?limit=2, task 1003545): the array holds two
308
- // rows and the total holds the whole rank-scoped queue. Asserting the derivation
309
- // rather than one literal expression is deliberate — the previous version pinned
310
- // `resp.claimable.length`, which read as a requirement to download the feed.
311
- assert.match(buildersJs, /lastClaimableCount = Number\.isFinite\(claimableTotal\)/,
312
- 'the claimable feed Home already fetches for Next-up answers the count');
313
- assert.match(buildersJs, /const claimableTotal = Number\(resp && resp\.claimable_total\);/,
314
- 'and it takes the count from the response total, so a bounded read still knows it');
315
308
  assert.doesNotMatch(buildersJs, /getJSON\(`\$\{API\}\/tasks\/claimable`/,
316
- 'no unbounded claimable fetch on Home — the count must never cost the whole queue');
309
+ 'no unbounded claimable fetch on Home');
317
310
  assert.match(buildersJs, /get lastProgressState\(\)/, 'the renderer reads progress through a getter, so it cannot freeze at boot');
318
- assert.match(buildersJs, /get lastClaimableCount\(\)/, 'and the count the same way');
319
- assert.equal((buildersJs.match(/renderGreeting\(\)/g) || []).length, 3, 'rendered after /me, after progress and after the claimable feed — each half as it lands');
311
+ assert.doesNotMatch(buildersJs, /lastClaimableCount/, 'the greeting no longer needs the claimable count (task 1004780)');
312
+ assert.equal((buildersJs.match(/renderGreeting\(\)/g) || []).length, 2, 'rendered after /me and after progress — each as it lands');
320
313
  });
321
314
 
322
315
  // Boot hall-render.js against a stub DOM and a ctx of getters (the way
323
316
  // builders.js builds it) and read what the greeting and the card actually say.
324
- function bootHallRender({ me, progress = null, claimable = null, leaderboard = [] }) {
317
+ function bootHallRender({ me, progress = null, leaderboard = [] }) {
325
318
  const els = new Map();
326
319
  const makeEl = () => ({
327
320
  dataset: {}, style: {}, textContent: '', hidden: false, _html: '', innerHTML: '',
@@ -359,7 +352,7 @@ function bootHallRender({ me, progress = null, claimable = null, leaderboard = [
359
352
  instanceRepo: null, newcomerGated: false,
360
353
  get lastMeState() { return me; }, get lastCostState() { return me.cost || null; }, get lastKarmaState() { return me.karma || null; },
361
354
  get lastNeedsState() { return me.needs || null; }, get lastRebaseState() { return me.needs_rebase_tasks || null; },
362
- get lastProgressState() { return progress; }, get lastClaimableCount() { return claimable; },
355
+ get lastProgressState() { return progress; },
363
356
  };
364
357
  const api = windowObj.OTBHallRender(ctx);
365
358
  api.lbState.rows = leaderboard;
@@ -375,30 +368,16 @@ const ME = {
375
368
  const LEADERBOARD = [{ github_login: 'a', total_credits: 20000 }, { github_login: 'percy', total_credits: 14441 }, { github_login: 'b', total_credits: 3 }, { github_login: 'c', total_credits: 1 }];
376
369
  const PROGRESS = { progress: [{ version_id: 'BONGOS-V1', status: 'building', total_count: 605, percent_complete: 75 }, { version_id: 'V2', status: 'planning', total_count: 9000, percent_complete: 0 }] };
377
370
 
378
- test('RUN: the greeting names the builder, and the sub-line says how far the version is and what is claimable', () => {
379
- const { api, el } = bootHallRender({ me: ME, progress: PROGRESS, claimable: 5 });
371
+ test('RUN: the greeting names the builder, and the version tab carries how far the version is', () => {
372
+ const { api, el } = bootHallRender({ me: ME, progress: PROGRESS });
380
373
  api.renderGreeting();
381
374
  assert.match(el('hall-h1').textContent, /^Good (morning|afternoon|evening), Percy Jackson$/);
382
- assert.equal(el('hall-sub').textContent, 'BONGOS-V1 is 75 percent complete. Five tasks are claimable right now.');
383
- assert.equal(el('hall-sub').hidden, false);
384
- });
385
-
386
- test('RUN: each half of the sentence renders as its data lands, and the line hides with neither', () => {
375
+ assert.equal(el('home-version-name').textContent, 'BONGOS-V1', 'the building version, not the bigger planning one');
376
+ assert.equal(el('home-version-pct').textContent, '75% complete');
377
+ assert.equal(el('home-version').hidden, false);
387
378
  const none = bootHallRender({ me: ME });
388
379
  none.api.renderGreeting();
389
- assert.equal(none.el('hall-sub').hidden, true, 'nothing to say yet');
390
- const onlyProgress = bootHallRender({ me: ME, progress: PROGRESS });
391
- onlyProgress.api.renderGreeting();
392
- assert.equal(onlyProgress.el('hall-sub').textContent, 'BONGOS-V1 is 75 percent complete.');
393
- const zero = bootHallRender({ me: ME, progress: PROGRESS, claimable: 0 });
394
- zero.api.renderGreeting();
395
- assert.match(zero.el('hall-sub').textContent, /Nothing is claimable right now\.$/);
396
- const one = bootHallRender({ me: ME, claimable: 1 });
397
- one.api.renderGreeting();
398
- assert.equal(one.el('hall-sub').textContent, 'One task is claimable right now.');
399
- const many = bootHallRender({ me: ME, claimable: 63 });
400
- many.api.renderGreeting();
401
- assert.equal(many.el('hall-sub').textContent, '63 tasks are claimable right now.');
380
+ assert.equal(none.el('home-version').hidden, true, 'no progress yet: no tab');
402
381
  });
403
382
 
404
383
  test('RUN: the Standing card carries the three facts, the place as "2nd of 4", and no banner', () => {
@@ -97,8 +97,12 @@ test('closing natively (Escape) also returns the slice, so a reopen still finds
97
97
  const { ctx, page } = await bootInbox();
98
98
  await page.click('[data-attn-open="attention-blockers"]');
99
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'));
100
+ // `open` turns false at once, but the dialog's `close` EVENT (which moves the slice home) is
101
+ // queued after it, so wait for the slice itself rather than for `open` (task 1004780 — reading
102
+ // straight after `open` flipped raced the event and failed CI about half the time).
103
+ const back = await page.waitForFunction(
104
+ () => !document.getElementById('attention-dialog').open && !!document.querySelector('#attention-slices > #attention-blockers'),
105
+ null, { timeout: 3000 }).then(() => true, () => false);
102
106
  assert.equal(back, true, 'the dialog close event moves the slice home');
103
107
  await page.click('[data-attn-open="attention-blockers"]');
104
108
  const rows = await page.evaluate(() => document.querySelectorAll('#attention-dialog-body .ginbox__item').length);