@bongos/core 1.19.1063 → 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 (65) hide show
  1. package/.bongos-core.json +136 -56
  2. package/.claude/skills/grade-recover/SKILL.md +1 -0
  3. package/.claude/skills/tweak/SKILL.md +70 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +12 -0
  6. package/clients/bongos-client/index.cjs +12 -0
  7. package/clients/bongos-client/index.d.ts +21 -1
  8. package/clients/bongos-client/index.mjs +12 -0
  9. package/docs/api/openapi.json +492 -4
  10. package/docs/api-reference.md +9 -3
  11. package/docs/copy-inventory.md +60 -41
  12. package/docs/copy-registry.json +228 -47
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +11 -2
  16. package/docs/page-readings.json +45 -22
  17. package/modules/copy-desk/page-decisions.js +71 -0
  18. package/modules/copy-desk/page-status.js +96 -0
  19. package/modules/copy-desk/pages.js +30 -0
  20. package/modules/copy-desk/routes/copy-desk.js +153 -0
  21. package/modules/copy-desk/tests/copy_no_cms.mjs +40 -4
  22. package/modules/hall-ui/public/approval-queue-lib.js +156 -0
  23. package/modules/hall-ui/public/approval-queue.css +286 -0
  24. package/modules/hall-ui/public/approval-queue.js +338 -0
  25. package/modules/hall-ui/public/studio.css +33 -0
  26. package/modules/hall-ui/public/studio.html +18 -1
  27. package/modules/hall-ui/public/studio.js +30 -0
  28. package/modules/hall-ui/public/studio.states.json +198 -0
  29. package/modules/hall-ui/records/approval-queue.md +32 -0
  30. package/modules/lifecycle/db-grade.js +96 -58
  31. package/modules/lifecycle/github-pr-close.js +41 -0
  32. package/modules/lifecycle/github-push.js +5 -2
  33. package/modules/lifecycle/lifecycle.js +12 -0
  34. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  35. package/modules/lifecycle/page-tweak-approve.js +242 -0
  36. package/modules/lifecycle/page-tweak-hold.js +74 -0
  37. package/modules/lifecycle/page-tweak-reads.js +33 -1
  38. package/modules/lifecycle/routes/gate-approvals.js +17 -2
  39. package/modules/lifecycle/routes/tasks.js +4 -2
  40. package/modules/lifecycle/routes/visuals.js +82 -0
  41. package/modules/lifecycle/task-visual-db.js +40 -1
  42. package/modules/lifecycle/task-visuals.js +24 -0
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +12 -0
  46. package/scripts/gds/copy-apply.js +277 -1
  47. package/scripts/gds/page-reader.js +2 -0
  48. package/scripts/gds/ship-finish.js +27 -2
  49. package/scripts/gds/ship-flow.js +17 -1
  50. package/scripts/gds/ship-land.js +30 -7
  51. package/scripts/gds/ship-merge.js +43 -15
  52. package/scripts/gds/strand-watch.js +41 -1
  53. package/scripts/gds/task.js +23 -5
  54. package/scripts/gds/tweak-renders.js +200 -0
  55. package/scripts/hall-preview/server.js +18 -0
  56. package/src/module-api.js +1 -1
  57. package/tests/copy_desk_page_approve.mjs +307 -0
  58. package/tests/fixtures/page-ledger.mjs +41 -1
  59. package/tests/gate_approvals.mjs +93 -0
  60. package/tests/hall_approval_queue.mjs +327 -0
  61. package/tests/hall_audit.mjs +4 -0
  62. package/tests/page_tweak_hold.mjs +265 -0
  63. package/tests/publish_status_branch.mjs +23 -0
  64. package/tests/task_visual_slots.mjs +161 -0
  65. package/tests/tweak_batch_apply.mjs +265 -0
