@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
@@ -38,6 +38,7 @@
38
38
  // Exit codes: 0 always on --line (fail-open). Otherwise 0 clean, 1 strands found, 2 undetermined.
39
39
 
40
40
  const { apiCall, arg, hasFlag } = require('./cli-lib');
41
+ const tweakHold = require('../../modules/lifecycle/page-tweak-hold.js');
41
42
 
42
43
  // ---- thresholds -------------------------------------------------------------
43
44
  // Reasoned, not magic. Written down because a future reader will want to retune them.
@@ -132,6 +133,31 @@ function rankStrands(strands) {
132
133
  });
133
134
  }
134
135
 
136
+ // ---- the held pass (task 1004322, ADR 0341 D7) --------------------------------
137
+ //
138
+ // A page tweak whose grade PASSED waits at `completed` for its artist on purpose:
139
+ // the artist approves the applied page before it lands. It is not a strand and not
140
+ // a failed grade, and calling it one would send someone to /grade-recover a task
141
+ // nobody should touch. The task list carries no grade, so each completed page
142
+ // tweak's grade is read on its own (there are a handful at most; the read is
143
+ // bounded anyway). A read that FAILS leaves the task to the ordinary
144
+ // classification: an unknown must stay loud, never be explained away as a hold.
145
+ const HOLD_READ_LIMIT = 25;
146
+
147
+ async function heldPageTweaks(tasks, { call, withTimeout }) {
148
+ const candidates = tasks.filter((t) => t && t.status === 'completed' && tweakHold.isPageTweak(t) && safeId(t.id)).slice(0, HOLD_READ_LIMIT);
149
+ const held = new Set();
150
+ await Promise.all(candidates.map(async (t) => {
151
+ try {
152
+ const res = await withTimeout(call('GET', `/api/bongos/tasks/${safeId(t.id)}?include=grade`), 'page-tweak grade');
153
+ const body = res && (res.data || res);
154
+ const grade = body && body.grade;
155
+ if (tweakHold.isHeldForArtist(t, grade ? grade.passed : null)) held.add(String(t.id));
156
+ } catch { /* unknown: classified as usual */ }
157
+ }));
158
+ return held;
159
+ }
160
+
135
161
  // ---- fetch ------------------------------------------------------------------
136
162
 
