@bongos/core 1.19.1064 → 1.19.1065

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/.bongos-core.json +85 -40
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +11 -1
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/api/openapi.json +237 -4
  8. package/docs/api-reference.md +5 -2
  9. package/docs/copy-inventory.md +60 -41
  10. package/docs/copy-registry.json +228 -47
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-inventory.json +11 -2
  13. package/docs/page-readings.json +45 -22
  14. package/modules/copy-desk/page-decisions.js +71 -0
  15. package/modules/copy-desk/page-status.js +96 -0
  16. package/modules/copy-desk/pages.js +30 -0
  17. package/modules/copy-desk/routes/copy-desk.js +153 -0
  18. package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
  19. package/modules/hall-ui/public/approval-queue-lib.js +156 -0
  20. package/modules/hall-ui/public/approval-queue.css +286 -0
  21. package/modules/hall-ui/public/approval-queue.js +338 -0
  22. package/modules/hall-ui/public/studio.css +33 -0
  23. package/modules/hall-ui/public/studio.html +18 -1
  24. package/modules/hall-ui/public/studio.js +30 -0
  25. package/modules/hall-ui/public/studio.states.json +198 -0
  26. package/modules/hall-ui/records/approval-queue.md +32 -0
  27. package/modules/lifecycle/db-grade.js +80 -58
  28. package/modules/lifecycle/github-pr-close.js +41 -0
  29. package/modules/lifecycle/github-push.js +5 -2
  30. package/modules/lifecycle/lifecycle.js +12 -0
  31. package/modules/lifecycle/page-tweak-approve.js +242 -0
  32. package/modules/lifecycle/routes/gate-approvals.js +17 -2
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/release-notes.json +6 -0
  36. package/scripts/hall-preview/server.js +18 -0
  37. package/src/module-api.js +1 -1
  38. package/tests/copy_desk_page_approve.mjs +307 -0
  39. package/tests/fixtures/page-ledger.mjs +41 -1
  40. package/tests/gate_approvals.mjs +93 -0
  41. package/tests/hall_approval_queue.mjs +327 -0
  42. package/tests/hall_audit.mjs +4 -0