@@ -0,0 +1,338 @@
1
+ // modules/hall-ui/public/approval-queue.js — the Approval queue panel (task
2
+ // 1004323 / BV2.TW12, goal 1000095; ADR 0341 D7 and D11).
3
+ //
4
+ // THE PIXEL CONTRACT. The wide glass dialog of docs/design/mocks/tweak-mode/
5
+ // Main.dc.html: the queue position ("approval queue · 1 of 2"), the page name
6
+ // and "· landing · 2 lines rewritten, applied", the before/after switch, close,
7
+ // the applied page itself, then "approve and ship", "send it back" (which opens
8
+ // a one-line note and "send"), "highlighted: the words you changed", and the
9
+ // approved and sent-back lines. Its styles are approval-queue.css.
10
+ //
11
+ // THE PAGE IS A PICTURE OF THE APPLIED BRANCH. The panel shows the renders
12
+ // /tweak attached to the round (GET /tasks/:id/visuals: before and after,
13
+ // desktop and phone, light and dark), never a page rebuilt from the batch
14
+ // (ADR 0341 builder pick 9, which keeps ADR 0233's "nothing renders a task
15
+ // description as product copy" true). A word cannot be highlighted inside an
16
+ // image, so the changed lines stand beside the render (under it on a phone),
17
+ // each marked the artboard's way; a line the applier refused says so.
18
+ //
19
+ // A COMPONENT, NOT A PAGE. The studio opens it from a quick action today, and
20
+ // the lofi studio home (task 1003971 / TW16) places the same panel without
21
+ // rebuilding it: `OTBApprovalQueue.mount({ api, apiBase, writes, onCount })`
22
+ // returns { open, close, refresh, count }. It writes ONLY through `writes`,
23
+ // which the host passes from its own allowlist (studio.js WRITE_ROUTES
24
+ // approve and sendBack), so the host's no-live-CMS test still sees every write
25
+ // the room can make. Nothing here writes a string, a file or an asset.
26
+
27
+ (() => {
28
+ 'use strict';
29
+
30
+ const L = window.OTBApprovalQueueLib;
31
+
32
+ function esc(s) {
33
+ return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
34
+ }
35
+
36
+ function errWords(data, fallback) {
37
+ const e = data && data.error;
38
+ const d = e && e.details;
39
+ return (d && d.what_this_means) || (e && e.message && e.message !== e.code ? e.message : null) || fallback;
40
+ }
41
+
42
+ function hallMode() {
43
+ const t = document.documentElement.getAttribute('data-theme');
44
+ if (t === 'dark' || t === 'light') return t;
45
+ return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
46
+ }
47
+
48
+ // On a phone the phone render is the one that reads; the switch still offers both.
49
+ function narrow() {
50
+ return !!(window.matchMedia && window.matchMedia('(max-width: 760px)').matches);
51
+ }
52
+
53
+ // A page id is <surface>:<page> (docs/page-inventory.json). The host's write
54
+ // routes put it in a path, so anything else is refused before a request.
55
+ const PAGE_ID_RE = /^[a-z0-9-]+:[a-z0-9-]+$/;
56
+
57
+ function mount({ api, apiBase = '/api/bongos', writes, onCount = null, root = document.body } = {}) {
58
+ if (!api || !writes || typeof writes.approve !== 'function' || typeof writes.sendBack !== 'function') {
59
+ throw new Error('OTBApprovalQueue.mount needs { api, writes: { approve, sendBack } }');
60
+ }
61
+
62
+ const state = {
63
+ open: false,
64
+ loaded: false,
65
+ loadError: null,
66
+ queue: [],
67
+ idx: 0,
68
+ view: { phase: 'after', device: narrow() ? 'phone' : 'desktop', mode: hallMode() },
69
+ renders: new Map(), // task id -> render index, or 'loading' / 'failed'
70
+ outcome: new Map(), // task id -> { kind: 'approved'|'sent', line }
71
+ noteOpen: false,
72
+ busy: false,
73
+ error: null,
74
+ opener: null,
75
+ };
76
+
77
+ const scrim = document.createElement('div');
78
+ scrim.className = 'aq-overlay';
79
+ scrim.hidden = true;
80
+ scrim.innerHTML = '<div class="aq-dialog" role="dialog" aria-modal="true" aria-labelledby="aq-title" tabindex="-1"></div>';
81
+ root.appendChild(scrim);
82
+ const dialog = scrim.querySelector('.aq-dialog');
83
+
84
+ const current = () => state.queue[state.idx] || null;
85
+
86
+ function countNow() {
87
+ return state.loaded ? state.queue.filter((q) => !state.outcome.has(q.task_id)).length : null;
88
+ }
89
+ function tellCount() {
90
+ if (typeof onCount === 'function') onCount(countNow());
91
+ }
92
+
93
+ // ---- reads ---------------------------------------------------------------
94
+
95
+ async function loadQueue() {
96
+ const res = await api.request('GET', `${apiBase}/copy-desk/approvals`);
97
+ if (!res.ok) {
98
+ state.loadError = errWords(res.data, 'The Approval queue could not be read just now.');
99
+ state.loaded = false;
100
+ return;
101
+ }
102
+ state.loadError = null;
103
+ state.loaded = true;
104
+ state.queue = (res.data && res.data.queue) || [];
105
+ if (state.idx >= state.queue.length) state.idx = 0;
106
+ tellCount();
107
+ }
108
+
109
+ async function loadRenders(item) {
110
+ if (!item || state.renders.has(item.task_id)) return;
111
+ state.renders.set(item.task_id, 'loading');
112
+ const res = await api.request('GET', `${apiBase}/tasks/${encodeURIComponent(item.task_id)}/visuals`);
113
+ state.renders.set(item.task_id, res.ok ? L.renderIndex(res.data && res.data.slots) : 'failed');
114
+ if (current() === item) paint();
115
+ }
116
+
117
+ // ---- paint ---------------------------------------------------------------
118
+
119
+ function pills(group, options, value, label) {
120
+ return `<div class="aq-flips" role="group" aria-label="${esc(label)}">${options.map((o) =>
121
+ `<button type="button" class="aq-flip" data-${group}="${esc(o)}" aria-pressed="${o === value ? 'true' : 'false'}">${esc(o)}</button>`).join('')}</div>`;
122
+ }
123
+
124
+ function frameHtml(item) {
125
+ const r = state.renders.get(item.task_id);
126
+ if (r === undefined || r === 'loading') return '<p class="aq-frame__note">Opening the rendered page…</p>';
127
+ if (r === 'failed') return '<p class="aq-frame__note">The renders could not be read just now. Close the queue and open it again.</p>';
128
+ const pick = L.pickRender(r, state.view);
129
+ if (!pick) {
130
+ return L.hasAnyRender(r)
131
+ ? `<p class="aq-frame__note">No ${esc(state.view.phase)} render is attached to this page.</p>`
132
+ : '<p class="aq-frame__note">No render is attached to this page yet. /tweak attaches them when it applies a page; until then there is nothing to approve from, so send it back or wait.</p>';
133
+ }
134
+ const name = L.displayTitle(item);
135
+ const alt = `${name}, ${state.view.phase}, ${pick.device}, ${pick.mode}`;
136
+ const moved = pick.exact ? '' : `<p class="aq-frame__swap">No ${esc(state.view.device)} ${esc(state.view.mode)} render; showing ${esc(pick.device)} ${esc(pick.mode)}.</p>`;
137
+ return `${moved}<img class="aq-render aq-render--${esc(pick.device)}" src="${esc(pick.url)}" alt="${esc(alt)}">`;
138
+ }
139
+
140
+ function linesHtml(item) {
141
+ const rows = (item.lines || []).map((l) => L.lineRow(l, state.view.phase));
142
+ if (!rows.length) return '<p class="aq-lines__none">This round changed no lines.</p>';
143
+ return `<ol class="aq-lines__list">${rows.map((r) => `
144
+ <li class="aq-line${r.refused ? ' aq-line--refused' : ''}">
145
+ ${r.section ? `<span class="aq-line__section">${esc(r.section)}</span>` : ''}
146
+ <span class="aq-line__text">${r.marked ? `<mark class="aq-mark">${esc(r.text)}</mark>` : esc(r.text)}</span>
147
+ <span class="aq-line__aside">${esc(r.aside)}</span>
148
+ </li>`).join('')}</ol>`;
149
+ }
150
+
151
+ function footHtml(item) {
152
+ const done = state.outcome.get(item.task_id);
153
+ if (done) {
154
+ const next = state.queue.findIndex((q) => !state.outcome.has(q.task_id));
155
+ const nextBtn = next !== -1
156
+ ? `<button type="button" class="aq-ghost aq-ghost--small" data-next="${next}">open ${esc(L.displayTitle(state.queue[next]))}</button>`
157
+ : '';
158
+ return `<div class="aq-foot aq-foot--done"><p class="aq-outcome aq-outcome--${esc(done.kind)}" role="status">${esc(done.line)}</p>${nextBtn}</div>`;
159
+ }
160
+ if (!item.can_decide) {
161
+ return '<div class="aq-foot"><p class="aq-sealed">Only the artist who wrote this page, or another artist, can approve it or send it back.</p><span class="aq-hint">highlighted: the words you changed</span></div>';
162
+ }
163
+ const r = state.renders.get(item.task_id);
164
+ const noPicture = r && r !== 'loading' && r !== 'failed' && !L.hasAnyRender(r);
165
+ const approve = `<button type="button" class="aq-accent" data-approve${state.busy || noPicture ? ' disabled' : ''}>approve and ship</button>`;
166
+ const note = state.noteOpen
167
+ ? `<div class="aq-note">
168
+ <label for="aq-note" class="aq-sr">What needs changing</label>
169
+ <input id="aq-note" class="aq-note__input" type="text" maxlength="${L.MAX_NOTE}" placeholder="The hero line is too long on a phone." autocomplete="off">
170
+ <button type="button" class="aq-ghost aq-ghost--send" data-send${state.busy ? ' disabled' : ''}>send</button>
171
+ </div>`
172
+ : `<button type="button" class="aq-ghost" data-open-note${state.busy ? ' disabled' : ''}>send it back</button>`;
173
+ const err = state.error ? `<p class="aq-error" role="alert">${esc(state.error)}</p>` : '';
174
+ return `<div class="aq-foot">${approve}${note}<span class="aq-hint">highlighted: the words you changed</span></div>${err}`;
175
+ }
176
+
177
+ function paint() {
178
+ if (!state.open) return;
179
+ const keepNote = document.getElementById('aq-note');
180
+ const noteValue = keepNote ? keepNote.value : '';
181
+ const item = current();
182
+ const close = '<button type="button" class="aq-close" data-close>close</button>';
183
+ if (!item) {
184
+ const words = state.loadError || (state.loaded ? 'Nothing is waiting for you. A page appears here once /tweak has applied it and its grade has passed.' : 'Opening the Approval queue…');
185
+ dialog.innerHTML = `
186
+ <div class="aq-head"><span class="aq-eyebrow">approval queue</span><h2 class="aq-title" id="aq-title">${state.loaded ? 'Nothing waiting' : 'Approval queue'}</h2><span class="aq-head__gap"></span>${close}</div>
187
+ <div class="aq-body aq-body--empty"><div class="aq-frame"><p class="aq-frame__note">${esc(words)}</p></div></div>`;
188
+ return;
189
+ }
190
+ loadRenders(item);
191
+ dialog.innerHTML = `
192
+ <div class="aq-head">
193
+ <span class="aq-eyebrow">${esc(L.eyebrow(item))}</span>
194
+ <h2 class="aq-title" id="aq-title">${esc(L.displayTitle(item))} <span class="aq-title__sub">${esc(L.subLine(item))}</span></h2>
195
+ <span class="aq-head__gap"></span>
196
+ ${pills('phase', L.PHASES, state.view.phase, 'Before or after')}
197
+ ${close}
198
+ </div>
199
+ <div class="aq-body">
200
+ <div class="aq-frame" tabindex="0" aria-label="The page, rendered">${frameHtml(item)}</div>
201
+ <aside class="aq-lines" aria-label="The words you changed">
202
+ <div class="aq-views">${pills('device', L.DEVICES, state.view.device, 'Desktop or phone')}${pills('mode', L.MODES, state.view.mode, 'Light or dark')}</div>
203
+ ${linesHtml(item)}
204
+ </aside>
205
+ </div>
206
+ ${footHtml(item)}`;
207
+ const note = document.getElementById('aq-note');
208
+ if (note) { note.value = noteValue; }
209
+ }
210
+
211
+ // ---- writes (only through the host's allowlist) --------------------------
212
+
213
+ async function decide(kind) {
214
+ const item = current();
215
+ if (!item || state.busy || state.outcome.has(item.task_id) || !PAGE_ID_RE.test(String(item.page_id))) return;
216
+ let body = { task_id: String(item.task_id) };
217
+ if (kind === 'sent') {
218
+ const el = document.getElementById('aq-note');
219
+ const note = el ? el.value : '';
220
+ const problem = L.noteProblem(note);
221
+ if (problem) { state.error = problem; paint(); focusNote(); return; }
222
+ body = { ...body, note: note.replace(/\s+/g, ' ').trim() };
223
+ }
224
+ state.busy = true;
225
+ state.error = null;
226
+ paint();
227
+ try {
228
+ const url = kind === 'approved' ? writes.approve(item.page_id) : writes.sendBack(item.page_id);
229
+ const res = await api.request('POST', url, body);
230
+ if (!res.ok) {
231
+ state.error = errWords(res.data, 'That could not be recorded. Nothing changed.');
232
+ return;
233
+ }
234
+ state.outcome.set(item.task_id, { kind, line: kind === 'approved' ? L.approvedLine(item, res.data) : L.sentBackLine() });
235
+ state.noteOpen = false;
236
+ tellCount();
237
+ } catch (_) {
238
+ state.error = 'That could not be recorded. Nothing changed.';
239
+ } finally {
240
+ state.busy = false;
241
+ paint();
242
+ const status = dialog.querySelector('.aq-outcome') || dialog.querySelector('.aq-error');
243
+ if (status && status.focus) { status.setAttribute('tabindex', '-1'); status.focus(); }
244
+ }
245
+ }
246
+
247
+ function focusNote() {
248
+ const el = document.getElementById('aq-note');
249
+ if (el) el.focus();
250
+ }
251
+
252
+ // ---- open / close ----------------------------------------------------------
253
+
254
+ async function open() {
255
+ state.opener = document.activeElement;
256
+ state.open = true;
257
+ state.error = null;
258
+ state.noteOpen = false;
259
+ scrim.hidden = false;
260
+ document.documentElement.classList.add('aq-open');
261
+ paint();
262
+ dialog.focus();
263
+ await loadQueue();
264
+ paint();
265
+ const first = dialog.querySelector('[data-phase][aria-pressed="true"]') || dialog.querySelector('[data-close]');
266
+ if (first) first.focus();
267
+ }
268
+
269
+ function close() {
270
+ state.open = false;
271
+ scrim.hidden = true;
272
+ document.documentElement.classList.remove('aq-open');
273
+ // A decided page leaves the queue for good; the next open starts fresh.
274
+ if (state.outcome.size) {
275
+ state.queue = state.queue.filter((q) => !state.outcome.has(q.task_id));
276
+ state.outcome.clear();
277
+ state.idx = 0;
278
+ }
279
+ if (state.opener && state.opener.focus) state.opener.focus();
280
+ }
281
+
282
+ // A switch repaints the dialog, so the pressed pill is focused again: a
283
+ // keyboard reader stays where they were instead of falling to the page.
284
+ function flip(group, value) {
285
+ state.view[group] = value;
286
+ paint();
287
+ const again = dialog.querySelector(`[data-${group}="${value}"]`);
288
+ if (again && again.focus) again.focus();
289
+ }
290
+
291
+ scrim.addEventListener('click', (ev) => {
292
+ if (ev.target === scrim) return close();
293
+ const t = ev.target.closest('button');
294
+ if (!t || t.disabled) return undefined;
295
+ if (('close' in t.dataset)) return close();
296
+ if (t.dataset.phase) return flip('phase', t.dataset.phase);
297
+ if (t.dataset.device) return flip('device', t.dataset.device);
298
+ if (t.dataset.mode) return flip('mode', t.dataset.mode);
299
+ if (('approve' in t.dataset)) return decide('approved');
300
+ if (('openNote' in t.dataset)) { state.noteOpen = true; paint(); return focusNote(); }
301
+ if (('send' in t.dataset)) return decide('sent');
302
+ if (t.dataset.next != null) { state.idx = Number(t.dataset.next); state.noteOpen = false; state.error = null; paint(); return undefined; }
303
+ return undefined;
304
+ });
305
+
306
+ scrim.addEventListener('keydown', (ev) => {
307
+ if (ev.key === 'Escape') { ev.preventDefault(); close(); return; }
308
+ if (ev.key === 'Enter' && ev.target && ev.target.id === 'aq-note') { ev.preventDefault(); decide('sent'); return; }
309
+ if (ev.key !== 'Tab') return;
310
+ // Keep focus inside the dialog while it is open (aria-modal).
311
+ const f = [...dialog.querySelectorAll('button:not([disabled]), input, [tabindex="0"]')];
312
+ if (!f.length) return;
313
+ const first = f[0];
314
+ const last = f[f.length - 1];
315
+ if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
316
+ else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
317
+ });
318
+
319
+ scrim.addEventListener('input', (ev) => {
320
+ if (ev.target && ev.target.id === 'aq-note' && state.error) {
321
+ state.error = null;
322
+ const e = dialog.querySelector('.aq-error');
323
+ if (e) e.remove();
324
+ }
325
+ });
326
+
327
+ return {
328
+ open,
329
+ close,
330
+ async refresh() { await loadQueue(); paint(); return countNow(); },
331
+ count: countNow,
332
+ };
333
+ }
334
+
335
+ const exported = { mount };
336
+ if (typeof window !== 'undefined') window.OTBApprovalQueue = exported;
337
+ if (typeof module !== 'undefined' && module.exports) module.exports = exported;
338
+ })();
@@ -73,9 +73,42 @@
73
73
 