137
163
  async function check({ nowMs = Date.now(), timeoutMs = DEFAULT_TIMEOUT_MS, confirmedStaleMs, completedStaleMs, call = apiCall } = {}) {
@@ -157,14 +183,23 @@ async function check({ nowMs = Date.now(), timeoutMs = DEFAULT_TIMEOUT_MS, confi
157
183
 
158
184
  const strands = [];
159
185
  const truncated = [];
186
+ const waiting = [];
187
+ const completed = (pages.find((p) => p.status === 'completed') || { tasks: [] }).tasks;
188
+ const held = await heldPageTweaks(completed, { call, withTimeout });
160
189
  for (const { status, tasks } of pages) {
161
190
  if (tasks.length >= PAGE_LIMIT) truncated.push(status);
162
191
  for (const t of tasks) {
192
+ if (status === 'completed' && held.has(String(t.id))) {
193
+ const updated = Date.parse(t.updated_at || '');
194
+ const ageMs = Number.isFinite(updated) ? nowMs - updated : NaN;
195
+ waiting.push({ id: safeId(t.id), status, ageMs, age: humanAge(ageMs), shape: tweakHold.HOLD_STATE });
196
+ continue;
197
+ }
163
198
  const s = classifyStrand(t, { nowMs, confirmedStaleMs, completedStaleMs });
164
199
  if (s) strands.push(s);
165
200
  }
166
201
  }
167
- return { undetermined: false, ok: strands.length === 0 && !truncated.length, strands: rankStrands(strands), truncated };
202
+ return { undetermined: false, ok: strands.length === 0 && !truncated.length, strands: rankStrands(strands), truncated, waiting };
168
203
  }
169
204
 
170
205
  // ---- rendering --------------------------------------------------------------
@@ -218,6 +253,11 @@ function report(r) {
218
253
  console.log(` ! coverage capped: the ${r.truncated.join(' and ')} queue(s) returned a full ${PAGE_LIMIT}-row page,`);
219
254
  console.log(' so strands older than the cutoff are NOT listed below.');
220
255
  }
256
+ // task 1004322: named, and kept apart from the strands — nobody should recover these.
257
+ const waiting = Array.isArray(r.waiting) ? r.waiting : [];
258
+ for (const w of waiting) {
259
+ console.log(` task ${w.id} waiting for the artist (${w.age}) — a page tweak whose grade passed; not a strand, leave it`);
260
+ }
221
261
  if (r.ok) {
222
262
  console.log(' ✓ none — nothing parked at completed or confirmed past its threshold.');
223
263
  console.log('');
@@ -13,6 +13,8 @@
13
13
  // bongos task show <id> [--json]
14
14
  // bongos task visual <id> <image> [--alt "caption"] attach/replace the ship-time visual
15
15
  // bongos task visual <id> --remove take it back off
16
+ // bongos task visual <id> <image> --slot <name> attach/replace a NAMED visual (task 1004322)
17
+ // bongos task visual <id> --slot <name> --remove take a named one back off
16
18
  //
17
19
  // Task CREATION is Archon-gated SERVER-SIDE (creating work shapes scope, ADR 0016). This
18
20
  // CLI adds no authority — a lower rank gets a clear 403 message pointing at idea capture.
@@ -84,6 +86,7 @@ function usage() {
84
86
  console.error(' bongos task show <id> [--json]');
85
87
  console.error(' bongos task visual <id> <image> [--alt "caption"] attach or replace the ship-time visual');
86
88
  console.error(' bongos task visual <id> --remove remove it again');
89
+ console.error(' bongos task visual <id> <image> --slot <name> attach or replace a named visual (e.g. after-phone-dark)');
87
90
  console.error(`\n kinds: ${VALID_KINDS.join(' | ')}`);
88
91
  console.error(` disciplines: ${VALID_DISCIPLINES.join(' | ')}`);
89
92
  }
@@ -174,6 +177,21 @@ async function cmdVisual(args) {
174
177
  }
175
178
  const api = await cliClient();
176
179
 
180
+ // task 1004322: --slot names the visual (lifecycle_013). Validated here with the
181
+ // server's own rule, so a bad name costs a retyped command, not an upload.
182
+ const slot = arg('--slot', args) || null;
183
+ if (slot !== null && !taskVisuals.isSafeSlot(slot)) {
184
+ console.error(`--slot "${slot}" is not a slot name: lowercase words joined by dashes, such as ${taskVisuals.TWEAK_RENDER_SLOTS[0]}.`);
185
+ process.exit(2);
186
+ }
187
+ if (slot && hasFlag('--remove', args)) {
188
+ const r = await api.tasks.deleteTasksIdVisualsSlot({ id, slot });
189
+ if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
190
+ if (!r.ok) { console.error(`visual remove failed (${r.status}):`, r.data); process.exit(1); }
191
+ console.log(`✓ Task #${id} no longer carries the "${slot}" visual.`);
192
+ return;
193
+ }
194
+
177
195
  if (hasFlag('--remove', args)) {
178
196
  const r = await api.tasks.deleteTasksIdVisual({ id });
179
197
  if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
@@ -199,14 +217,14 @@ async function cmdVisual(args) {
199
217
  }
200
218
 
201
219
  const alt = arg('--alt', args) || '';
202
- const r = await api.tasks.postTasksIdVisual({
203
- id,
204
- body: { image_b64: visual.buf.toString('base64'), content_type: visual.contentType, ...(alt ? { alt } : {}) },
205
- });
220
+ const body = { image_b64: visual.buf.toString('base64'), content_type: visual.contentType, ...(alt ? { alt } : {}) };
221
+ const r = slot
222
+ ? await api.tasks.postTasksIdVisualsSlot({ id, slot, body })
223
+ : await api.tasks.postTasksIdVisual({ id, body });
206
224
  if (r.status === 404) { console.error(`task #${id} not found.`); process.exit(1); }
207
225
  if (r.status === 403) { console.error(`Only task #${id}'s claim holder (or an Archon) may attach its visual.`); process.exit(1); }
208
226
  if (!r.ok) { console.error(`visual upload failed (${r.status}):`, r.data); process.exit(1); }
209
- console.log(`✓ Task #${id} now carries a visual (${Math.round(visual.buf.length / 1024)} KB).`);
227
+ console.log(`✓ Task #${id} now carries ${slot ? `the "${slot}" visual` : 'a visual'} (${Math.round(visual.buf.length / 1024)} KB).`);
210
228
  console.log(' It renders wherever this task\'s value summary does.');
211
229
  }
212
230
 
@@ -0,0 +1,200 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ //
4
+ // scripts/gds/tweak-renders.js — the eight renders of a page tweak, shot and
5
+ // attached (task 1004322 / BV2.TW11; ADR 0341 D7 and its builder pick 9).
6
+ //
7
+ // WHY. The artist approves the APPLIED PAGE, not a diff and not a page built from
8
+ // the batch: the Approval queue (TW12) shows images of the applied branch, which
9
+ // is what keeps ADR 0233's "nothing renders a task description as product copy"
10
+ // true. So /tweak renders the page before it applies the batch and again after,
11
+ // on a desktop (1440) and a phone (390), in light and in dark, and attaches the
12
+ // eight pictures to the round's task under their slot names
13
+ // (task-visuals.TWEAK_RENDER_SLOTS: before-desktop-light … after-phone-dark).
14
+ //
15
+ // HOW IT RENDERS. Exactly the way the page reader reads (scripts/gds/page-reader.js):
16
+ // the hall through the hall-preview harness with the pinned fixture account, the
17
+ // landing and status surfaces through the ui-design kit's stub, one browser from
18
+ // the kit's launchBrowser (system Edge or Chrome; nothing is downloaded), the
19
+ // clock pinned, the page's first state from its .states.json. A page that pins
20
+ // one colour mode is shot in that mode for both of its "light" and "dark" slots,
21
+ // and the run says so, rather than inventing a mode the page does not have.
22
+ // Full-page JPEG, so a long page still fits under the store's 4 MB cap.
23
+ //
24
+ // Run: node scripts/gds/tweak-renders.js shoot --page <id> --phase before|after --out <dir>
25
+ // node scripts/gds/tweak-renders.js attach <task-id> --dir <dir> (every slot file in <dir>)
26
+ // node scripts/gds/tweak-renders.js plan --page <id> (what would be shot; no browser)
27
+ //
28
+ // Exit: 0 done · 1 a render or an upload failed · 2 usage · 3 no browser.
29
+
30
+ const fs = require('node:fs');
31
+ const path = require('node:path');
32
+
33
+ const taskVisuals = require('../../modules/lifecycle/task-visuals');
34
+ const { arg, cliClient, cliExit } = require('./cli-lib');
35
+
36
+ const REPO_ROOT = path.resolve(__dirname, '..', '..');
37
+ const EXT = 'jpg';
38
+ const JPEG_QUALITY = 82;
39
+
40
+ // ---- pure ------------------------------------------------------------------
41
+
42
+ function slotName(phase, device, mode) {
43
+ return `${phase}-${device}-${mode}`;
44
+ }
45
+
46
+ // The shots for one phase of one page: one per device x mode, each carrying the
47
+ // slot it fills and the mode the page will actually render in. `plan` is the
48
+ // page's first render plan from page-reader.planRenders.
49
+ function shotsFor(phase, plan) {
50
+ if (!taskVisuals.TWEAK_RENDER_PHASES.includes(phase)) throw new Error(`phase must be one of ${taskVisuals.TWEAK_RENDER_PHASES.join(', ')}`);
51
+ const pinned = plan && plan.pinnedMode ? plan.pinnedMode : null;
52
+ const out = [];
53
+ for (const [device, width] of Object.entries(taskVisuals.TWEAK_RENDER_DEVICES)) {
54
+ for (const mode of taskVisuals.TWEAK_RENDER_MODES) {
55
+ out.push({ slot: slotName(phase, device, mode), device, width, mode, renderMode: pinned || mode, file: `${slotName(phase, device, mode)}.${EXT}` });
56
+ }
57
+ }
58
+ return out;
59
+ }
60
+
61
+ // The page's first render plan, and whether its states file pins a mode. Reads
62
+ // the inventory and the states file; no browser.
63
+ function planForPage(pageId, { inventory, planRenders, root = REPO_ROOT }) {
64
+ const page = (inventory.pages || []).find((p) => p.id === pageId);
65
+ if (!page) return { ok: false, code: 'unknown_page', detail: { page: pageId } };
66
+ const plans = planRenders(page, { root });
67
+ const plan = plans[0];
68
+ let modes = null;
69
+ if (page.states_files && page.states_files.length) {
70
+ try { modes = JSON.parse(fs.readFileSync(path.join(root, page.states_files[0]), 'utf8')).modes || null; } catch { modes = null; }
71
+ }
72
+ const pinnedMode = Array.isArray(modes) && modes.length === 1 ? modes[0] : null;
73
+ return { ok: true, page, plan: { ...plan, pinnedMode } };
74
+ }
75
+
76
+ // Which slot files a directory holds, in slot order, and which are missing.
77
+ function slotFilesIn(dir, { exists = (p) => fs.existsSync(p) } = {}) {
78
+ const found = [];
79
+ const missing = [];
80
+ for (const slot of taskVisuals.TWEAK_RENDER_SLOTS) {
81
+ const hit = ['jpg', 'png', 'webp'].map((e) => path.join(dir, `${slot}.${e}`)).find(exists);
82
+ if (hit) found.push({ slot, file: hit });
83
+ else missing.push(slot);
84
+ }
85
+ return { found, missing };
86
+ }
87
+
88
+ // ---- the browser half --------------------------------------------------------
89
+
90
+ async function shoot({ pageId, phase, out }) {
91
+ const reader = require('./page-reader.js');
92
+ const kit = require('../../modules/ui-design/kit/lib');
93
+ const inventory = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, reader.INVENTORY_REL), 'utf8'));
94
+ const planned = planForPage(pageId, { inventory, planRenders: reader.planRenders });
95
+ if (!planned.ok) { console.error(`tweak-renders: ${planned.code} (${pageId})`); return 2; }
96
+ const { page, plan } = planned;
97
+ const shots = shotsFor(phase, plan);
98
+ if (plan.pinnedMode) console.log(`tweak-renders: ${pageId} pins the ${plan.pinnedMode} mode, so both of its mode slots are shot in ${plan.pinnedMode}.`);
99
+ fs.mkdirSync(out, { recursive: true });
100
+
101
+ let browser;
102
+ try { browser = await kit.launchBrowser(); } catch (e) {
103
+ console.error(`tweak-renders: ${e.message}`);
104
+ return e instanceof kit.NoBrowserError ? 3 : 1;
105
+ }
106
+ const stoppers = [];
107
+ let failed = 0;
108
+ try {
109
+ let base;
110
+ if (page.surface === 'builders') {
111
+ const h = await reader.startHarness(kit);
112
+ stoppers.push(h.stop);
113
+ base = h.base;
114
+ } else {
115
+ const s = await kit.startStub({ surface: reader.SURFACE_MODULE[page.surface] || page.surface, auth: plan.auth, prefix: plan.stub.prefix, child: plan.stub.child, fixtures: plan.stub.fixtures });
116
+ stoppers.push(s.stop);
117
+ base = s.base;
118
+ }
119
+ for (const shot of shots) {
120
+ const [W, H] = kit.SIZES[shot.width];
121
+ const ctx = await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 1, colorScheme: shot.renderMode });
122
+ try {
123
+ if (ctx.clock && typeof ctx.clock.setFixedTime === 'function') await ctx.clock.setFixedTime(new Date(reader.PINNED_CLOCK));
124
+ const p = await ctx.newPage();
125
+ p.setDefaultTimeout(6000);
126
+ p.on('dialog', (d) => { d.dismiss().catch(() => {}); });
127
+ await p.goto(base + kit.withMode(plan.url, shot.renderMode, plan.modeQuery), { waitUntil: 'load', timeout: 45000 });
128
+ await p.evaluate(() => document.fonts.ready).catch(() => {});
129
+ await p.waitForTimeout(kit.SETTLE_MS);
130
+ try { await kit.runActions(p, plan.actions); } catch (e) { console.log(` ${shot.slot}: an action failed (${String(e.message).split('\n')[0].slice(0, 100)}); shot as it stood`); }
131
+ await p.screenshot({ path: path.join(out, shot.file), fullPage: true, type: 'jpeg', quality: JPEG_QUALITY });
132
+ const kb = Math.round(fs.statSync(path.join(out, shot.file)).size / 1024);
133
+ console.log(` ${shot.slot}: ${W}px ${shot.renderMode} -> ${shot.file} (${kb} KB)`);
134
+ } catch (e) {
135
+ failed += 1;
136
+ console.error(` ${shot.slot}: NOT rendered (${String(e.message).split('\n')[0].slice(0, 160)})`);
137
+ } finally {
138
+ await ctx.close().catch(() => {});
139
+ }
140
+ }
141
+ } finally {
142
+ await browser.close().catch(() => {});
143
+ for (const stop of stoppers) stop();
144
+ }
145
+ console.log(`tweak-renders: ${shots.length - failed} of ${shots.length} ${phase} render(s) of ${pageId} in ${out}`);
146
+ return failed ? 1 : 0;
147
+ }
148
+
149
+ // ---- the upload half ---------------------------------------------------------
150
+
151
+ async function attach({ taskId, dir }, deps = {}) {
152
+ const { found, missing } = slotFilesIn(dir, deps);
153
+ if (!found.length) { console.error(`tweak-renders: no slot files in ${dir} (expected ${taskVisuals.TWEAK_RENDER_SLOTS[0]}.${EXT} and the rest).`); return 1; }
154
+ const client = deps.client || await cliClient();
155
+ let failed = 0;
156
+ for (const { slot, file } of found) {
157
+ const img = taskVisuals.readImageFile(file);
158
+ if (!img.ok) { failed += 1; console.error(` ${slot}: ${img.message}`); continue; }
159
+ const r = await client.tasks.postTasksIdVisualsSlot({ id: taskId, slot, body: { image_b64: img.buf.toString('base64'), content_type: img.contentType, alt: `${slot.replace(/-/g, ' ')} render of the page` } });
160
+ if (r.ok) console.log(` ${slot}: attached (${Math.round(img.buf.length / 1024)} KB)`);
161
+ else { failed += 1; console.error(` ${slot}: upload failed (${r.status})`); }
162
+ }
163
+ if (missing.length) console.error(`tweak-renders: MISSING ${missing.length} slot(s): ${missing.join(', ')}. The Approval queue shows what is attached; shoot the missing phase and attach again.`);
164
+ console.log(`tweak-renders: ${found.length - failed} of ${taskVisuals.TWEAK_RENDER_SLOTS.length} render(s) attached to task ${taskId}.`);
165
+ return failed || missing.length ? 1 : 0;
166
+ }
167
+
168
+ async function main(args) {
169
+ const cmd = args[0];
170
+ if (cmd === 'shoot' || cmd === 'plan') {
171
+ const pageId = arg('--page', args);
172
+ if (!pageId) { console.error('usage: node scripts/gds/tweak-renders.js shoot --page <id> --phase before|after --out <dir>'); return 2; }
173
+ if (cmd === 'plan') {
174
+ const reader = require('./page-reader.js');
175
+ const inventory = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, reader.INVENTORY_REL), 'utf8'));
176
+ const planned = planForPage(pageId, { inventory, planRenders: reader.planRenders });
177
+ if (!planned.ok) { console.error(`tweak-renders: ${planned.code} (${pageId})`); return 2; }
178
+ for (const phase of taskVisuals.TWEAK_RENDER_PHASES) for (const s of shotsFor(phase, planned.plan)) console.log(`${s.slot}\t${s.width}px\t${s.renderMode}\t${planned.plan.url}`);
179
+ return 0;
180
+ }
181
+ const phase = arg('--phase', args);
182
+ const out = arg('--out', args);
183
+ if (!taskVisuals.TWEAK_RENDER_PHASES.includes(phase) || !out) { console.error('usage: node scripts/gds/tweak-renders.js shoot --page <id> --phase before|after --out <dir>'); return 2; }
184
+ return shoot({ pageId, phase, out: path.resolve(out) });
185
+ }
186
+ if (cmd === 'attach') {
187
+ const taskId = Number(args[1]);
188
+ const dir = arg('--dir', args);
189
+ if (!Number.isInteger(taskId) || taskId <= 0 || !dir) { console.error('usage: node scripts/gds/tweak-renders.js attach <task-id> --dir <dir>'); return 2; }
190
+ return attach({ taskId, dir: path.resolve(dir) });
191
+ }
192
+ console.error('usage: node scripts/gds/tweak-renders.js shoot|attach|plan … (see the file header)');
193
+ return 2;
194
+ }
195
+
196
+ if (require.main === module) {
197
+ main(process.argv.slice(2)).then((code) => cliExit(code), (e) => { console.error(`tweak-renders: ${e.stack || e.message}`); cliExit(1); });
198
+ }
199
+
200
+ module.exports = { shotsFor, planForPage, attach };
@@ -136,6 +136,9 @@ const FIXTURE_FALLBACKS = [
136
136
  // page id, since the editor shows one page. A page id carries a colon, which a
137
137
  // Windows file name cannot, so there is no per-id file to find first.
138
138
  [/^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft'],
139
+ // the Approval queue's renders (task 1004323): a round's named visuals, one
140
+ // fixture for any task id, as the editor read above serves any page.
141
+ [/^tasks\/\d+\/visuals$/, 'tasks__visuals'],
139
142
  ];
140
143
 
141
144
  // CANNED WRITES (task 1004321). The tweak editor's whole flow is writes (the
@@ -148,6 +151,10 @@ const CANNED_WRITES = [
148
151
  ['PUT', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft.put'],
149
152
  ['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/submit$/, 'copy-desk__pages__submit.post'],
150
153
  ['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/claim$/, 'copy-desk__pages__claim.post'],
154
+ // the Approval queue's two decisions (task 1004323), so the approved and
155
+ // sent-back lines the artboard draws render too.
156
+ ['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/approve$/, 'copy-desk__pages__approve.post'],
157
+ ['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/send-back$/, 'copy-desk__pages__send-back.post'],
151
158
  ];
152
159
 
153
160
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
@@ -252,6 +259,17 @@ app.use((req, res, next) => {
252
259
  // frame, so the one thing being reviewed is the one thing you cannot see.
253
260
  // A generated stand-in; see scripts/hall-preview/task-visual.js.
254
261
  if (/^task-visuals\/[A-Za-z0-9._-]+$/.test(apiPath) && req.method === 'GET') {
262
+ // HALL_PREVIEW_VISUALS=<dir> serves a REAL image of that name from <dir>
263
+ // instead (task 1004323): the Approval queue's review sheet shows the renders
264
+ // /tweak really attached, copied under the fixture's names into a scratch
265
+ // dir. Nothing is committed; unset, every visual is the stand-in as before.
266
+ const realDir = process.env.HALL_PREVIEW_VISUALS;
267
+ const name = apiPath.slice('task-visuals/'.length);
268
+ const real = realDir && /^task-\d+-[0-9a-f]{16}\.(png|jpg)$/.test(name) ? path.join(realDir, name) : null;
269
+ if (real && fs.existsSync(real)) {
270
+ res.set({ 'content-type': name.endsWith('.png') ? 'image/png' : 'image/jpeg', 'cache-control': 'no-store' });
271
+ return res.end(fs.readFileSync(real));
272
+ }
255
273
  const png = require('./task-visual').placeholderPng();
256
274
  res.set({ 'content-type': 'image/png', 'cache-control': 'no-store' });
257
275
  return res.end(png);
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1063'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1065'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');