@bongos/core 1.21.109 → 1.21.111

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.
@@ -72,6 +72,34 @@ Heal whatever ship-check names first (`git add` new files before it). Run ship.j
72
72
 
73
73
  A failed grade is an ordinary one: read it (`GET /api/bongos/tasks/<task-id>?include=grade`), fix, commit, `node scripts/gds/ship.js <task-id> --regrade`. If the branch did not publish, a plain `node scripts/gds/ship.js <task-id>` publishes it again without re-grading.
74
74
 
75
+ ## Unattended mode: `/tweak --page <page-id> --unattended`
76
+
77
+ The Autobongos tweak lane (ADR 0371 D1, D8) runs this skill with nobody at the terminal. It names ONE page and claims that page's submitted round with the `auto_pickup` mark before you start. In this mode:
78
+
79
+ - **Never ask.** Every question the steps above would put to a person is a halt instead.
80
+ - **Work only the named page.** Skip step 1's queue read; the round is the one you hold. Pass `--page <page-id>` to every script: `copy-apply.js --batch --page <page-id> --unattended` refuses a round for another page (exit 5) and, on any non-zero exit, releases the round with a plain note itself.
81
+ - **Change only the page's own files.** No shell cache-bust, no hand fix of a refused line, no edit outside the page's files in `docs/page-inventory.json` (besides the copy registry, the page readings and the generated maps ship.js refreshes), and no session-log file (it would move `CLAUDE.md`, a protected path). A rewrite that needs anything else is already its own engineer task.
82
+ - **Ship one round, once, graded.** Never `--skip-grade`, `--force` or `--allow-empty`. Stop at WAITING FOR THE ARTIST.
83
+
84
+ **On any halt, release and exit.** Run the matching line and stop; do not retry, and do not try another round:
85
+
86
+ ```bash
87
+ node scripts/gds/tweak-halt.js <task-id> --code <code>
88
+ ```
89
+
90
+ | What happened | `--code` |
91
+ |---|---|
92
+ | the claim was refused | `claim_refused` (nothing to release: just exit) |
93
+ | `copy-apply` exited non-zero | already released by `--unattended` |
94
+ | `ship-check` named something stale it cannot heal by regenerating | `ship_check_stale` |
95
+ | the grade came back GRADER UNAVAILABLE | `grader_unavailable` |
96
+ | the grade failed | `grade_failed` |
97
+ | the diff would touch a file outside the page | `outside_page` |
98
+
99
+ The note is fixed wording per code, so nothing you write reaches the round.
100
+
101
+ **The server holds these rules, not you.** A page tweak reaches `confirmed` only through its artist's approval or the owner's override (`POST /tasks/:id/confirm` refuses it), and the publish of an `auto_pickup` round whose pull request touches a file outside the page, or a protected path, is refused and its pull request closed. Breaking a rule above gets the round refused, not landed.
102
+
75
103
  ## What happens next (not yours)
76
104
 
77
105
  The artist approves in the studio's Approval queue (TW12), which lands it like any confirmed task, or sends it back with a note, which returns the round to the queue for the next `/tweak`.
@@ -55,10 +55,17 @@ const NAME_RE = /^bug-[a-z0-9]{1,40}-[0-9a-f]{16}\.(png|jpg|gif|webp)$/;
55
55
  // used to be __dirname/../../var, which is the repo root in a single checkout and
56
56
  // `node_modules/@bongos/core/var` on an instance running a vendored core — where
57
57
  // every core reinstall wipes it. That is the task 1002237 shape, and these files
58
- // are the one thing here that cannot be regenerated: a Discord CDN URL expires in
59
- // about a day, so our copy IS the image. All five attachments this instance ever
60
- // stored are already gone (the 2026-07-05 droplet move carried the database and
61
- // not var/), which is what this default is meant to stop happening again.
58
+ // are the hardest thing here to get back. What expires in about a day is the URL
59
+ // SIGNATURE (`?ex=&is=&hm=`), not the attachment: Discord keeps the file for as
60
+ // long as its message exists, and `GET /channels/{channel}/messages/{message}`
61
+ // with the bot token returns a freshly-signed URL. The stored filename carries
62
+ // the message id (`bug-<messageId>-<rand>.<ext>`), so a lost file is still
63
+ // addressable — `scripts/gds/bug-attachment-recover.js` re-fetches it to its
64
+ // original name (task 1004667). It stops working the day the message is deleted
65
+ // or the channel history is gone, so our copy is still the copy to keep. All five
66
+ // attachments this instance ever stored went missing from disk (the 2026-07-05
67
+ // droplet move carried the database and not var/), which is what this default is
68
+ // meant to stop happening again.
62
69
  //