74
74
  /* Everything the reader acts on sits above the lamp. */
75
75
  .studio-head,
76
+ .studio-doors,
76
77
  .studio-focus,
77
78
  .studio-rest { position: relative; z-index: 1; }
78
79
 
80
+ /* The quick actions (task 1004323 / TW12): the master design's 72px glass rows
81
+ (docs/design/mocks/tweak-mode/Main.dc.html), in the hall's own tokens so they
82
+ read in both modes. One today, the Approval queue; the lofi home (TW16)
83
+ brings the rest and the night room around them. */
84
+ .studio-doors {
85
+ display: flex;
86
+ flex-direction: column;
87
+ gap: 12px;
88
+ width: min(420px, 100%);
89
+ margin-bottom: clamp(22px, 4vh, 40px);
90
+ }
91
+ .studio-door {
92
+ display: flex;
93
+ flex-direction: column;
94
+ align-items: flex-start;
95
+ justify-content: center;
96
+ gap: 3px;
97
+ min-height: 72px;
98
+ padding: 0 24px;
99
+ box-sizing: border-box;
100
+ border-radius: 22px;
101
+ cursor: pointer;
102
+ text-align: left;
103
+ font-family: var(--font-body);
104
+ background: var(--glass);
105
+ border: 1px solid var(--rule-soft);
106
+ color: var(--ink);
107
+ }
108
+ .studio-door:hover { border-color: var(--ink-faint); }
109
+ .studio-door__label { font-size: 17px; color: var(--ink); }
110
+ .studio-door__detail { font-size: 13px; color: var(--ink-soft); }
111
+
79
112
  /* The head: a mono eyebrow naming the mode, then the greeting as the display
80
113
  voice (Manrope light, tight), then the live count as a quiet sub-line. */
