@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
@@ -0,0 +1,52 @@
1
+ -- lifecycle_013_task_visual_slots.sql — a task can carry NAMED visuals, not only
2
+ -- the one (task 1004322 / BV2.TW11, goal 1000095; ADR 0341 D7 and its builder
3
+ -- pick 9).
4
+ --
5
+ -- WHY. core_223 gave a task ONE optional image (tasks.visual_url + visual_alt),
6
+ -- the picture beside its value summary. A page tweak needs eight: the applied
7
+ -- page before and after, on desktop and on a phone, in light and in dark. The
8
+ -- Approval queue (TW12) shows the artist exactly those renders, images of the
9
+ -- applied branch, never a page built from the batch, which is what keeps ADR
10
+ -- 0233's "nothing renders a task description as product copy" true. One column
11
+ -- cannot hold eight pictures, and eight columns would be a schema for one
12
+ -- feature, so a visual gets a NAME and a row.
13
+ --
14
+ -- THE SINGLE VISUAL IS UNTOUCHED. tasks.visual_url stays the ship-time picture
15
+ -- every existing reader renders (the hall, the status feed); a slot is a
16
+ -- separate, named picture a reader asks for by name. Nothing here moves,
17
+ -- copies or backfills the existing column.
18
+ --
19
+ -- A MODULE TABLE, because ADR 0083 lets a module migration create only its own
20
+ -- lifecycle_-namespaced tables; it references the core tasks table the way
21
+ -- lifecycle_010 does, and never alters it.
22
+ --
23
+ -- THE SAME AUDIENCE AS THE ONE VISUAL. A slot's file is stored and served by the
24
+ -- existing task-visuals store (task-<id>-<hex>.<ext>, GET /task-visuals/:name),
25
+ -- whose serve route derives who may see an image from the TASK it names. So a
26
+ -- slot adds no privacy axis, exactly as core_223 promised of the column.
27
+ --
28
+ -- PORTABLE and replay-safe: IF NOT EXISTS throughout, no instance data.
29
+
30
+ BEGIN;
31
+
32
+ CREATE TABLE IF NOT EXISTS lifecycle_task_visual_slots (
33
+ task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
34
+
35
+ -- The slot's name: lowercase words joined by dashes, such as
36
+ -- 'after-phone-dark'. The vocabulary a page tweak uses is declared once in
37
+ -- modules/lifecycle/task-visuals.js (TWEAK_RENDER_SLOTS); the column checks only
38
+ -- the SHAPE, so a later feature can name its own slots without a migration.
39
+ slot text NOT NULL CHECK (slot ~ '^[a-z0-9]+(-[a-z0-9]+){0,5}$' AND length(slot) <= 48),
40
+
41
+ -- The served URL (/api/bongos/task-visuals/<file>), minted by the store.
42
+ visual_url text NOT NULL,
43
+ visual_alt text,
44
+
45
+ updated_at timestamptz NOT NULL DEFAULT now(),
46
+
47
+ -- One picture per (task, slot): attaching a slot again REPLACES it, and the
48
+ -- route unlinks the superseded file.
49
+ PRIMARY KEY (task_id, slot)
50
+ );
51
+
52
+ COMMIT;
@@ -0,0 +1,74 @@
1
+ // modules/lifecycle/page-tweak-hold.js — the ONE definition of "a page tweak
2
+ // waiting for its artist" (task 1004322 / BV2.TW11, ADR 0341 D7).
3
+ //
4
+ // THE HOLD. A page tweak transcribes words an artist wrote, so its artist must
5
+ // see the applied page before it ships (spec decision 4). When a page tweak's
6
+ // grade PASSES, applyGrade records the grade but does not promote `completed`
7
+ // to `confirmed`: the task waits at `completed` until the artist approves it
8
+ // (TW12's approve route does the promotion the grade withheld) or sends it back.
9
+ //
10
+ // WHY ONE FILE. Five readers must agree on this one fact, and a second copy of
11
+ // it is how they would drift: applyGrade (do not promote), the publish-branch
12
+ // route (publish, but never arm auto-merge), the publish-status route (never
13
+ // nudge the merge), ship.js (publish the branch and say the page is waiting)
14
+ // and strand-watch / grade-recover (a held pass is not a failed grade). Every
15
+ // one of them asks the questions below.
16
+ //
17
+ // PURE: no require, no fs, no DB. The scripts under scripts/gds/ require it
18
+ // directly, so it must load without the module doorway.
19
+ //
20
+ // Every other task is untouched by construction: each predicate is false unless
21
+ // the task's source is exactly 'page-tweak'.
22
+
23
+ 'use strict';
24
+
25
+ // The task source the page claim writes (modules/copy-desk/pages.js SOURCE).
26
+ // Restated, not required: copy-desk is another module, and this file requires
27
+ // nothing so that the CLI can load it.
28
+ const PAGE_TWEAK_SOURCE = 'page-tweak';
29
+
30
+ // The derived round state this hold produces (ADR 0341 D7's table).
31
+ const HOLD_STATE = 'waiting_for_artist';
32
+
33
+ function isPageTweak(task) {
34
+ return !!task && task.source === PAGE_TWEAK_SOURCE;
35
+ }
36
+
37
+ // Is this task held for its artist? `gradePassed` is the task's grade row's
38
+ // `passed` (task_grades keeps one row per task, the latest). Only a strict
39
+ // `true` holds: a missing grade, or a failed one, is the ordinary `completed`
40
+ // that /grade-recover handles.
41
+ function isHeldForArtist(task, gradePassed) {
42
+ return isPageTweak(task) && task.status === 'completed' && gradePassed === true;
43
+ }
44
+
45
+ // What ship.js prints when it parks a held pass. One place, so the ship, the
46
+ // re-grade and the resume all say the same thing.
47
+ function heldLines(taskId, { prUrl = null } = {}) {
48
+ return [
49
+ '',
50
+ `Task #${taskId} passed its grade and is WAITING FOR THE ARTIST (ADR 0341 D7).`,
51
+ ' This is a page tweak: the artist approves the applied page before it ships.',
52
+ prUrl
53
+ ? ` The branch and its PR are published (${prUrl}); auto-merge is NOT armed.`
54
+ : ' The branch is published; auto-merge is NOT armed.',
55
+ ' The task stays at completed. Approve promotes it to land; send-back returns it to the /tweak queue.',
56
+ ' Nothing more to do here.',
57
+ ];
58
+ }
59
+
60
+ // The auto-merge result the publish route reports for a held pass, in the shape
61
+ // enableAutoMergeDetailed returns, so the route's response carries its reason
62
+ // on the field it already has (auto_merge_reason).
63
+ const HELD_NOT_ARMED = Object.freeze({
64
+ ok: false,
65
+ reason: 'held for the artist (ADR 0341 D7): a page tweak lands only after its artist approves it',
66
+ });
67
+
68
+ module.exports = {
69
+ HELD_NOT_ARMED,
70
+ HOLD_STATE,
71
+ isPageTweak,
72
+ isHeldForArtist,
73
+ heldLines,
74
+ };
@@ -12,6 +12,7 @@
12
12
  'use strict';
