@bongos/core 1.19.1062 → 1.19.1064

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 (56) hide show
  1. package/.bongos-core.json +123 -48
  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 +8 -0
  6. package/clients/bongos-client/index.cjs +8 -0
  7. package/clients/bongos-client/index.d.ts +12 -0
  8. package/clients/bongos-client/index.mjs +8 -0
  9. package/docs/api/openapi.json +301 -3
  10. package/docs/api-reference.md +7 -3
  11. package/docs/copy-inventory.md +44 -7
  12. package/docs/copy-registry.json +393 -26
  13. package/docs/file-map.md +1 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-inventory.json +36 -4
  16. package/docs/page-readings.json +34 -1
  17. package/modules/copy-desk/page-status.js +63 -0
  18. package/modules/copy-desk/pages.js +1 -0
  19. package/modules/copy-desk/routes/copy-desk.js +30 -0
  20. package/modules/hall-ui/public/tweak-editor-lib.js +137 -0
  21. package/modules/hall-ui/public/tweak-editor.css +437 -0
  22. package/modules/hall-ui/public/tweak-editor.html +111 -0
  23. package/modules/hall-ui/public/tweak-editor.js +447 -0
  24. package/modules/hall-ui/public/tweak-editor.states.json +99 -0
  25. package/modules/hall-ui/records/tweak-editor.md +32 -0
  26. package/modules/lifecycle/db-grade.js +16 -0
  27. package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
  28. package/modules/lifecycle/page-tweak-hold.js +74 -0
  29. package/modules/lifecycle/page-tweak-reads.js +33 -1
  30. package/modules/lifecycle/routes/tasks.js +4 -2
  31. package/modules/lifecycle/routes/visuals.js +82 -0
  32. package/modules/lifecycle/task-visual-db.js +40 -1
  33. package/modules/lifecycle/task-visuals.js +24 -0
  34. package/package-lock.json +2 -2
  35. package/package.json +1 -1
  36. package/release-notes.json +12 -0
  37. package/scripts/gds/copy-apply.js +277 -1
  38. package/scripts/gds/page-reader.js +2 -0
  39. package/scripts/gds/ship-finish.js +27 -2
  40. package/scripts/gds/ship-flow.js +17 -1
  41. package/scripts/gds/ship-land.js +30 -7
  42. package/scripts/gds/ship-merge.js +43 -15
  43. package/scripts/gds/strand-watch.js +41 -1
  44. package/scripts/gds/task.js +23 -5
  45. package/scripts/gds/tweak-renders.js +200 -0
  46. package/scripts/hall-preview/server.js +21 -0
  47. package/src/bongos/serve-internal.js +5 -1
  48. package/src/module-api.js +1 -1
  49. package/tests/copy_desk_page_editor.mjs +181 -0
  50. package/tests/hall_audit.mjs +5 -0
  51. package/tests/hall_page_gate_map.mjs +3 -0
  52. package/tests/hall_tweak_editor.mjs +432 -0
  53. package/tests/page_tweak_hold.mjs +265 -0
  54. package/tests/publish_status_branch.mjs +23 -0
  55. package/tests/task_visual_slots.mjs +161 -0
  56. 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 };