81
114
  .studio-head { margin-bottom: clamp(22px, 4vh, 40px); max-width: var(--measure); }
@@ -14,6 +14,7 @@
14
14
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
15
15
  <link rel="stylesheet" href="/builders/style.css?v=2026-07-02-shell1">
16
16
  <link rel="stylesheet" href="/builders/studio.css">
17
+ <link rel="stylesheet" href="/builders/approval-queue.css">
17
18
  </head>
18
19
  <!-- data-fullscreen-switch opts into the shell's full-screen switch (task
19
20
  1004327): the studio in the hall chrome by default, and one button in the top
@@ -44,6 +45,18 @@
44
45
  <p class="studio-sub" id="studio-sub">A quiet place to tend the words and art the project shows people.</p>
45
46
  </div>
46
47
 
48
+ <!-- The quick actions. One today: the Approval queue (task 1004323 / TW12),
49
+ the pages /tweak has applied, waiting for the artist to approve or
50
+ send back. It opens the glass panel in place (approval-queue.js);
51
+ the lofi studio home (task 1003971 / TW16) moves this door into its
52
+ own quick actions and keeps the panel as it is. -->
53
+ <div class="studio-doors">
54
+ <button type="button" class="studio-door" id="studio-approvals" data-approvals aria-haspopup="dialog">
55
+ <span class="studio-door__label">Approval queue</span>
56
+ <span class="studio-door__detail" id="studio-approvals-detail">the pages /tweak has applied</span>
57
+ </button>
58
+ </div>
59
+
47
60
  <!-- The one thing to tend now. Rendered by studio.js; the markup here is