@@ -0,0 +1,327 @@
1
+ // tests/hall_approval_queue.mjs — the Approval queue panel (task 1004323 /
2
+ // BV2.TW12, ADR 0341 D7; docs/design/mocks/tweak-mode/Main.dc.html).
3
+ //
4
+ // Boots the REAL panel: approval-queue-lib.js and approval-queue.js in a vm
5
+ // over a mini-DOM, against a stub client. What the client answers for the
6
+ // queue is built by the REAL composer (page-status.js composeApprovals) over
7
+ // round descriptions the REAL pages.js wrote, so the panel is tested on the
8
+ // route's own shape, never a hand-written literal of it.
9
+ //
10
+ // The done-when, as far as a panel can prove it: open the queue from the studio
11
+ // (1 of 2), flip before and after over the applied page's renders, approve and
12
+ // ship (the page's next status and the next page named), send one back with a
13
+ // note, with no terminal.
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import vm from 'node:vm';
20
+ import { fileURLToPath } from 'node:url';
21
+ import { makeRunner, makeEl, walk, settle } from './helpers.mjs';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const PUB = path.join(ROOT, 'modules/hall-ui/public');
26
+ const read = (f) => fs.readFileSync(path.join(PUB, f), 'utf8');
27
+ const lib = require('../modules/hall-ui/public/approval-queue-lib.js');
28
+ const pages = require('../modules/copy-desk/pages.js');
29
+ const pageStatus = require('../modules/copy-desk/page-status.js');
30
+
31
+ const { test, summary } = makeRunner();
32
+
33
+ // ── the queue, through the real composer ───────────────────────────────────
34
+ const INVENTORY = {
35
+ pages: [
36
+ { id: 'landing:index', surface: 'landing', title: '{{productName}}: {{tagline}}', path: '/' },
37
+ { id: 'landing:projects', surface: 'landing', title: 'Projects', path: '/projects' },
38
+ ],
39
+ };
40
+ const ME = '7';
41
+ function round({ id, pageId, n, lines, refused = [], appliedAt, artist = ME }) {
42
+ const description = [
43
+ `Page tweak round ${n}.`,
44
+ pages.composeBatchBlock({ page_id: pageId, reading_hash: 'h', submitted_at: '2026-09-19T00:00:00Z', lines: lines.map((l) => ({ ...l, placement: 'placed', file: 'x.html', line: 1, string_id: 'a' })) }),
45
+ pages.composeAppliedBlock({ page_id: pageId, applied_at: appliedAt, reading_hash_after: 'h2', lines_after: lines.map((l) => l.after), refused }),
46
+ ].join('\n\n');
47
+ return { id, status: 'completed', grade_passed: true, description, source_ref: pages.roundSourceRef(pageId, n), holder_id: null, artist_id: artist, artist_login: 'me' };
48
+ }
49
+ const TASKS = [
50
+ { id: 40, status: 'shipped', description: '', source_ref: pages.roundSourceRef('landing:index', 1), shipped_at: '2026-09-01T00:00:00Z' },
51
+ round({
52
+ id: 41, pageId: 'landing:index', n: 2, appliedAt: '2026-09-21T00:00:00Z',
53
+ lines: [
54
+ { key: 'L0003', section: 'hero', before: 'AI agents claim tasks and ship the code.', after: 'Agents write the code.' },
55
+ { key: 'L0004', section: 'hero', before: '<img src=x onerror="window.pwned=1">', after: 'safe' },
56
+ ],
57
+ refused: [{ key: 'L0004', code: 'target_gone' }],
58
+ }),
59
+ round({ id: 42, pageId: 'landing:projects', n: 1, appliedAt: '2026-09-22T00:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All the projects' }] }),
60
+ ];
61
+ const queueFor = ({ crafts = ['artist'], tasks = TASKS } = {}) => ({ ok: true, ...pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts }) });
62
+ const url = (task, slot) => `/api/bongos/task-visuals/task-${task}-${String(slot.length).padStart(2, '0')}${'ab'.repeat(7)}.jpg`;
63
+ const slotsFor = (task, only = lib.PHASES.flatMap((p) => lib.DEVICES.flatMap((d) => lib.MODES.map((m) => lib.slotName(p, d, m))))) =>
64
+ ({ ok: true, task_id: String(task), slots: only.map((slot) => ({ slot, visual_url: url(task, slot), visual_alt: slot })) });
65
+
66
+ // vm-born objects fail a strict deepEqual on their prototype, so what the panel
67
+ // sent is compared through JSON.
68
+ // ── the mini-DOM ────────────────────────────────────────────────────────────
69
+ function augment(el) {
70
+ if (el._aug) return el;
71
+ el._aug = true;
72
+ Object.defineProperty(el, 'hidden', { get() { return 'hidden' in el.attrs; }, set(v) { if (v) el.attrs.hidden = ''; else delete el.attrs.hidden; }, configurable: true });
73
+ el.focus = () => { boot.focused = el; };
74
+ return el;
75
+ }
76
+ let boot = null;
77
+
78
+ function bootPanel({ answers = {}, writes = null, mode = 'light' } = {}) {
79
+ const body = augment(makeEl('body', {}));
80
+ const classes = new Set();
81
+ const requests = [];
82
+ const counts = [];
83
+ const byId = (id) => walk(body, (c) => (c.id === id ? c : null));
84
+ const documentObj = {
85
+ body,
86
+ documentElement: {
87
+ getAttribute: (k) => (k === 'data-theme' ? mode : null),
88
+ classList: { add: (c) => classes.add(c), remove: (c) => classes.delete(c), contains: (c) => classes.has(c) },
89
+ },
90
+ getElementById: byId,
91
+ createElement: (tag) => augment(makeEl(tag, {})),
92
+ get activeElement() { return boot.focused || null; },
93
+ };
94
+ const api = {
95
+ async request(method, u, reqBody) {
96
+ requests.push({ method, url: u, body: reqBody });
97
+ const key = `${method} ${u}`;
98
+ const hit = answers[key];
99
+ const val = typeof hit === 'function' ? hit(reqBody) : hit;
100
+ if (val === undefined) return { ok: false, status: 404, data: { error: { code: 'no_answer', message: key } } };
101
+ return val.__status ? { ok: false, status: val.__status, data: val.data } : { ok: true, status: 200, data: val };
102
+ },
103
+ };
104
+ const win = { matchMedia: () => ({ matches: false }) };
105
+ const ctx = vm.createContext({ window: win, document: documentObj, console, setTimeout, clearTimeout, encodeURIComponent });
106
+ win.window = win;
107
+ vm.runInContext(read('approval-queue-lib.js'), ctx, { filename: 'approval-queue-lib.js' });
108
+ vm.runInContext(read('approval-queue.js'), ctx, { filename: 'approval-queue.js' });
109
+ const panel = win.OTBApprovalQueue.mount({
110
+ api,
111
+ apiBase: '/api/bongos',
112
+ writes: writes || {
113
+ approve: (p) => `/api/bongos/copy-desk/pages/${p}/approve`,
114
+ sendBack: (p) => `/api/bongos/copy-desk/pages/${p}/send-back`,
115
+ },
116
+ onCount: (n) => counts.push(n),
117
+ });
118
+ const scrim = body.children[0];
119
+ const q = (sel) => scrim.querySelector(sel);
120
+ const qa = (sel) => scrim.querySelectorAll(sel);
121
+ const click = async (el) => { assert.ok(el, 'the control exists'); scrim.fire('click', { target: el }); await settle(); await settle(); };
122
+ boot = { body, scrim, q, qa, click, requests, counts, panel, classes, win, focused: null };
123
+ return boot;
124
+ }
125
+
126
+ const APPROVALS = 'GET /api/bongos/copy-desk/approvals';
127
+ const vis = (id) => `GET /api/bongos/tasks/${id}/visuals`;
128
+ const text = (el) => (el ? el.textContent.replace(/\s+/g, ' ').trim() : null);
129
+
130
+ // ── the pure rules: every word the panel says ──────────────────────────────
131
+
132
+ await test('lib: the artboard\'s words, from the route\'s own shape', () => {
133
+ const [home, projects] = queueFor().queue;
134
+ assert.equal(lib.eyebrow(home), 'approval queue · 1 of 2');
135
+ assert.equal(lib.eyebrow(projects), 'approval queue · 2 of 2');
136
+ assert.equal(lib.displayTitle(home), 'Home', 'an apex template title is Home, the artboard\'s own word');
137
+ assert.equal(lib.subLine(home), '· landing · 1 line rewritten, applied · 1 not applied');
138
+ assert.equal(lib.subLine(projects), '· landing · 1 line rewritten, applied');
139
+ assert.equal(lib.doorDetail(2), '2 pages applied, waiting for you');
140
+ assert.equal(lib.doorDetail(1), '1 page applied, waiting for you');
141
+ assert.equal(lib.doorDetail(0), 'nothing waiting for you');
142
+ assert.equal(lib.approvedLine(home, { status_after: 'Text tweaked 3x', next: { page: { title: 'Contact' } } }),
143
+ 'Approved. It ships now, and Home reads text tweaked 3x. Next in the queue: Contact.', 'the artboard line, word for word');
144
+ assert.equal(lib.approvedLine(home, { status_after: 'Text tweaked 2x', next: { page: null } }), 'Approved. It ships now, and Home reads text tweaked 2x. Nothing else is waiting.');
145
+ assert.equal(lib.sentBackLine(), 'Sent back with your note. It comes back to the queue once it is re-applied.');
146
+ assert.equal(lib.noteProblem('too long'), 'Say what needs changing in at least 10 characters.');
147
+ assert.equal(lib.noteProblem('The hero line is too long on a phone.'), null);
148
+ });
149
+
150
+ await test('lib: renders are picked by view, fall back to the nearest, and only server-minted URLs are kept', () => {
151
+ const idx = lib.renderIndex([
152
+ { slot: 'after-desktop-dark', visual_url: url(41, 'after-desktop-dark') },
153
+ { slot: 'after-phone-light', visual_url: url(41, 'after-phone-light') },
154
+ { slot: 'before-desktop-light', visual_url: 'https://evil.example/x.jpg' },
155
+ { slot: 'not-a-render', visual_url: url(41, 'not-a-render') },
156
+ ]);
157
+ assert.deepEqual(Object.keys(idx).sort(), ['after-desktop-dark', 'after-phone-light'], 'a foreign URL and an unknown slot are dropped');
158
+ const light = lib.pickRender(idx, { phase: 'after', device: 'desktop', mode: 'light' });
159
+ assert.equal(light.slot, 'after-desktop-dark');
160
+ assert.equal(light.exact, false, 'the other colour mode first, and it says so');
161
+ assert.equal(lib.pickRender(idx, { phase: 'after', device: 'phone', mode: 'light' }).exact, true);
162
+ assert.equal(lib.pickRender(idx, { phase: 'before', device: 'desktop', mode: 'light' }), null);
163
+ });
164
+
165
+ await test('lib: a changed line is marked after, and plain before; a refused line says why', () => {
166
+ const [home] = queueFor().queue;
167
+ const [ok, refused] = home.lines;
168
+ assert.deepEqual(lib.lineRow(ok, 'after'), { key: 'L0003', section: 'hero', text: 'Agents write the code.', marked: true, aside: 'was: AI agents claim tasks and ship the code.', refused: false });
169
+ assert.deepEqual(lib.lineRow(ok, 'before'), { key: 'L0003', section: 'hero', text: 'AI agents claim tasks and ship the code.', marked: false, aside: 'becomes: Agents write the code.', refused: false });
170
+ const r = lib.lineRow(refused, 'after');
171
+ assert.equal(r.marked, false, 'a refused line never looks applied');
172
+ assert.equal(r.aside, 'not applied: the page no longer shows it');
173
+ });
174
+
175
+ // ── the panel ───────────────────────────────────────────────────────────────
176
+
177
+ await test('open: the queue\'s head, the applied page\'s render, and the words beside it', async () => {
178
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41) } });
179
+ assert.equal(b.scrim.hidden, true, 'closed until opened');
180
+ await b.panel.open();
181
+ await settle(); await settle();
182
+ assert.equal(b.scrim.hidden, false);
183
+ assert.ok(b.classes.has('aq-open'), 'the page behind stops scrolling');
184
+ assert.equal(text(b.q('.aq-eyebrow')), 'approval queue · 1 of 2');
185
+ assert.equal(text(b.q('.aq-title')), 'Home · landing · 1 line rewritten, applied · 1 not applied');
186
+ const img = b.q('img');
187
+ assert.equal(img.attrs.src, url(41, 'after-desktop-light'), 'the AFTER render, desktop, in the hall\'s own mode');
188
+ assert.equal(img.attrs.alt, 'Home, after, desktop, light');
189
+ assert.deepEqual(b.qa('[data-phase]').map((x) => [text(x), x.attrs['aria-pressed']]), [['before', 'false'], ['after', 'true']]);
190
+ assert.equal(b.qa('mark').length, 1, 'one applied line carries the mark');
191
+ assert.equal(text(b.q('mark')), 'Agents write the code.');
192
+ assert.match(text(b.q('.aq-line--refused')), /not applied: the page no longer shows it/);
193
+ assert.equal(b.body.querySelectorAll('img').length, 1, 'a hostile line is text, never markup');
194
+ assert.equal(b.win.pwned, undefined);
195
+ assert.equal(text(b.q('.aq-hint')), 'highlighted: the words you changed');
196
+ assert.deepEqual(b.counts, [2], 'the door hears how many wait');
197
+ assert.ok(!b.requests.some((r) => r.method !== 'GET'), 'opening writes nothing');
198
+ });
199
+
200
+ await test('the switches: before and after, desktop and phone, light and dark, each a render of the applied branch', async () => {
201
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41) } });
202
+ await b.panel.open(); await settle(); await settle();
203
+ await b.click(b.q('[data-phase="before"]'));
204
+ assert.equal(b.q('img').attrs.src, url(41, 'before-desktop-light'));
205
+ assert.equal(b.qa('mark').length, 0, 'before is the page as it was: nothing marked');
206
+ await b.click(b.q('[data-device="phone"]'));
207
+ assert.equal(b.q('img').attrs.src, url(41, 'before-phone-light'));
208
+ assert.match(b.q('img').attrs.class, /aq-render--phone/);
209
+ await b.click(b.q('[data-mode="dark"]'));
210
+ assert.equal(b.q('img').attrs.src, url(41, 'before-phone-dark'));
211
+ assert.equal(b.requests.filter((r) => r.url.endsWith('/visuals')).length, 1, 'the renders are read once per round');
212
+ });
213
+
214
+ await test('a missing render says what it shows instead; no renders at all means nothing to approve from', async () => {
215
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41, ['after-desktop-dark']) } });
216
+ await b.panel.open(); await settle(); await settle();
217
+ assert.equal(b.q('img').attrs.src, url(41, 'after-desktop-dark'));
218
+ assert.equal(text(b.q('.aq-frame__swap')), 'No desktop light render; showing desktop dark.');
219
+ const none = bootPanel({ answers: { [APPROVALS]: queueFor(), [vis(41)]: slotsFor(41, []) } });
220
+ await none.panel.open(); await settle(); await settle();
221
+ assert.equal(none.q('img'), null);
222
+ assert.match(text(none.q('.aq-frame__note')), /No render is attached/);
223
+ assert.ok(none.q('[data-approve]').disabled, 'approve waits for a picture of the page');
224
+ assert.ok(!none.q('[data-open-note]').disabled, 'sending it back does not');
225
+ });
226
+
227
+ await test('approve and ship: one POST through the host\'s write, then the artboard\'s line and the next page', async () => {
228
+ const b = bootPanel({
229
+ answers: {
230
+ [APPROVALS]: queueFor(),
231
+ [vis(41)]: slotsFor(41),
232
+ 'POST /api/bongos/copy-desk/pages/landing:index/approve': { ok: true, outcome: 'approved', status_after: 'Text tweaked 2x', next: { count: 1, page: { page_id: 'landing:projects', title: 'Projects', path: '/projects' } } },
233
+ },
234
+ });
235
+ await b.panel.open(); await settle(); await settle();
236
+ await b.click(b.q('[data-approve]'));
237
+ const posts = JSON.parse(JSON.stringify(b.requests.filter((r) => r.method === 'POST')));
238
+ assert.deepEqual(posts, [{ method: 'POST', url: '/api/bongos/copy-desk/pages/landing:index/approve', body: { task_id: '41' } }], 'the round the artist looked at is pinned');
239
+ assert.equal(text(b.q('.aq-outcome')), 'Approved. It ships now, and Home reads text tweaked 2x. Next in the queue: Projects.');
240
+ assert.equal(b.q('[data-approve]'), null, 'decided: the buttons are gone');
241
+ assert.deepEqual(b.counts, [2, 1]);
242
+ await b.click(b.q('[data-next]'));
243
+ assert.equal(text(b.q('.aq-eyebrow')), 'approval queue · 2 of 2');
244
+ assert.equal(text(b.q('.aq-title')).startsWith('Projects'), true);
245
+ });
246
+
247
+ await test('send it back: the note opens in place, a short one is refused here, a real one is sent', async () => {
248
+ const b = bootPanel({
249
+ answers: {
250
+ [APPROVALS]: queueFor(),
251
+ [vis(41)]: slotsFor(41),
252
+ 'POST /api/bongos/copy-desk/pages/landing:index/send-back': (body) => ({ ok: true, outcome: 'sent_back', sent_back: { note: body.note }, next: { count: 1, page: null } }),
253
+ },
254
+ });
255
+ await b.panel.open(); await settle(); await settle();
256
+ await b.click(b.q('[data-open-note]'));
257
+ const input = b.q('#aq-note');
258
+ assert.ok(input, 'the note field opens');
259
+ assert.equal(input.attrs.placeholder, 'The hero line is too long on a phone.');
260
+ assert.equal(b.q('[data-open-note]'), null, 'send it back gives way to the note and send');
261
+ input.value = 'too long';
262
+ await b.click(b.q('[data-send]'));
263
+ assert.equal(b.requests.filter((r) => r.method === 'POST').length, 0, 'a short note never leaves the panel');
264
+ assert.equal(text(b.q('.aq-error')), 'Say what needs changing in at least 10 characters.');
265
+ b.q('#aq-note').value = ' The hero line is too long on a phone. ';
266
+ await b.click(b.q('[data-send]'));
267
+ const posts = JSON.parse(JSON.stringify(b.requests.filter((r) => r.method === 'POST')));
268
+ assert.deepEqual(posts.map((p) => [p.url, p.body]), [['/api/bongos/copy-desk/pages/landing:index/send-back', { task_id: '41', note: 'The hero line is too long on a phone.' }]]);
269
+ assert.equal(text(b.q('.aq-outcome')), 'Sent back with your note. It comes back to the queue once it is re-applied.');
270
+ });
271
+
272
+ await test('a refusal is the server\'s words, and nothing is marked decided', async () => {
273
+ const b = bootPanel({
274
+ answers: {
275
+ [APPROVALS]: queueFor(),
276
+ [vis(41)]: slotsFor(41),
277
+ 'POST /api/bongos/copy-desk/pages/landing:index/approve': { __status: 409, data: { error: { code: 'round_moved', details: { what_this_means: 'The page waiting now is a different round.' } } } },
278
+ },
279
+ });
280
+ await b.panel.open(); await settle(); await settle();
281
+ await b.click(b.q('[data-approve]'));
282
+ assert.equal(text(b.q('.aq-error')), 'The page waiting now is a different round.');
283
+ assert.ok(b.q('[data-approve]'), 'still deciding');
284
+ assert.deepEqual(b.counts, [2]);
285
+ });
286
+
287
+ await test('a builder who may not decide reads the queue with a sentence, not two dead buttons', async () => {
288
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor({ crafts: ['engineer'], tasks: TASKS.map((t) => ({ ...t, artist_id: '99' })) }), [vis(41)]: slotsFor(41) } });
289
+ await b.panel.open(); await settle(); await settle();
290
+ assert.equal(b.q('[data-approve]'), null);
291
+ assert.equal(b.q('[data-open-note]'), null);
292
+ assert.match(text(b.q('.aq-sealed')), /Only the artist who wrote this page, or another artist/);
293
+ });
294
+
295
+ await test('empty, closed with Escape, and focus goes back where it was', async () => {
296
+ const b = bootPanel({ answers: { [APPROVALS]: queueFor({ tasks: [] }) } });
297
+ const opener = { focus() { boot.focused = opener; } };
298
+ b.focused = opener;
299
+ await b.panel.open(); await settle();
300
+ assert.equal(text(b.q('.aq-title')), 'Nothing waiting');
301
+ assert.match(text(b.q('.aq-frame__note')), /Nothing is waiting for you/);
302
+ b.scrim.fire('keydown', { key: 'Escape', preventDefault() {}, target: b.scrim });
303
+ assert.equal(b.scrim.hidden, true);
304
+ assert.equal(b.focused, opener);
305
+ assert.ok(!b.classes.has('aq-open'));
306
+ assert.deepEqual(b.counts, [0]);
307
+ });
308
+
309
+ // ── the writes: only the host's, and the host's are the studio's allowlist ──
310
+
311
+ await test('the panel writes only through the writes it is handed; the studio hands it its own WRITE_ROUTES', () => {
312
+ const src = read('approval-queue.js').replace(/^\s*\/\/.*$/gm, '');
313
+ const sites = [...src.matchAll(/\.request\(\s*'(POST|PATCH|PUT|DELETE)'\s*,\s*([^,)]+)/g)].map((m) => [m[1], m[2].trim()]);
314
+ assert.deepEqual(sites, [['POST', 'url']], 'exactly one write site');
315
+ assert.match(src, /const url = kind === 'approved' \? writes\.approve\(item\.page_id\) : writes\.sendBack\(item\.page_id\);/);
316
+ assert.equal(/copy-desk\/pages|\/visual\b|\/confirm\b|\/ship\b|\/claims\b|proposals/.test(src.replace(/\/copy-desk\/approvals|\/visuals`/g, '')), false,
317
+ 'no route is named here but the two reads');
318
+ const studio = read('studio.js');
319
+ assert.match(studio, /approve: \(pageId\) => `\$\{API\}\/copy-desk\/pages\/\$\{pageId\}\/approve`,/);
320
+ assert.match(studio, /sendBack: \(pageId\) => `\$\{API\}\/copy-desk\/pages\/\$\{pageId\}\/send-back`,/);
321
+ assert.match(studio, /writes: \{ approve: WRITE_ROUTES\.approve, sendBack: WRITE_ROUTES\.sendBack \}/);
322
+ const html = read('studio.html');
323
+ assert.ok(html.indexOf('approval-queue-lib.js') < html.indexOf('approval-queue.js"') && html.indexOf('approval-queue.js"') < html.indexOf('studio.js?'), 'the lib, the panel, then the studio');
324
+ assert.match(html, /id="studio-approvals"[^>]*aria-haspopup="dialog"/);
325
+ });
326
+
327
+ summary();
@@ -429,6 +429,10 @@ const LITERAL_EXEMPT = {
429
429
  // studio, the glass bar, and a sheet of PAPER that stays paper in both modes,
430
430
  // so its palette cannot be the hall's mode-flipping tier. The sky precedent.
431
431
  'tweak-editor.css': 'the tweak editor\'s stage is the artboard\'s own palette (task 1004321, the pixel contract: paper in both modes)',
432
+ // The Approval queue (task 1004323 / BV2.TW12): the glass dialog of
433
+ // docs/design/mocks/tweak-mode/Main.dc.html, built 1:1. Glass over the night
434
+ // studio in both modes, so its palette sits on the panel's own root.
435
+ 'approval-queue.css': 'the Approval queue\'s glass is the artboard\'s own palette (task 1004323, the pixel contract: night glass in both modes)',
432
436
  };
433
437
  const COLOUR_PROPS = 'color|background|background-color|border|border-color|border-top-color|border-bottom-color|fill|stroke|outline|outline-color|box-shadow|text-shadow';
434
438
  const colourLiteralRe = () =>