@@ -132,6 +132,22 @@ const FIXTURE_FALLBACKS = [
132
132
  // the npm-release module's line on a task's page (task 1004301): one reading serves any
133
133
  // id, as the core-upgrade preview above does — the page shows one task.
134
134
  [/^npm-release\/task\/\d+$/, 'npm-release__task'],
135
+ // the tweak editor's read (task 1004321): one page's lines and draft serve any
136
+ // page id, since the editor shows one page. A page id carries a colon, which a
137
+ // Windows file name cannot, so there is no per-id file to find first.
138
+ [/^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft'],
139
+ ];
140
+
141
+ // CANNED WRITES (task 1004321). The tweak editor's whole flow is writes (the
142
+ // autosave, the submit, the claim "take next" posts), so without an answer the
143
+ // harness could only ever show its "not saved" state, never the page the
144
+ // artboard draws after a save or a submit. Each member answers a FIXED fixture
145
+ // and changes nothing: the harness has no state to change, and the page reads
146
+ // the same fixtures again on reload. Anything absent is still refused.
147
+ const CANNED_WRITES = [
148
+ ['PUT', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/draft$/, 'copy-desk__pages__draft.put'],
149
+ ['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/submit$/, 'copy-desk__pages__submit.post'],
150
+ ['POST', /^copy-desk\/pages\/[a-z0-9-]+:[a-z0-9-]+\/claim$/, 'copy-desk__pages__claim.post'],
135
151
  ];
136
152
 
137
153
  // A DATE THAT ROTS CANNOT DEMO AN AGE SIGNAL (task 1004041). The help-request
@@ -254,6 +270,11 @@ app.use((req, res, next) => {
254
270
  const READ_SHAPED_POSTS = new Set([
255
271
  'inbox/preview-landing', // the landing verdict for a draft (task 1004083)
256
272
  ]);
273
+ const canned = CANNED_WRITES.find(([method, re]) => method === req.method && re.test(apiPath));
274
+ if (canned) {
275
+ const hit = loadFixture(canned[2]);
276
+ if (hit !== undefined) return res.json(hit);
277
+ }
257
278
  if (req.method !== 'GET' && !READ_SHAPED_POSTS.has(apiPath)) {
258
279
  return res.status(501).json({ error: { code: 'preview_readonly', message: 'the preview harness serves fixtures; writes are not wired' } });
259
280
  }
@@ -1205,8 +1205,12 @@ function mountInternalSurfaces(app) {
1205
1205
  // GET /sky/mine, with the room inside it as the list drawer. The old ambient
1206
1206
  // page at `sky` (task 1004105) is gone; its URL 301s to /thinking below, so it
1207
1207
  // leaves this regex — one URL cannot be two tiers, and a redirect is not a page.
1208
+ // task 1004321 (BV2.TW10): `tweak-editor`, Tweak Mode's editor, the artist's
1209
+ // own room beside the studio, on the studio's tier. Its read (GET
1210
+ // /copy-desk/pages/:pageId/draft) is any-builder like the .docx download, and
1211
+ // every write is the page claim's holder only, checked server-side.
1208
1212
  const SIGNEDIN_BUILDERS_PAGE_RE =
1209
- /^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
1213
+ /^\/builders\/(?:work|settings|atlas|modules|goals|goal-map|roadmap|copy-desk|studio|tweak-editor|ideas|thinking|collab|task|idea|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
1210
1214
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
1211
1215
  // page that embeds them.
1212
1216
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
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.1062'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1064'; // 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');
@@ -0,0 +1,181 @@
1
+ // tests/copy_desk_page_editor.mjs — the read the tweak editor opens on,
2
+ // GET /copy-desk/pages/:pageId/draft, the WHOLE path (task 1004321 / BV2.TW10,
3
+ // ADR 0341 D4).
4
+ //
5
+ // The done-when's middle: an artist rewrites a page, closes the tab, and comes
6
+ // back to the draft. So the read must give back every line of the page in page
7
+ // order with its margin note, say whether the caller holds the round, and hand
8
+ // back exactly the draft the autosave stored, and never a draft saved on
9
+ // another reading (its keys may name other lines).
10
+ //
11
+ // Driven through the real copy-desk routes, the real lifecycle port (the page
12
+ // claim, updateHeldTaskDescription, submitPageTweak) and the real page reads,
13
+ // against the shared stateful fake ledger (tests/fixtures/page-ledger.mjs).
14
+
15
+ import { strict as assert } from 'node:assert';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import os from 'node:os';
19
+ import path from 'node:path';
20
+ import { makeRunner } from './helpers.mjs';
21
+ import { installPageLedger } from './fixtures/page-ledger.mjs';
22
+
23
+ process.env.NODE_ENV = 'test';
24
+ const require = createRequire(import.meta.url);
25
+ const F = require('../modules/copy-desk/tests/fixtures/page-tweak.cjs');
26
+ const pageStatus = require('../modules/copy-desk/page-status.js');
27
+ const api = require('../src/module-api.js');
28
+
29
+ installPageLedger(api, {
30
+ builders: [
31
+ { id: '7', github_login: 'alice', display_name: 'Alice', preferred_disciplines: ['artist'] },
32
+ { id: '8', github_login: 'bob', display_name: 'Bob', preferred_disciplines: ['artist'] },
33
+ ],
34
+ });
35
+ const ALICE = '7';
36
+ const BOB = '8';
37
+
38
+ api.requireBuilder = (req, res, next) => {
39
+ req.builder = { id: String(req.headers['x-builder'] || ALICE), rank: 'metic' };
40
+ next();
41
+ };
42
+
43
+ const seams = require('../src/module-seams.js');
44
+ const lifecyclePort = require('../modules/lifecycle/lifecycle.js');
45
+ if (!seams.hasProvider('lifecycle')) seams.registerProvider('lifecycle', lifecyclePort);
46
+
47
+ const pageData = require('../modules/copy-desk/page-data.js');
48
+ const express = require('express');
49
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
50
+ const buildRouter = require('../modules/copy-desk/routes/copy-desk.js');
51
+
52
+ const app = express();
53
+ app.use(express.json());
54
+ app.use(attachFail);
55
+ app.use(buildRouter());
56
+ const server = await new Promise((resolve) => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
57
+ const base = `http://127.0.0.1:${server.address().port}`;
58
+
59
+ async function call(method, p, builder, body = {}) {
60
+ const r = await fetch(base + p, { method, headers: { 'content-type': 'application/json', 'x-builder': builder }, body: method === 'GET' ? undefined : JSON.stringify(body) });
61
+ return { status: r.status, body: await r.json() };
62
+ }
63
+ const errCode = (b) => (b && b.error && b.error.code) || null;
64
+
65
+ const L = (key, section, text, placement, row) => ({ key, section, text, placement, ...(row || {}) });
66
+ const STUDIO_LINES = [
67
+ L('L0001', 'top bar', 'Studio', 'shared', { file: 'modules/hall-ui/public/shell.js', line: 40, string_id: 'sh0001' }),
68
+ L('L0002', 'top bar', 'Ctrl K', 'shared'),
69
+ L('L0003', 'greeting', 'Welcome back', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 12, string_id: 'st0003' }),
70
+ L('L0004', 'greeting', 'Your desk is quiet today', 'unplaced'),
71
+ L('L0005', 'footer', 'Resume where you left off', 'placed', { file: 'modules/hall-ui/public/studio.html', line: 90, string_id: 'st0005' }),
72
+ ];
73
+ const readingsWith = (studioHash, lines = STUDIO_LINES) => ({
74
+ schema: 1,
75
+ pages: [{ id: 'builders:studio', reading_hash: studioHash, lines }],
76
+ });
77
+
78
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'page-editor-'));
79
+ fs.mkdirSync(path.join(tmp, 'docs'), { recursive: true });
80
+ fs.writeFileSync(path.join(tmp, 'docs/page-inventory.json'), JSON.stringify(F.INVENTORY));
81
+ function writeReadings(r) {
82
+ fs.writeFileSync(path.join(tmp, 'docs/page-readings.json'), JSON.stringify(r));
83
+ pageData._setRoot(tmp); // drop the (mtime, size) cache
84
+ }
85
+
86
+ const { test, summary } = makeRunner();
87
+ const STUDIO = '/copy-desk/pages/builders:studio';
88
+
89
+ try {
90
+ writeReadings(readingsWith('studio-h1'));
91
+
92
+ await test('before anyone claims: every line in page order, with its margin note, and no round', async () => {
93
+ const r = await call('GET', `${STUDIO}/draft`, ALICE);
94
+ assert.equal(r.status, 200, JSON.stringify(r.body));
95
+ assert.equal(r.body.page.title, 'Studio');
96
+ assert.equal(r.body.page.surface_word, 'builders hall');
97
+ assert.equal(r.body.reading.reading_hash, 'studio-h1');
98
+ assert.deepEqual(r.body.reading.lines.map((l) => [l.key, l.section, l.text, l.note]), [
99
+ ['L0001', 'top bar', 'Studio', 'shared'],
100
+ // Shared shell text with no registry row cannot be applied mechanically,
101
+ // so it goes to an engineer at submit, and the note says so.
102
+ ['L0002', 'top bar', 'Ctrl K', 'engineer'],
103
+ ['L0003', 'greeting', 'Welcome back', null],
104
+ ['L0004', 'greeting', 'Your desk is quiet today', 'engineer'],
105
+ ['L0005', 'footer', 'Resume where you left off', null],
106
+ ]);
107
+ assert.deepEqual(r.body.notes, { shared: 'changes this on every page', engineer: 'goes to an engineer' });
108
+ assert.equal(r.body.round, null);
109
+ assert.equal(r.body.draft, null);
110
+ assert.deepEqual(r.body.counter, { changed: 0, total: 5 });
111
+ // The read never exposes where a line lives in the code.
112
+ assert.equal(r.body.reading.lines.some((l) => 'file' in l || 'string_id' in l), false);
113
+ });
114
+
115
+ await test('rewrite, close the tab, come back: the read hands back the saved draft, held by you', async () => {
116
+ assert.equal((await call('POST', `${STUDIO}/claim`, ALICE)).status, 201);
117
+ const saved = await call('PUT', `${STUDIO}/draft`, ALICE, {
118
+ reading_hash: 'studio-h1',
119
+ lines: [{ key: 'L0003', after: 'Welcome home' }, { key: 'L0001', after: 'The studio' }],
120
+ });
121
+ assert.equal(saved.body.outcome, 'saved');
122
+ const back = await call('GET', `${STUDIO}/draft`, ALICE);
123
+ assert.equal(back.status, 200);
124
+ assert.equal(back.body.round.state, 'writing');
125
+ assert.equal(back.body.round.held_by_you, true);
126
+ assert.equal(back.body.round.claim.path, '/copy-desk/pages/builders:studio/claim');
127
+ assert.deepEqual(back.body.draft.lines, [{ key: 'L0001', after: 'The studio' }, { key: 'L0003', after: 'Welcome home' }]);
128
+ assert.equal(back.body.draft.saved_at, saved.body.draft.saved_at);
129
+ assert.deepEqual(back.body.counter, { changed: 2, total: 5 });
130
+ });
131
+
132
+ await test('another artist sees the round held by its writer, not by them', async () => {
133
+ const r = await call('GET', `${STUDIO}/draft`, BOB);
134
+ assert.equal(r.body.round.held_by_you, false);
135
+ assert.deepEqual(r.body.round.holder, { id: ALICE, login: 'alice' });
136
+ });
137
+
138
+ await test('a draft saved on an older reading is reported, never laid over the new lines', async () => {
139
+ writeReadings(readingsWith('studio-h2'));
140
+ const r = await call('GET', `${STUDIO}/draft`, ALICE);
141
+ assert.equal(r.body.draft, null);
142
+ assert.equal(r.body.draft_on_older_reading, true);
143
+ assert.deepEqual(r.body.counter, { changed: 0, total: 5 });
144
+ writeReadings(readingsWith('studio-h1'));
145
+ });
146
+
147
+ await test('after submit the round reads submitted and nobody holds it', async () => {
148
+ assert.equal((await call('POST', `${STUDIO}/submit`, ALICE)).body.outcome, 'submitted');
149
+ const r = await call('GET', `${STUDIO}/draft`, ALICE);
150
+ assert.equal(r.body.round.state, 'submitted');
151
+ assert.equal(r.body.round.held_by_you, false);
152
+ assert.equal(r.body.round.holder, null);
153
+ });
154
+
155
+ await test('an unknown page is unknown_page, and a page with no reading is page_unread', async () => {
156
+ const r = await call('GET', '/copy-desk/pages/landing:nowhere/draft', ALICE);
157
+ assert.equal(r.status, 404);
158
+ assert.equal(errCode(r.body), 'unknown_page');
159
+ const u = await call('GET', '/copy-desk/pages/landing:index/draft', ALICE);
160
+ assert.equal(u.status, 503);
161
+ assert.equal(errCode(u.body), 'page_unread');
162
+ });
163
+
164
+ await test('composeEditor keeps only draft lines the reading still has', () => {
165
+ const out = pageStatus.composeEditor({
166
+ page: { id: 'builders:studio', title: 'Studio', surface: 'builders' },
167
+ readingPage: { reading_hash: 'h', lines: [STUDIO_LINES[2]] },
168
+ round: { id: 9, status: 'active', holder_id: 7, holder_login: 'alice', description: '```page-tweak-draft\n' + JSON.stringify({ schema: 1, page_id: 'builders:studio', reading_hash: 'h', saved_at: 'x', lines: [
169
+ { key: 'L0003', section: 'greeting', before: 'Welcome back', after: 'Hi', placement: 'placed' },
170
+ { key: 'L0099', section: 'gone', before: 'Old', after: 'New', placement: 'placed' },
171
+ ] }) + '\n```\n' },
172
+ builderId: 7,
173
+ });
174
+ assert.deepEqual(out.draft.lines, [{ key: 'L0003', after: 'Hi' }]);
175
+ assert.equal(out.round.held_by_you, true);
176
+ });
177
+ } finally {
178
+ server.close();
179
+ pageData._setRoot(null);
180
+ }
181
+ summary();
@@ -424,6 +424,11 @@ const LITERAL_EXEMPT = {
424
424
  'thinking.css': 'the sky\'s own sheet is its token tier (task 1004231, ADR 0331 — the design of record)',
425
425
  'sky-interaction.js': 'the sky\'s hover card writes the template\'s ring colour inline (task 1004231)',
426
426
  'sky-panel.js': 'the sky\'s panel writes the template\'s dead-bar colour inline (task 1004231)',
427
+ // The tweak editor (task 1004321 / BV2.TW10): docs/design/mocks/tweak-mode/
428
+ // Editor.dc.html is the owner-approved master design, built 1:1: the night
429
+ // studio, the glass bar, and a sheet of PAPER that stays paper in both modes,
430
+ // so its palette cannot be the hall's mode-flipping tier. The sky precedent.
431
+ 'tweak-editor.css': 'the tweak editor\'s stage is the artboard\'s own palette (task 1004321, the pixel contract: paper in both modes)',
427
432
  };
428
433
  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';
429
434
  const colourLiteralRe = () =>
@@ -62,6 +62,9 @@ const ORACLE = {
62
62
  // whole gate, and its two reads (GET /tasks, GET /copy-desk/queue) already gate
63
63
  // themselves server-side.
64
64
  '/builders/studio': 'requireBuilderPage',
65
+ // task 1004321 (BV2.TW10): the tweak editor, on the studio's own tier. Its read
66
+ // is any-builder like the .docx download; every write is the page holder's only.
67
+ '/builders/tweak-editor': 'requireBuilderPage',
65
68
  '/builders/ideas': 'requireBuilderPage',
66
69
  // THE ROOM (task 1004098 / BV2.R03) — a builder's own idea trail. Sign-in and
67
70
  // no rank floor, the tier /ideas sits on: there is no permission gate on