48
61
  the loading state (and the no-JS fallback), never an empty panel. -->
49
62
  <section class="studio-focus" id="studio-focus" aria-live="polite">
@@ -80,6 +93,10 @@
80
93
  and a fourth hand-rolled <figure> is what the kit exists to stop. -->
81
94
  <script src="/builders/hall-visual.js?v=2026-09-16-visual1"></script>
82
95
  <script src="/builders/hall-kit.js"></script>
83
- <script src="/builders/studio.js?v=2026-09-10-studio2"></script>
96
+ <!-- The Approval queue panel (task 1004323): its pure rules, then the panel.
97
+ studio.js mounts it with its own WRITE_ROUTES. -->
98
+ <script src="/builders/approval-queue-lib.js"></script>
99
+ <script src="/builders/approval-queue.js"></script>
100
+ <script src="/builders/studio.js?v=2026-09-28-approvals"></script>
84
101
  </body>
85
102
  </html>
@@ -81,6 +81,17 @@
81
81
  patch: (reviewId) => `${API}/tasks/${reviewId}`,
82
82
  create: () => `${API}/tasks`,
83
83
  merge: (reviewId) => `${API}/tasks/${reviewId}/merge`,
84
+ // The Approval queue's two decisions (task 1004323 / BV2.TW12, ADR 0341
85
+ // D7), handed to the panel (approval-queue.js), which writes through
86
+ // nothing else. Both move the page's TWEAK ROUND through the lifecycle
87
+ // port: approve lets the applied branch land the ordinary way, send-back
88
+ // returns it to /tweak with the artist's note. Neither writes a live
89
+ // string: the words reach the page only through that shipped branch.
90
+ // The route decides who may (the author or an artist), not the rank. The
91
+ // page id is the inventory's <surface>:<page>, which the panel checks, and
92
+ // travels as the route's own path (the tweak editor's take.path shape).
93
+ approve: (pageId) => `${API}/copy-desk/pages/${pageId}/approve`,
94
+ sendBack: (pageId) => `${API}/copy-desk/pages/${pageId}/send-back`,
84
95
  });
