@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.
- package/.bongos-core.json +68 -28
- package/docs/copy-inventory.md +14 -10
- package/docs/copy-registry.json +48 -12
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +195 -195
- package/modules/copy-desk/skills/tweak/SKILL.md +28 -0
- package/modules/discord/bug-attachments.js +35 -8
- package/modules/hall-ui/public/craft-mode.js +276 -21
- package/modules/hall-ui/public/craft-review.css +227 -0
- package/modules/hall-ui/public/craft-review.js +64 -0
- package/modules/hall-ui/public/style.css +1 -0
- package/modules/hall-ui/records/craft-mode.md +8 -0
- package/modules/lifecycle/autopickup-files.js +140 -0
- package/modules/lifecycle/db-ship.js +2 -1
- package/modules/lifecycle/routes/tasks.js +4 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/bug-attachment-audit.js +13 -7
- package/scripts/gds/bug-attachment-recover.js +198 -0
- package/scripts/gds/copy-apply.js +17 -2
- package/scripts/gds/tweak-halt.js +69 -0
- package/src/module-api.js +1 -1
- package/tests/bug_attachment_audit.mjs +4 -3
- package/tests/bug_attachment_recover.mjs +128 -0
- package/tests/bug_attachments.mjs +30 -0
- package/tests/craft_review_mode.mjs +307 -0
- package/tests/page_notes_panel.mjs +4 -1
- package/tests/tweak_batch_apply.mjs +16 -0
- package/tests/tweak_unattended.mjs +274 -0
|
@@ -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
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
//
|
|
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
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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,
|