63
70
  // BUG_ATTACHMENTS_DIR still wins, and on a box where the instance root is itself
64
71
  // impermanent that override is the answer — the standup recipe says so.
@@ -155,12 +162,19 @@ function sniffMatches(buf, ext) {
155
162
  // success, or { ok:false, reason } when the image is refused (never throws on a
156
163
  // refusal; throws only on an unexpected fs error the caller wraps). `messageId`
157
164
  // just seeds the (still-random) filename so attachments group readably.
165
+ //
166
+ // `file` (task 1004667) writes under a GIVEN name instead of a fresh one — the
167
+ // recovery path, re-fetching a lost image back to the exact name a task
168
+ // description already links. The name must pass isSafeName, its extension must
169
+ // match the bytes actually fetched, and an existing file is never overwritten.
170
+ // Same download path, same guards: recovery does not grow a second one.
158
171
  // How many manual redirect hops we'll re-validate + follow before giving up.
159
172
  // Discord CDN attachments are direct (0 redirects) in practice; a small bound
160
173
  // covers a CDN edge bounce without becoming an open redirect-follow loop.
161
174
  const MAX_REDIRECT_HOPS = 3;
162
175
 
163
- async function persistImageFromUrl(url, { messageId = 'x', fetchImpl = globalThis.fetch, dir = attachmentsDir() } = {}) {
176
+ async function persistImageFromUrl(url, { messageId = 'x', fetchImpl = globalThis.fetch, dir = attachmentsDir(), file: givenFile = null } = {}) {
177
+ if (givenFile != null && !isSafeName(givenFile)) return { ok: false, reason: 'bad_name' };
164
178
  if (!isAllowedAttachmentHost(url)) return { ok: false, reason: 'bad_host' };
165
179
  // L3 residual (red-team 2026-06-12): fetch with redirect:'manual' and re-run
166
180
  // the host allowlist on every Location before following. With redirect:'follow'
@@ -202,10 +216,23 @@ async function persistImageFromUrl(url, { messageId = 'x', fetchImpl = globalThi
202
216
  if (buf.length > MAX_BYTES) return { ok: false, reason: 'too_big', bytes: buf.length };
203
217
  if (!sniffMatches(buf, ext)) return { ok: false, reason: 'sniff_mismatch' };
204
218
 
205
- const idSlug = String(messageId).replace(/[^a-z0-9]/gi, '').slice(0, 40).toLowerCase() || 'x';
206
- const file = `bug-${idSlug}-${crypto.randomBytes(8).toString('hex')}.${ext}`;
219
+ let file;
220
+ if (givenFile != null) {
221
+ if (path.extname(givenFile).slice(1) !== ext) return { ok: false, reason: 'ext_mismatch', type: rawType };
222
+ file = givenFile;
223
+ } else {
224
+ const idSlug = String(messageId).replace(/[^a-z0-9]/gi, '').slice(0, 40).toLowerCase() || 'x';
225
+ file = `bug-${idSlug}-${crypto.randomBytes(8).toString('hex')}.${ext}`;
226
+ }
207
227
  await fs.mkdir(dir, { recursive: true });
208
- await fs.writeFile(path.join(dir, file), buf, { mode: 0o640 });
228
+ try {
229
+ // 'wx' only matters for a given name (a random one cannot collide): a
230
+ // recovery never clobbers a file that is already there.
231
+ await fs.writeFile(path.join(dir, file), buf, { mode: 0o640, flag: givenFile != null ? 'wx' : 'w' });
232
+ } catch (e) {
233
+ if (e && e.code === 'EEXIST') return { ok: false, reason: 'exists' };
234
+ throw e;
235
+ }
209
236
  return { ok: true, file, url: `/api/bongos/bug-attachments/${file}`, bytes: buf.length };
210
237
  }
211
238
 
@@ -9,7 +9,9 @@
9
9
  // It is a MODE the existing hall wears, never a second hall (the founding-mode
10
10
  // precedent, docs/design/project-startup-direction.md). The nav follows the mode
11
11
  // (task 1004493, MODE_TABS below), and so does the look (task 1004495, LOOKS
12
- // below); the mode tools are their own tasks (1004496 onward). Legacy mode, a
12
+ // below); the mode tools are their own tasks (1004496 onward), and Artistry's
13
+ // Review button opens review mode (task 1004686): the hall wears another
14
+ // mode's experience while an artist reviews it. Legacy mode, a
13
15
  // fifth choice, was removed (task 1004567): Building already shows every tab,
14
16
  // and a stored 'legacy' now reads as no mode.
15
17
  //
@@ -32,6 +34,7 @@
32
34
  governing: '<path d="M2 6 8 2.5 14 6M3.5 6.5v6M6.5 6.5v6M9.5 6.5v6M12.5 6.5v6M2 13.5h12"/>',
33
35
  brush: '<path d="M13.6 2.4 7.4 8.6"/><path d="M7.4 8.6c-1.3-.7-3-.1-3.4 1.5-.3 1.3-.8 2.2-1.9 2.9 2.4.7 4.6.1 5.6-1.4.6-.9.5-2.1-.3-3Z"/>',
34
36
  note: '<path d="M9.5 2.5h-6v11h9v-6"/><path d="M5.5 8h5M5.5 10.5h3.5"/><path d="M12.5 1.5v4M10.5 3.5h4"/>',
37
+ write: '<path d="M2.5 13.5h11"/><path d="M10.4 2.6l2.9 2.9-6.4 6.4H4V9z"/>',
35
38
  modes: '<rect x="2.5" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="9" y="2.5" width="4.5" height="4.5" rx="1"/><rect x="2.5" y="9" width="4.5" height="4.5" rx="1"/><rect x="9" y="9" width="4.5" height="4.5" rx="1"/>',
36
39
  };
37
40
  // The four crafts. `short` is the switch's label; `line` is the
@@ -266,7 +269,8 @@
266
269
  artistry: [
267
270
  // Named "Artist review" since task 1004499: the public site's button
268
271
  // (public-landing assets/artist-review.js) opens this same panel.
269
- { id: 'palette', icon: 'artistry', short: 'Review', label: 'Artist review: write feedback on this page', script: 'page-notes.js', api: 'OTBPageNotes' },
272
+ // `menu`: hovering it opens the review-mode menu (task 1004686, below).
273
+ { id: 'palette', icon: 'artistry', short: 'Review', label: 'Artist review: write feedback on this page', script: 'page-notes.js', api: 'OTBPageNotes', menu: true },
270
274
  // Task 1004497: this page's words, edited where they stand as a proposed wording change.
271
275
  { id: 'paintbrush', icon: 'brush', short: 'Edit words', label: 'Edit this page\'s words', script: 'paintbrush.js', api: 'OTBPaintbrush' },
272
276
  ],
@@ -297,29 +301,270 @@
297
301
  for (var m in TOOLS) for (var i = 0; i < TOOLS[m].length; i++) if (TOOLS[m][i].id === id) return TOOLS[m][i];
298
302
  return null;
299
303
  }
304
+ // The tools a mode shows. Reviewing another experience (task 1004686), the
305
+ // artist keeps Artistry's tools and gains that experience's own after them.
306
+ function toolsFor(mode) {
307
+ var own = Object.prototype.hasOwnProperty.call(TOOLS, mode) ? TOOLS[mode] : [];
308
+ if (!review || mode === 'artistry') return own;
309
+ return TOOLS.artistry.concat(own);
310
+ }
311
+ function openTool(t, b) {
312
+ loadTool(t).then(function (api) { api.open(); }, function () { b.setAttribute('aria-disabled', 'true'); });
313
+ }
314
+ function toolButton(t) {
315
+ var b = document.createElement('button');
316
+ b.type = 'button';
317
+ b.id = 'craft-tool-' + t.id;
318
+ b.className = 'craft-tool craft-tool--' + t.id;
319
+ b.setAttribute('data-craft-tool', t.id);
320
+ b.setAttribute('aria-label', t.label);
321
+ b.title = t.label;
322
+ b.innerHTML = icon(t.icon, 'craft-tool__icon') + '<span class="craft-tool__label">' + esc(t.short) + '</span>';
323
+ b.addEventListener('click', function (e) {
324
+ // A phone has no hover, so there a tap on Review opens its menu instead.
325
+ if (t.menu && narrow()) { toggleReviewMenu(e && e.detail === 0); return; }
326
+ openTool(t, b);
327
+ });
328
+ return t.menu ? reviewGroup(t, b) : b;
329
+ }
330
+ // The node a tool occupies in the bar: its button, or Review's whole group.
331
+ function toolNode(t) {
332
+ var b = document.getElementById('craft-tool-' + t.id);
333
+ return b && t.menu && b.parentNode && b.parentNode.id === REVIEW_GROUP ? b.parentNode : b;
334
+ }
300
335
  function applyTools(mode) {
301
336
  var sw = document.getElementById('craft-switch');
302
- var own = Object.prototype.hasOwnProperty.call(TOOLS, mode) ? TOOLS[mode] : [];
337
+ var own = toolsFor(mode);
303
338
  var old = document.querySelectorAll ? document.querySelectorAll('[data-craft-tool]') : [];
304
339
  for (var i = old.length - 1; i >= 0; i--) {
305
- if (!own.some(function (t) { return t.id === old[i].getAttribute('data-craft-tool'); })) remove(old[i]);
340
+ if (own.some(function (t) { return t.id === old[i].getAttribute('data-craft-tool'); })) continue;
341
+ var t0 = toolFor(old[i].getAttribute('data-craft-tool'));
342
+ remove(t0 ? toolNode(t0) : old[i]);
306
343
  }
307
344
  if (!sw || !sw.parentNode) return;
345
+ // Right to left, each missing tool goes in front of the one after it, so
346
+ // the bar reads in TOOLS order whichever tools were already there.
347
+ var before = sw;
348
+ for (var j = own.length - 1; j >= 0; j--) {
349
+ var node = toolNode(own[j]);
350
+ if (!node) node = sw.parentNode.insertBefore(toolButton(own[j]), before);
351
+ before = node;
352
+ }
353
+ // A guest is the reviewed experience's own tool beside the artist's: under
354
+ // 360px the bar has no room for three, so craft-review.css folds it away.
308
355
  own.forEach(function (t) {
309
- if (document.getElementById('craft-tool-' + t.id)) return;
310
- var b = document.createElement('button');
311
- b.type = 'button';
312
- b.id = 'craft-tool-' + t.id;
313
- b.className = 'craft-tool craft-tool--' + t.id;
314
- b.setAttribute('data-craft-tool', t.id);
315
- b.setAttribute('aria-label', t.label);
316
- b.title = t.label;
317
- b.innerHTML = icon(t.icon, 'craft-tool__icon') + '<span class="craft-tool__label">' + esc(t.short) + '</span>';
318
- b.addEventListener('click', function () {
319
- loadTool(t).then(function (api) { api.open(); }, function () { b.setAttribute('aria-disabled', 'true'); });
320
- });
321
- sw.parentNode.insertBefore(b, sw);
356
+ var el = document.getElementById('craft-tool-' + t.id);
357
+ if (el && el.classList) el.classList.toggle('craft-tool--guest', !!review && TOOLS.artistry.indexOf(t) === -1);
322
358
  });
359
+ var on = document.getElementById('craft-tool-palette');
360
+ if (on) {
361
+ on.classList.toggle('craft-tool--reviewing', !!review);
362
+ var word = on.querySelector && on.querySelector('.craft-tool__label');
363
+ if (word) word.textContent = review ? 'Reviewing' : 'Review';
364
+ }
365
+ }
366
+
367
+ // ---------- artist review mode (task 1004686) ----------
368
+ // Hovering Artistry's Review button opens a menu of the four experiences
369
+ // (owner, 2026-10-09, in the owner's order and words). Picking one makes the hall
370
+ // wear that mode on every page — its tabs, its look, its tools — the way a
371
+ // builder in it sees it, while the artist keeps Artistry's tools and small
372
+ // sky-style doodles are scribbled into the top bar's empty space (craft-review.js draws them,
373
+ // loaded only in review mode; craft-review.css styles it and the menu).
374
+ // The artist's own mode stays Artistry: the experience is this session's
375
+ // alone (sessionStorage), and choosing any mode from the switch or the
376
+ // picker ends it. Cosmetic like the mode: a tab the gate hides stays hidden.
377
+ var REVIEW_KEY = 'hall-craft-review';
378
+ var REVIEW_GROUP = 'craft-review';
379
+ var EXPERIENCES = [
380
+ { id: 'ideating', label: 'Ideator experience' },
381
+ { id: 'governing', label: 'Governing experience' },
382
+ { id: 'building', label: 'Building experience' },
383
+ { id: 'artistry', label: 'Artistry experience' },
384
+ ];
385
+ // The experience being reviewed, or null. Read from the session once, then
386
+ // held here, so a browser whose storage throws still reviews this page.
387
+ var review = null;
388
+ function storedReview() {
389
+ return read() === 'artistry' ? valid(get('sessionStorage', REVIEW_KEY)) : null;
390
+ }
391
+ // The mode the hall wears: the experience under review, else the chosen mode.
392
+ function wearing() { return review || read(); }
393
+ function startReview(mode) {
394
+ mode = valid(mode);
395
+ if (!mode) return;
396
+ set('sessionStorage', MODE_KEY, 'artistry');
397
+ set('sessionStorage', ASKED_KEY, '1');
398
+ set('sessionStorage', REVIEW_KEY, mode);
399
+ review = mode;
400
+ apply(mode);
401
+ }
402
+ function stopReview() {
403
+ drop('sessionStorage', REVIEW_KEY);
404
+ review = null;
405
+ apply('artistry');
406
+ }
407
+
408
+ // The menu. Hover opens it where a pointer can hover; the chevron (or, on a
409
+ // phone, Review itself) opens it by click, tap or keyboard. Built on every
410
+ // open, so the checked experience is always the live one.
411
+ var closeTimer = null;
412
+ // Hover is a mouse's alone: a touch also fires pointer events, and a laptop
413
+ // with a touch screen can report no hover at all, so ask the pointer itself.
414
+ function byMouse(e) { return !!e && e.pointerType === 'mouse'; }
415
+ function reviewMenuHtml() {
416
+ var items = '<button class="craft-review__item" type="button" role="menuitem" data-review-act="write">' +
417
+ icon('write', 'craft-review__icon') + '<span>Write feedback on this page</span></button>' +
418
+ '<p class="craft-review__head" role="presentation">Review mode</p>';
419
+ items += EXPERIENCES.map(function (x) {
420
+ var on = review === x.id;
421
+ return '<button class="craft-review__item" type="button" role="menuitemradio" aria-checked="' + (on ? 'true' : 'false') + '" data-review-pick="' + x.id + '">' +
422
+ icon(x.id, 'craft-review__icon') + '<span>' + esc(x.label) + '</span>' +
423
+ (on ? '<svg class="craft-review__check" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3.5 8.5l3 3 6-7"/></svg>' : '') +
424
+ '</button>';
425
+ }).join('');
426
+ if (review) {
427
+ items += '<button class="craft-review__item craft-review__item--stop" type="button" role="menuitem" data-review-act="stop">' +
428
+ '<svg class="craft-review__icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" aria-hidden="true"><path d="M4.5 4.5l7 7M11.5 4.5l-7 7"/></svg><span>Stop reviewing</span></button>';
429
+ }
430
+ return items;
431
+ }
432
+ function menuEl() { return document.getElementById(REVIEW_GROUP + '-menu'); }
433
+ function menuItems() {
434
+ var m = menuEl();
435
+ return m && m.querySelectorAll ? Array.prototype.slice.call(m.querySelectorAll('[role^="menuitem"]')) : [];
436
+ }
437
+ function openReviewMenu(focusFirst) {
438
+ var m = menuEl(), more = document.getElementById(REVIEW_GROUP + '-more');
439
+ if (!m) return;
440
+ clearTimeout(closeTimer);
441
+ if (m.hasAttribute('hidden')) {
442
+ m.innerHTML = reviewMenuHtml();
443
+ m.removeAttribute('hidden');
444
+ if (more) more.setAttribute('aria-expanded', 'true');
445
+ document.addEventListener('click', onOutside, true);
446
+ }
447
+ if (focusFirst) {
448
+ var first = menuItems()[0];
449
+ if (first && first.focus) first.focus();
450
+ }
451
+ }
452
+ function closeReviewMenu(refocus) {
453
+ var m = menuEl(), more = document.getElementById(REVIEW_GROUP + '-more');
454
+ clearTimeout(closeTimer);
455
+ document.removeEventListener('click', onOutside, true);
456
+ if (!m || m.hasAttribute('hidden')) return;
457
+ m.setAttribute('hidden', '');
458
+ if (more) more.setAttribute('aria-expanded', 'false');
459
+ if (refocus) {
460
+ var back = narrow() ? document.getElementById('craft-tool-palette') : more;
461
+ if (back && back.focus) back.focus();
462
+ }
463
+ }
464
+ function toggleReviewMenu(focusFirst) {
465
+ var m = menuEl();
466
+ if (m && !m.hasAttribute('hidden')) closeReviewMenu(false); else openReviewMenu(focusFirst);
467
+ }
468
+ function onOutside(e) {
469
+ var g = document.getElementById(REVIEW_GROUP);
470
+ if (g && g.contains && !g.contains(e.target)) closeReviewMenu(false);
471
+ }
472
+ function onMenuClick(e) {
473
+ var btn = e.target.closest && e.target.closest('button');
474
+ if (!btn) return;
475
+ var pick = btn.getAttribute('data-review-pick');
476
+ var act = btn.getAttribute('data-review-act');
477
+ if (!pick && !act) return;
478
+ closeReviewMenu(false);
479
+ if (pick) startReview(pick);
480
+ else if (act === 'stop') stopReview();
481
+ else if (act === 'write') openTool(toolFor('palette'), document.getElementById('craft-tool-palette'));
482
+ }
483
+ // The menu keys: arrows move, Home and End jump, Escape closes back to the
484
+ // button that opened it, Tab closes and lets focus move on.
485
+ function onMenuKey(e) {
486
+ var items = menuItems();
487
+ var at = items.indexOf(document.activeElement);
488
+ var to = null;
489
+ if (e.key === 'ArrowDown') to = (at + 1) % items.length;
490
+ else if (e.key === 'ArrowUp') to = (at - 1 + items.length) % items.length;
491
+ else if (e.key === 'Home') to = 0;
492
+ else if (e.key === 'End') to = items.length - 1;
493
+ else if (e.key === 'Escape') { e.preventDefault(); closeReviewMenu(true); return; }
494
+ else if (e.key === 'Tab') { closeReviewMenu(false); return; }
495
+ if (to === null || !items[to]) return;
496
+ e.preventDefault();
497
+ items[to].focus();
498
+ }
499
+ function reviewGroup(t, b) {
500
+ var g = document.createElement('span');
501
+ g.id = REVIEW_GROUP;
502
+ g.className = 'craft-review';
503
+ var more = document.createElement('button');
504
+ more.type = 'button';
505
+ more.id = REVIEW_GROUP + '-more';
506
+ more.className = 'craft-review__more';
507
+ more.setAttribute('aria-haspopup', 'menu');
508
+ more.setAttribute('aria-expanded', 'false');
509
+ more.setAttribute('aria-controls', REVIEW_GROUP + '-menu');
510
+ more.setAttribute('aria-label', 'Review mode: see the hall as another mode');
511
+ more.title = 'Review mode: see the hall as another mode';
512
+ more.innerHTML = '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 4.5 6 7.5 9 4.5"/></svg>';
513
+ more.addEventListener('click', function (e) { toggleReviewMenu(e && e.detail === 0); });
514
+ more.addEventListener('keydown', function (e) {
515
+ if (e.key === 'ArrowDown') { e.preventDefault(); openReviewMenu(true); }
516
+ });
517
+ var menu = document.createElement('div');
518
+ menu.id = REVIEW_GROUP + '-menu';
519
+ menu.className = 'craft-review__menu';
520
+ menu.setAttribute('role', 'menu');
521
+ menu.setAttribute('aria-label', 'Review mode');
522
+ menu.setAttribute('hidden', '');
523
+ menu.addEventListener('click', onMenuClick);
524
+ menu.addEventListener('keydown', onMenuKey);
525
+ b.addEventListener('keydown', function (e) {
526
+ if (e.key === 'ArrowDown') { e.preventDefault(); openReviewMenu(true); }
527
+ });
528
+ g.addEventListener('pointerenter', function (e) { if (byMouse(e)) openReviewMenu(false); });
529
+ g.addEventListener('pointerleave', function (e) {
530
+ if (!byMouse(e)) return;
531
+ clearTimeout(closeTimer);
532
+ closeTimer = setTimeout(function () { closeReviewMenu(false); }, 220);
533
+ });
534
+ g.appendChild(b);
535
+ g.appendChild(more);
536
+ g.appendChild(menu);
537
+ return g;
538
+ }
539
+
540
+ // The scribble wall: the top bar's empty space, doodled on while reviewing.
541
+ // craft-review.js draws it (decorative, aria-hidden); every motion is CSS,
542
+ // so the reduced-motion kill switch leaves three finished doodles standing.
543
+ var WALL = 'craft-graffiti';
544
+ var wallTool = { script: 'craft-review.js', api: 'OTBCraftReview' };
545
+ function applyWall(mode) {
546
+ var wall = document.getElementById(WALL);
547
+ var root = document.documentElement;
548
+ if (!review) {
549
+ remove(wall);
550
+ if (root.removeAttribute) root.removeAttribute('data-craft-review');
551
+ return;
552
+ }
553
+ if (root.setAttribute) root.setAttribute('data-craft-review', mode);
554
+ var side = document.querySelector && document.querySelector('#app-topbar > .topbar-side');
555
+ if (wall && wall.getAttribute('data-wall') === mode) return;
556
+ remove(wall);
557
+ if (!side || !side.parentNode) return;
558
+ wall = document.createElement('div');
559
+ wall.id = WALL;
560
+ wall.className = 'craft-graffiti';
561
+ wall.setAttribute('data-wall', mode);
562
+ wall.setAttribute('aria-hidden', 'true');
563
+ side.parentNode.insertBefore(wall, side);
564
+ loadTool(wallTool).then(function (api) {
565
+ var live = document.getElementById(WALL);
566
+ if (live === wall && review === mode) wall.innerHTML = api.wallHtml(mode);
567
+ }, function () { /* a bare wall; the menu still says you are reviewing */ });
323
568
  }
324
569
  // sessionStorage: this session's mode, or that the picker was already shown.
325
570
  var MODE_KEY = 'hall-craft';
@@ -371,13 +616,16 @@
371
616
  applyLook(mode);
372
617
  applyTallies(mode);
373
618
  applyTools(mode);
374
- try { document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: mode } })); } catch (_) { /* old engine */ }
619
+ applyWall(mode);
620
+ try { document.dispatchEvent(new CustomEvent('otb:craft', { detail: { mode: mode, review: !!review } })); } catch (_) { /* old engine */ }
375
621
  }