85
96
 
86
97
  // The verdict floor, read off /me. Local like goals-render.js's copy — four
@@ -526,7 +537,26 @@
526
537
  if (ev.target && ev.target.id === 'studio-words') verdictError('');
527
538
  });
528
539
 
540
+ // The Approval queue (task 1004323): the panel is a component, mounted here
541
+ // with the two decisions above, and its door says how many pages wait.
542
+ let approvals = null;
543
+ function mountApprovals() {
544
+ const door = $('#studio-approvals');
545
+ const Q = window.OTBApprovalQueue;
546
+ const lib = window.OTBApprovalQueueLib;
547
+ if (!door || !Q || !lib) return;
548
+ approvals = Q.mount({
549
+ api,
550
+ apiBase: API,
551
+ writes: { approve: WRITE_ROUTES.approve, sendBack: WRITE_ROUTES.sendBack },
552
+ onCount: (n) => { const d = $('#studio-approvals-detail'); if (d) d.textContent = lib.doorDetail(n); },
553
+ });
554
+ door.addEventListener('click', () => approvals.open());
555
+ approvals.refresh();
556
+ }
557
+
529
558
  (async () => {
559
+ mountApprovals();
530
560
  const me = await api.request('GET', `${API}/me`);
531
561
  if (me.ok) viewer = me.data.builder || me.data;
532
562
  // Reviews first (the richer craft), then flags — each newest-first within.
@@ -115,6 +115,204 @@
115
115
  ]
116
116
  ]