13
13
 
14
14
  const api = require('../../src/module-api');
15
+ const hold = require('./page-tweak-hold');
15
16
 
16
17
  // Every page-tweak task, oldest first, with what the round state (D7) and the
17
18
  // changelog (D8) need beside the task itself:
@@ -93,4 +94,35 @@ async function shippedTasksTouchingSince({ files, since, limit = 50 } = {}) {
93
94
  return rows;
94
95
  }
95
96
 
96
- module.exports = { listPageTweakTasks, shippedTasksTouchingSince };
97
+ // Is this task a page tweak held for its artist (ADR 0341 D7; task 1004322)?
98
+ // The publish routes ask it before they arm or nudge a merge: a held pass is
99
+ // published so the Approval queue can show the applied branch, but it must not
100
+ // land until the artist approves. One row: the task's source and status, and its
101
+ // grade (task_grades keeps one row per task). The verdict is the shared pure
102
+ // predicate, so this read and applyGrade cannot disagree about what "held" means.
103
+ async function readTweakHold(taskId) {
104
+ const { rows } = await api.pool.query(
105
+ `SELECT t.source, t.status, g.passed AS grade_passed
106
+ FROM tasks t
107
+ LEFT JOIN task_grades g ON g.task_id = t.id
108
+ WHERE t.id = $1`,
109
+ [taskId]
110
+ );
111
+ const row = rows[0] || null;
112
+ return { held: !!row && hold.isHeldForArtist(row, row.grade_passed), page_tweak: hold.isPageTweak(row) };
113
+ }
114
+
115
+ // The publish routes' form of the question, FAIL-CLOSED: a read that throws
116
+ // answers "held", so the route neither arms auto-merge nor nudges the merge.
117
+ // That is the safe direction for every task: an ordinary confirmed task whose
118
+ // PR was left un-armed still lands, because the publish reconciler's sweep
119
+ // merges green PRs of confirmed tasks on its own (task 1162).
120
+ async function heldForArtistFailClosed(taskId, read = readTweakHold) {
121
+ try {
122
+ return (await read(taskId)).held;
123
+ } catch (_) {
124
+ return true;
125
+ }
126
+ }
127
+
128
+ module.exports = { listPageTweakTasks, shippedTasksTouchingSince, heldForArtistFailClosed };
@@ -35,6 +35,7 @@ const { depRefusalRow } = require('../dead-deps.js');
35
35
  const { gradeQualityWhere } = require('../db-shared');