376
622
 
377
623
  // Choose a mode: this session always; across sessions only when asked to keep it.
624
+ // Choosing any mode, Artistry included, ends a review (task 1004686).
378
625
  function choose(mode, keep) {
379
626
  mode = valid(mode);
380
627
  if (!mode) return;
628
+ if (review) { drop('sessionStorage', REVIEW_KEY); review = null; closeReviewMenu(false); }
381
629
  set('sessionStorage', MODE_KEY, mode);
382
630
  set('sessionStorage', ASKED_KEY, '1');
383
631
  if (keep === true) set('localStorage', KEEP_KEY, mode);
@@ -389,7 +637,8 @@
389
637
  // ---------- the header switch ----------
390
638
  function switchHtml() {
391
639
  if (pageOptsOut()) return '';
392
- var current = read();
640
+ review = storedReview();
641
+ var current = review || read();
393
642
  return '<div class="craft-switch' + (current ? '' : ' craft-switch--unset') + '" id="craft-switch" role="group" aria-label="Mode">' +
394
643
  MODES.map(function (m) {
395
644
  return '<button class="craft-switch__opt craft-switch__opt--' + m.id + '" type="button" data-craft-opt="' + m.id + '"' +
@@ -551,7 +800,8 @@
551
800
  var mounted = false;
552
801
  function mount() {
553
802
  if (pageOptsOut()) return;
554
- apply(read());
803
+ review = storedReview();
804
+ apply(wearing());
555
805
  if (mounted) return;
556
806
  mounted = true;
557
807
  var sw = document.getElementById('craft-switch');
@@ -572,8 +822,13 @@
572
822
 
573
823
  window.OTBCraftMode = {
574
824
  MODES: IDS.slice(),
575
- current: read,
825
+ current: wearing,
576
826
  choose: choose,
827
+ // Review mode (task 1004686): the experience under review, or null.
828
+ reviewing: function () { return review; },
829
+ review: startReview,
830
+ stopReview: stopReview,
831
+ EXPERIENCES: EXPERIENCES.map(function (x) { return x.id; }),
577
832
  open: openPicker,
578
833
  ALWAYS_TABS: ALWAYS_TABS.slice(),
579
834
  tabsFor: tabsFor,