117
117
  }
118
+ },
119
+ "approvals": {
120
+ "auth": true,
121
+ "_": "The Approval queue (task 1004323): the studio's quick action opens the glass panel, 1 of 2, on the AFTER render of the applied page with the changed lines beside it. Against the kit's own stub it opens on its could-not-read line; populated, run it against the hall-preview harness (fixtures copy-desk__approvals.json, tasks__visuals.json and the two CANNED_WRITES). HALL_PREVIEW_VISUALS=<dir> serves real renders by the fixture's names.",
122
+ "actions": [
123
+ [
124
+ "wait",
125
+ 700
126
+ ],
127
+ [
128
+ "click",
129
+ "#studio-approvals"
130
+ ],
131
+ [
132
+ "wait",
133
+ 1200
134
+ ]
135
+ ],
136
+ "expect": {
137
+ "visible": [
138
+ ".aq-dialog",
139
+ ".aq-eyebrow",
140
+ ".aq-render"
141
+ ]
142
+ }
143
+ },
144
+ "approvals-before": {
145
+ "auth": true,
146
+ "_": "The before/after switch on before: the page as it was, nothing marked.",
147
+ "actions": [
148
+ [
149
+ "wait",
150
+ 700
151
+ ],
152
+ [
153
+ "click",
154
+ "#studio-approvals"
155
+ ],
156
+ [
157
+ "wait",
158
+ 1200
159
+ ],
160
+ [
161
+ "click",
162
+ "[data-phase=\"before\"]"
163
+ ],
164
+ [
165
+ "wait",
166
+ 700
167
+ ]
168
+ ],
169
+ "expect": {
170
+ "visible": [
171
+ ".aq-dialog",
172
+ ".aq-eyebrow",
173
+ ".aq-render"
174
+ ]
175
+ }
176
+ },
177
+ "approvals-phone": {
178
+ "auth": true,
179
+ "_": "The phone render (the view switch beside the lines).",
180
+ "actions": [
181
+ [
182
+ "wait",
183
+ 700
184
+ ],
185
+ [
186
+ "click",
187
+ "#studio-approvals"
188
+ ],
189
+ [
190
+ "wait",
191
+ 1200
192
+ ],
193
+ [
194
+ "click",
195
+ "[data-device=\"phone\"]"
196
+ ],
197
+ [
198
+ "wait",
199
+ 700
200
+ ]
201
+ ],
202
+ "expect": {
203
+ "visible": [
204
+ ".aq-dialog",
205
+ ".aq-eyebrow",
206
+ ".aq-render"
207
+ ]
208
+ }
209
+ },
210
+ "approvals-note": {
211
+ "auth": true,
212
+ "_": "Send it back: the one-line note opens in place.",
213
+ "actions": [
214
+ [
215
+ "wait",
216
+ 700
217
+ ],
218
+ [
219
+ "click",
220
+ "#studio-approvals"
221
+ ],
222
+ [
223
+ "wait",
224
+ 1200
225
+ ],
226
+ [
227
+ "click",
228
+ "[data-open-note]"
229
+ ],
230
+ [
231
+ "wait",
232
+ 300
233
+ ]
234
+ ],
235
+ "expect": {
236
+ "visible": [
237
+ "#aq-note",
238
+ "[data-send]"
239
+ ]
240
+ }
241
+ },
242
+ "approved": {
243
+ "auth": true,
244
+ "_": "Approve and ship: the artboard's approved line, and the next page named.",
245
+ "actions": [
246
+ [
247
+ "wait",
248
+ 700
249
+ ],
250
+ [
251
+ "click",
252
+ "#studio-approvals"
253
+ ],
254
+ [
255
+ "wait",
256
+ 1200
257
+ ],
258
+ [
259
+ "click",
260
+ "[data-approve]"
261
+ ],
262
+ [
263
+ "wait",
264
+ 600
265
+ ]
266
+ ],
267
+ "expect": {
268
+ "visible": [
269
+ ".aq-outcome--approved"
270
+ ]
271
+ }
272
+ },
273
+ "sent-back": {
274
+ "auth": true,
275
+ "_": "Sent back with a note.",
276
+ "actions": [
277
+ [
278
+ "wait",
279
+ 700
280
+ ],
281
+ [
282
+ "click",
283
+ "#studio-approvals"
284
+ ],
285
+ [
286
+ "wait",
287
+ 1200
288
+ ],
289
+ [
290
+ "click",
291
+ "[data-open-note]"
292
+ ],
293
+ [
294
+ "wait",
295
+ 200
296
+ ],
297
+ [
298
+ "fill",
299
+ "#aq-note",
300
+ "The hero line is too long on a phone."
301
+ ],
302
+ [
303
+ "click",
304
+ "[data-send]"
305
+ ],
306
+ [
307
+ "wait",
308
+ 600
309
+ ]
310
+ ],
311
+ "expect": {
312
+ "visible": [
313
+ ".aq-outcome--sent"
314
+ ]
315
+ }
118
316
  }
119
317
  },
120
318
  "reducedMotion": {