36
36
  const auth = api;
37
37
  const db = require('../db');
38
+ const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED } = require('../page-tweak-hold'); // ADR 0341 D7 (tests stub the read)
38
39
  const hierarchy = require('../hierarchy');
39
40
  const { suggestCriteria } = require('../criterion-suggest');
40
41
  // The orphan-task advisory (task 1002539) — pure, fail-open; see goal-advisory.js.
@@ -1184,7 +1185,8 @@ module.exports = function buildTasksRouter() {
1184
1185
  // task 1001431: ask WHY when arming fails. `auto_merge: false` on its own told the
1185
1186
  // builder nothing, and the commonest cause is a repo with allow_auto_merge OFF —
1186
1187
  // a permanent condition that reads exactly like a transient blip.
1187
- const autoMergeResult = await githubPush.enableAutoMergeDetailed({ nodeId: pr.node_id });
1188
+ // task 1004322 (ADR 0341 D7): a page tweak held for its artist is PUBLISHED, never armed.
1189
+ const autoMergeResult = (await pageTweakReads.heldForArtistFailClosed(id)) ? HELD_NOT_ARMED : await githubPush.enableAutoMergeDetailed({ nodeId: pr.node_id });
1188
1190
  const autoMerge = autoMergeResult.ok;
1189
1191
  // task 1139 (ADR 0058): attest the author's trust tier on the pushed sha so the
1190
1192
  // gate-review check can auto-approve a Metic+ author's gate-surface PR with no
@@ -1326,7 +1328,7 @@ module.exports = function buildTasksRouter() {
1326
1328
  // mirrors the reconcile-on-read precedent above (a GET that finishes the
1327
1329
  // land); best-effort so a merge-driver miss never turns the poll into a 502.
1328
1330
  let merge_driver = null;
1329
- if (!reconciled && !status.merged && status.pr_state === 'open') {
1331
+ if (!reconciled && !status.merged && status.pr_state === 'open' && !(await pageTweakReads.heldForArtistFailClosed(id))) { // a held page tweak is never nudged (ADR 0341 D7)
1330
1332
  try {
1331
1333
  merge_driver = await githubPush.mergeIfGreen({ branch });
1332
1334
  } catch (e) {
@@ -14,6 +14,12 @@
14
14
  // DELETE /tasks/:id/visual take it back off (claim holder | Archon)
15
15
  // GET /task-visuals/:name serve one (see the audience note)
16
16
  //
17
+ // NAMED SLOTS (task 1004322 / BV2.TW11, lifecycle_013) — the same store, the same
18
+ // gate, the same audience, one picture per name:
19
+ // POST /tasks/:id/visuals/:slot attach or replace a named one (claim holder | Archon)
20
+ // DELETE /tasks/:id/visuals/:slot take it back off (claim holder | Archon)
21
+ // GET /tasks/:id/visuals list them by name (any builder)
22
+ //
17
23
  // The twin of routes/bug-attachments.js, for the image a builder attaches to
18
24
  // their own shipped task instead of one the bot pulled out of #bugs. Storage +
19
25
  // screening live in ../task-visuals.js; all three routes live here rather than
@@ -191,6 +197,82 @@ module.exports = function buildTaskVisualsRouter() {
191
197
  }
192
198
  });
193
199
 
200
+ // ==========================================================================
201
+ // NAMED SLOTS (task 1004322 / BV2.TW11; ADR 0341 D7, builder pick 9)
202
+ // --------------------------------------------------------------------------
203
+ // A page tweak carries eight renders of the applied page (before and after,
204
+ // desktop and phone, light and dark), and the Approval queue shows them by
205
+ // name. Each slot is stored and screened exactly as the one visual is, and
206
+ // served by the same GET /task-visuals/:name, whose audience comes from the
207
+ // task. The slot name is validated before any byte is read.
208
+
209
+ // POST /tasks/:id/visuals/:slot — attach (or replace) one named visual.
210
+ router.post('/tasks/:id/visuals/:slot', auth.requireBuilder, visualUpload, async (req, res) => {
211
+ const id = parseId(req, res, { code: 'bad_task_id' });
212
+ if (id === null) return;
213
+ const slot = req.params.slot;
214
+ if (!visuals.isSafeSlot(slot)) return res.fail('bad_slot', 400, { max_length: visuals.MAX_SLOT_LENGTH, example: visuals.TWEAK_RENDER_SLOTS[0] });
215
+ if (validateOrRespond(req, res, {
216
+ image_b64: { required: true, type: 'string', maxLength: VISUAL_B64_MAX, minLength: 1 },
217
+ content_type: { required: true, type: 'string', maxLength: 100, minLength: 1 },
218
+ alt: { type: 'string', maxLength: visuals.MAX_ALT_LENGTH },
219
+ })) return;
220
+ if (!(await gateTaskOwnership(req, res, id))) return;
221
+ const buf = Buffer.from(req.body.image_b64, 'base64');
222
+ const screened = visuals.screenImage(buf, req.body.content_type);
223
+ if (!screened.ok) {
224
+ return res.fail(`visual_${screened.reason}`, screened.reason === 'too_big' ? 413 : 400, {
225
+ max_bytes: visuals.MAX_BYTES,
226
+ accepted: Object.keys(visuals.EXT_BY_TYPE),
227
+ });
228
+ }
229
+ const task = await db.getTaskVisualAccess(id);
230
+ if (!task) return res.fail('task_not_found', 404);
231
+ try {
232
+ const stored = await visuals.persistImageBuffer(buf, { taskId: id, contentType: req.body.content_type });
233
+ if (!stored.ok) return res.fail(`visual_${stored.reason}`, 400);
234
+ const row = await db.setTaskVisualSlot({ taskId: id, slot, visualUrl: stored.url, visualAlt: visuals.normalizeAlt(req.body.alt) });
235
+ if (row && row.replaced_url && row.replaced_url !== stored.url) await visuals.deleteStored(row.replaced_url);
236
+ return res.status(201).json({ ok: true, slot, visual_url: row.visual_url, visual_alt: row.visual_alt, bytes: stored.bytes });
237
+ } catch (err) {
238
+ log.error({ taskId: id, slot, err: err && err.message }, 'task-visual slot upload failed');
239
+ return res.fail('visual_store_failed', { status: 500, message: 'internal error' });
240
+ }
241
+ });
242
+
243
+ // DELETE /tasks/:id/visuals/:slot — remove one named visual. Idempotent.
244
+ router.delete('/tasks/:id/visuals/:slot', auth.requireBuilder, async (req, res) => {
245
+ const id = parseId(req, res, { code: 'bad_task_id' });
246
+ if (id === null) return;
247
+ const slot = req.params.slot;
248
+ if (!visuals.isSafeSlot(slot)) return res.fail('bad_slot', 400);
249
+ if (!(await gateTaskOwnership(req, res, id))) return;
250
+ try {
251
+ const url = await db.deleteTaskVisualSlot({ taskId: id, slot });
252
+ if (url) await visuals.deleteStored(url);
253
+ return res.json({ ok: true, slot, visual_url: null });
254
+ } catch (err) {
255
+ log.error({ taskId: id, slot, err: err && err.message }, 'task-visual slot delete failed');
256
+ return res.fail('visual_delete_failed', { status: 500, message: 'internal error' });
257
+ }
258
+ });
259
+
260
+ // GET /tasks/:id/visuals — the task's named visuals. Any signed-in builder: it
261
+ // lists URLs only, and each image is still served under its own task's audience.
262
+ router.get('/tasks/:id/visuals', auth.requireBuilder, async (req, res) => {
263
+ const id = parseId(req, res, { code: 'bad_task_id' });
264
+ if (id === null) return;
265
+ try {
266
+ const task = await db.getTaskVisualAccess(id);
267
+ if (!task) return res.fail('task_not_found', 404);
268
+ const rows = await db.listTaskVisualSlots(id);
269
+ return res.json({ ok: true, task_id: String(id), slots: rows.map((x) => ({ slot: x.slot, visual_url: x.visual_url, visual_alt: x.visual_alt, updated_at: x.updated_at })) });
270
+ } catch (err) {
271
+ log.error({ taskId: id, err: err && err.message }, 'task-visual slot list failed');
272
+ return res.fail('visual_list_failed', { status: 500, message: 'internal error' });
273
+ }
274
+ });
275
+
194
276
  // DELETE /tasks/:id/visual — remove it again. The counterpart to "always
195
277
  // optional": a builder who attached the wrong screenshot must be able to take
196
278
  // it back, and an image is not prose (the write-once rule of task 1003102
@@ -43,4 +43,43 @@ async function getTaskVisualAccess(taskId) {
43
43
  return rows[0] ?? null;
44
44
  }
45
45
 
46
- module.exports = { setTaskVisual, getTaskVisualAccess };
46
+ // NAMED SLOTS (task 1004322, lifecycle_013). Status-blind, like the two above:
47
+ // the routes decide who may write and who may see.
48
+ //
49
+ // Upsert one slot. Returns the stored row and the URL it REPLACED (null when the
50
+ // slot was empty), read in the same statement so the route can unlink exactly the
51
+ // superseded file.
52
+ async function setTaskVisualSlot({ taskId, slot, visualUrl, visualAlt = null }) {
53
+ const { rows } = await pool.query(
54
+ `WITH prev AS (
55
+ SELECT visual_url FROM lifecycle_task_visual_slots WHERE task_id = $1 AND slot = $2
56
+ )
57
+ INSERT INTO lifecycle_task_visual_slots (task_id, slot, visual_url, visual_alt, updated_at)
58
+ VALUES ($1, $2, $3, $4, now())
59
+ ON CONFLICT (task_id, slot) DO UPDATE
60
+ SET visual_url = EXCLUDED.visual_url, visual_alt = EXCLUDED.visual_alt, updated_at = now()
61
+ RETURNING slot, visual_url, visual_alt, (SELECT visual_url FROM prev) AS replaced_url`,
62
+ [taskId, slot, visualUrl, visualAlt]
63
+ );
64
+ return rows[0] ?? null;
65
+ }
66
+
67
+ // Remove one slot. Returns the URL it held, or null when it was already empty.
68
+ async function deleteTaskVisualSlot({ taskId, slot }) {
69
+ const { rows } = await pool.query(
70
+ `DELETE FROM lifecycle_task_visual_slots WHERE task_id = $1 AND slot = $2 RETURNING visual_url`,
71
+ [taskId, slot]
72
+ );
73
+ return rows[0] ? rows[0].visual_url : null;
74
+ }
75
+
76
+ // Every slot a task carries, by name.
77
+ async function listTaskVisualSlots(taskId) {
78
+ const { rows } = await pool.query(
79
+ `SELECT slot, visual_url, visual_alt, updated_at FROM lifecycle_task_visual_slots WHERE task_id = $1 ORDER BY slot`,
80
+ [taskId]
81
+ );
82
+ return rows;
83
+ }
84
+
85
+ module.exports = { setTaskVisual, getTaskVisualAccess, setTaskVisualSlot, deleteTaskVisualSlot, listTaskVisualSlots };
@@ -55,6 +55,24 @@ const TYPE_BY_EXT = {
55
55
  };
56
56
 
57
57
  const MAX_BYTES = 4 * 1024 * 1024; // 4 MB — a screenshot, not an asset library.
58
+
59
+ // NAMED SLOTS (task 1004322 / BV2.TW11, lifecycle_013). A task may carry named
60
+ // visuals beside its one ship-time picture. The shape is the column's own CHECK,
61
+ // restated so a bad name is refused before any byte is stored.
62
+ const SLOT_RE = /^[a-z0-9]+(-[a-z0-9]+){0,5}$/;
63
+ const MAX_SLOT_LENGTH = 48;
64
+ function isSafeSlot(slot) {
65
+ return typeof slot === 'string' && slot.length <= MAX_SLOT_LENGTH && SLOT_RE.test(slot);
66
+ }
67
+ // The eight renders /tweak attaches to a page tweak (ADR 0341 D7, builder pick 9):
68
+ // the applied page before and after, desktop (1440) and phone (390), light and
69
+ // dark. Declared ONCE: the renderer names its files with it, the attach step
70
+ // uploads by it, and the Approval queue (TW12) reads the same names.
71
+ const TWEAK_RENDER_PHASES = Object.freeze(['before', 'after']);
72
+ const TWEAK_RENDER_DEVICES = Object.freeze({ desktop: 1440, phone: 390 });
73
+ const TWEAK_RENDER_MODES = Object.freeze(['light', 'dark']);
74
+ const TWEAK_RENDER_SLOTS = Object.freeze(TWEAK_RENDER_PHASES.flatMap((phase) =>
75
+ Object.keys(TWEAK_RENDER_DEVICES).flatMap((device) => TWEAK_RENDER_MODES.map((mode) => `${phase}-${device}-${mode}`))));
58
76
  const MAX_ALT_LENGTH = 300; // the caption/alt text stored beside it.
59
77
 
60
78
  // Stored filenames are SERVER-generated, never user-supplied: a fixed shape we
@@ -249,6 +267,12 @@ async function deleteStored(nameOrUrl, { dir = visualsDir() } = {}) {
249
267
  }
250
268
 
251
269
  module.exports = {
270
+ isSafeSlot,
271
+ MAX_SLOT_LENGTH,
272
+ TWEAK_RENDER_PHASES,
273
+ TWEAK_RENDER_DEVICES,
274
+ TWEAK_RENDER_MODES,
275
+ TWEAK_RENDER_SLOTS,
252
276
  EXT_BY_TYPE,
253
277
  TYPE_BY_EXT,
254
278
  MAX_BYTES,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1062",
3
+ "version": "1.19.1064",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1062",
9
+ "version": "1.19.1064",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1062",
3
+ "version": "1.19.1064",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -7673,5 +7673,17 @@
7673
7673
  "id": "1004320",
7674
7674
  "text": "An artist can now download any page's words as a Word document, work on it offline, and upload it back. The upload goes into their draft, never straight to the site, and only the lines they actually changed in Word are taken,"
7675
7675
  }
7676
+ ],
7677
+ "1.19.1063": [
7678
+ {
7679
+ "id": "1004321",
7680
+ "text": "Artists now have their own editor for a page. It shows every word of the page as one sheet of paper over the studio at night. You type over any line and it saves as you go. A changed line gets a small orange mark and shows wha"
7681
+ }
7682
+ ],
7683
+ "1.19.1064": [
7684
+ {
7685
+ "id": "1004322",
7686
+ "text": "A session can now apply an artist's rewritten page. The new /tweak skill takes the next submitted page, applies every line in one pass, and photographs the page before and after, on a desktop and a phone, in light and dark, at"
7687
+ }
7676
7688
  ]
7677
7689
  }