@bongos/core 1.21.107 → 1.21.108

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.
@@ -13,6 +13,14 @@
13
13
  // Owner, 2026-10-04: the edits are REAL wording changes (the page-tweak path),
14
14
  // kept apart from the palette's feedback docs.
15
15
  //
16
+ // Owner, 2026-10-09 (task 1004696): an artist can also recommend REMOVING a
17
+ // line ("Remove this line" on the line they are in, or by emptying it). A
18
+ // removal is saved as { key, remove: true } and, being a code change, goes to
19
+ // an engineer when the page is sent. And the artist review (the palette's
20
+ // sheet, page-notes.js) is one press away while editing and again once the
21
+ // words are sent, so the words and the look are reviewed in one sitting. This
22
+ // file only opens that sheet; it still never writes a feedback doc itself.
23
+ //
16
24
  // Taking the page is the copy desk's claim: artists only, one at a time, and
17
25
  // the page shows as being tweaked while it is held. The walls are the server's
18
26
  // (modules/copy-desk); this file only draws. A word the page fills in for
@@ -23,6 +31,8 @@
23
31
  var API = '/api/bongos/copy-desk/pages/';
24
32
  var SAVE_MS = 700;
25
33
  var HOLE = '{…}';
34
+ // The artist review's own words for itself (page-notes.js), on the button that opens it.
35
+ var NOTES_TITLE = 'Write what this page should look like; each point becomes an idea';
26
36
  // Never made editable: form controls, code, drawings, and the hall's own chrome.
27
37
  var SKIP = 'script, style, svg, textarea, input, select, option, button, code, pre, [contenteditable="false"], #paintbrush';
28
38
  var SCRIPT_V = (function () {
@@ -40,6 +50,7 @@
40
50
  }
41
51
  function tidy(s) { return String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); }
42
52
  function plural(n, one, many) { return n + ' ' + (n === 1 ? one : many); }
53
+ function clip(s, n) { s = tidy(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; }
43
54
  // The page's id in the readings (docs/page-readings.json): builders:<data-page>.
44
55
  function pageId() {
45
56
  var b = document.body || { dataset: {} };
@@ -109,16 +120,32 @@
109
120
  '<button class="pb__btn pb__btn--go" type="button" data-pb-take data-pb-focus>Take this page</button>' +
110
121
  '</div>';
111
122
  }
123
+ // The line the artist is in, and what can be done with it: remove it, or keep
124
+ // a removed one after all. Nothing until a line has been clicked or focused.
125
+ function currentHtml() {
126
+ var line = st.current ? lineOf(st.current) : null;
127
+ if (!line) return '';
128
+ var gone = removed(line);
129
+ return '<div class="pb__current">' +
130
+ '<span class="pb__current-words">' + (gone ? '<span class="pb__tag">Remove</span> ' : '') + esc(clip(line.text, 70)) + '</span>' +
131
+ (gone
132
+ ? '<button class="pb__btn pb__btn--quiet" type="button" data-pb-keep>Keep this line</button>'
133
+ : '<button class="pb__btn pb__btn--quiet" type="button" data-pb-remove>Remove this line</button>') +
134
+ '</div>';
135
+ }
112
136
  function editingHtml() {
113
137
  var changed = changes().length;
114
138
  var save = st.save === 'saving' ? 'saving…' : st.save === 'failed' ? 'not saved' : 'saved';
115
139
  var rest = st.unreached;
116
140
  return '<p class="pb__line" aria-live="polite"><strong>' + esc(plural(changed, 'change', 'changes')) + '</strong> · ' + esc(save) +
117
141
  (st.error ? ' · <span class="pb__error-inline">' + esc(st.error) + '</span>' : '') + '</p>' +
118
- '<p class="pb__hint">Click any underlined words and type over them.' +
142
+ '<p class="pb__hint">Click any underlined words and type over them, or remove a line the page does not need.' +
119
143
  (rest ? ' ' + esc(plural(rest, 'other line', 'other lines')) + ' of this page ' + (rest === 1 ? 'is' : 'are') + ' in the <a href="' + esc(editorHref(st.id)) + '">tweak editor</a>.' : '') + '</p>' +
144
+ currentHtml() +
145
+ '<p class="pb__error" role="alert">' + esc(st.notesError || '') + '</p>' +
120
146
  '<div class="pb__actions">' +
121
147
  '<button class="pb__btn" type="button" data-pb-stop>Stop editing</button>' +
148
+ '<button class="pb__btn" type="button" data-pb-notes title="' + esc(NOTES_TITLE) + '">Artist review</button>' +
122
149
  '<button class="pb__btn pb__btn--go" type="button" data-pb-review' + (changed ? '' : ' disabled') + '>Review ' + esc(plural(changed, 'change', 'changes')) + '</button>' +
123
150
  '</div>';
124
151
  }
@@ -126,13 +153,20 @@
126
153
  var rows = changes();
127
154
  return '<p class="pb__line"><strong>Your changes to this page</strong></p>' +
128
155
  '<ol class="pb__changes">' + rows.map(function (c) {
156
+ if (c.remove) {
157
+ return '<li class="pb__change pb__change--remove">' +
158
+ '<span class="pb__where">' + esc(c.section || 'this page') + '</span>' +
159
+ '<span class="pb__was"><span class="pb__tag">Remove</span> ' + esc(c.was) + '</span>' +
160
+ '</li>';
161
+ }
129
162
  return '<li class="pb__change">' +
130
163
  '<span class="pb__where">' + esc(c.section || 'this page') + '</span>' +
131
164
  '<span class="pb__was"><span class="pb__tag">Was</span> ' + esc(c.was) + '</span>' +
132
165
  '<span class="pb__now"><span class="pb__tag">Now</span> ' + esc(c.now) + '</span>' +
133
166
  '</li>';
134
167
  }).join('') + '</ol>' +
135
- '<p class="pb__hint">Sending submits the page: an engineer applies the new words, and they come back to you to approve before anyone sees them.</p>' +
168
+ '<p class="pb__hint">Sending submits the page: an engineer applies the new words, and they come back to you to approve before anyone sees them.' +
169
+ (rows.some(function (c) { return c.remove; }) ? ' A line you remove goes to an engineer to take out.' : '') + '</p>' +
136
170
  '<p class="pb__error" role="alert">' + esc(err || '') + '</p>' +
137
171
  '<div class="pb__actions">' +
138
172
  '<button class="pb__btn" type="button" data-pb-back>Keep editing</button>' +
@@ -140,13 +174,29 @@
140
174
  '</div>';
141
175
  }
142
176
 
177
+ // After sending: the words are on their way, and the look is one press away.
178
+ function sentHtml(n) {
179
+ return '<p class="pb__line" role="status">Sent for approval: ' + esc(plural(n, 'change', 'changes')) + '. An engineer applies the new words, and they come back to you in the studio to approve.</p>' +
180
+ '<p class="pb__line"><strong>Review how this page looks too?</strong> Write what you would change about it, and each point becomes an idea under your name.</p>' +
181
+ '<p class="pb__error" role="alert">' + esc(st.notesError || '') + '</p>' +
182
+ '<div class="pb__actions">' +
183
+ '<button class="pb__btn" type="button" data-pb-close>Not now</button>' +
184
+ '<button class="pb__btn pb__btn--go" type="button" data-pb-notes data-pb-focus>Start an artist review</button>' +
185
+ '</div>';
186
+ }
187
+
143
188
  // ---------- the words ----------
144
189
  // Every line the artist has changed, in the page's reading order.
190
+ // A removal is a line whose words were emptied (the lib's rule, task 1004696).
145
191
  function changes() {
146
192
  if (!st || !st.lines) return [];
147
- return st.lines.filter(function (l) { return st.lib.editor.isChanged(l, st.values[l.key]) && String(st.values[l.key]).trim(); })
148
- .map(function (l) { return { key: l.key, section: l.section, was: l.text, now: String(st.values[l.key]).replace(/\s+/g, ' ').trim() }; });
193
+ return st.lines.filter(function (l) { return st.lib.editor.isChanged(l, st.values[l.key]); })
194
+ .map(function (l) {
195
+ if (removed(l)) return { key: l.key, section: l.section, was: l.text, remove: true };
196
+ return { key: l.key, section: l.section, was: l.text, now: String(st.values[l.key]).replace(/\s+/g, ' ').trim() };
197
+ });
149
198
  }
199
+ function removed(line) { return !!line && st.lib.editor.isRemoved(line, st.values[line.key]); }
150
200
  // The elements on the page whose words are one reading line: a leaf holding
151
201
  // text, inside the page's main content, not a control and not shared chrome.
152
202
  function candidates() {
@@ -178,14 +228,12 @@
178
228
  var line = byText[tidy(el.textContent)] || null;
179
229
  if (!line || used[line.key]) return;
180
230
  used[line.key] = true;
181
- st.els.push({ el: el, key: line.key, original: el.textContent });
182
- el.textContent = st.values[line.key];
183
- el.setAttribute('contenteditable', 'plaintext-only');
184
- if (el.contentEditable !== 'plaintext-only') el.setAttribute('contenteditable', 'true');
231
+ var en = { el: el, key: line.key, original: el.textContent };
232
+ st.els.push(en);
185
233
  el.setAttribute('spellcheck', 'false');
186
234
  el.setAttribute('data-pb-key', line.key);
187
235
  el.classList.add('pb-word');
188
- el.classList.toggle('pb-word--changed', st.lib.editor.isChanged(line, st.values[line.key]));
236
+ paintLine(en);
189
237
  });
190
238
  // A hint, not a ledger: a line the page shows twice is edited once (the
191
239
  // first), and its second showing is counted in neither number.
@@ -198,14 +246,44 @@
198
246
  document.addEventListener('keydown', onPageKey, true);
199
247
  document.addEventListener('paste', onPaste, true);
200
248
  document.addEventListener('focusout', onBlur, true);
249
+ document.addEventListener('focusin', onFocus, true);
201
250
  window.addEventListener('pagehide', flush);
202
251
  if (!st.els.length) {
203
252
  note('None of this page\'s words can be edited where they stand. <a href="' + esc(editorHref(st.id)) + '">Rewrite them in the tweak editor</a>.');
204
253
  return;
205
254
  }
255
+ // The first line is the one the bar offers to remove until another is
256
+ // chosen; set here, since a window without focus fires no focusin.
257
+ st.current = st.els[0].key;
206
258
  paint(editingHtml());
207
259
  st.els[0].el.focus();
208
260
  }
261
+ // One line as its state says: its words, editable; or, marked to be removed,
262
+ // the page's own words struck through, focusable but not typed into, until
263
+ // "Keep this line" puts it back. Never called mid-typing: the caret would jump.
264
+ function paintLine(en) {
265
+ var line = lineOf(en.key);
266
+ var el = en.el;
267
+ var gone = removed(line);
268
+ if (gone) {
269
+ el.textContent = en.original;
270
+ el.removeAttribute('contenteditable');
271
+ el.setAttribute('tabindex', '0');
272
+ el.setAttribute('aria-label', 'Marked to be removed: ' + tidy(line.text));
273
+ } else {
274
+ el.textContent = st.values[en.key];
275
+ el.setAttribute('contenteditable', 'plaintext-only');
276
+ if (el.contentEditable !== 'plaintext-only') el.setAttribute('contenteditable', 'true');
277
+ el.removeAttribute('tabindex');
278
+ el.removeAttribute('aria-label');
279
+ }
280
+ el.classList.toggle('pb-word--removed', gone);
281
+ el.classList.toggle('pb-word--changed', !gone && !!line && st.lib.editor.isChanged(line, st.values[en.key]));
282
+ }
283
+ function entryByKey(key) {
284
+ for (var i = 0; i < st.els.length; i++) if (st.els[i].key === key) return st.els[i];
285
+ return null;
286
+ }
209
287
  function entryOf(el) {
210
288
  var host = el && el.closest ? el.closest('[data-pb-key]') : null;
211
289
  if (!host) return null;
@@ -216,7 +294,10 @@
216
294
  var en = entryOf(e.target);
217
295
  if (!en) return;
218
296
  st.values[en.key] = en.el.textContent;
297
+ st.current = en.key;
219
298
  var line = lineOf(en.key);
299
+ // Emptied, it stays editable while the artist is still in it: they may be
300
+ // retyping. Leaving it empty marks it to be removed (onBlur).
220
301
  en.el.classList.toggle('pb-word--changed', !!line && st.lib.editor.isChanged(line, st.values[en.key]));
221
302
  if (!st.reviewing) paint(editingHtml());
222
303
  clearTimeout(st.timer);
@@ -243,7 +324,41 @@
243
324
  var text = ((e.clipboardData && e.clipboardData.getData('text/plain')) || '').replace(/\s+/g, ' ');
244
325
  document.execCommand('insertText', false, text);
245
326
  }
246
- function onBlur(e) { if (entryOf(e.target)) save(); }
327
+ function onBlur(e) {
328
+ var en = entryOf(e.target);
329
+ if (!en) return;
330
+ if (removed(lineOf(en.key)) && en.el.hasAttribute('contenteditable')) {
331
+ paintLine(en);
332
+ if (!st.reviewing) paint(editingHtml());
333
+ }
334
+ save();
335
+ }
336
+ // The line the artist is in is the one "Remove this line" acts on.
337
+ function onFocus(e) {
338
+ var en = entryOf(e.target);
339
+ if (!en || st.current === en.key) return;
340
+ st.current = en.key;
341
+ if (!st.reviewing) paint(editingHtml());
342
+ }
343
+ function removeLine() {
344
+ var en = st.current ? entryByKey(st.current) : null;
345
+ if (!en) return;
346
+ st.values[en.key] = '';
347
+ paintLine(en);
348
+ st.focusBar = true;
349
+ paint(editingHtml());
350
+ save();
351
+ }
352
+ function keepLine() {
353
+ var en = st.current ? entryByKey(st.current) : null;
354
+ var line = en && lineOf(en.key);
355
+ if (!line) return;
356
+ st.values[en.key] = line.text;
357
+ paintLine(en);
358
+ paint(editingHtml());
359
+ en.el.focus();
360
+ save();
361
+ }
247
362
 
248
363
  // The WHOLE draft each time (every changed line, the ones typed here and any
249
364
  // the editor saved), one save in flight at a time.
@@ -277,7 +392,9 @@
277
392
  en.el.removeAttribute('contenteditable');
278
393
  en.el.removeAttribute('spellcheck');
279
394
  en.el.removeAttribute('data-pb-key');
280
- en.el.classList.remove('pb-word', 'pb-word--changed');
395
+ en.el.removeAttribute('tabindex');
396
+ en.el.removeAttribute('aria-label');
397
+ en.el.classList.remove('pb-word', 'pb-word--changed', 'pb-word--removed');
281
398
  });
282
399
  st.els = [];
283
400
  st.editing = false;
@@ -287,6 +404,7 @@
287
404
  document.removeEventListener('keydown', onPageKey, true);
288
405
  document.removeEventListener('paste', onPaste, true);
289
406
  document.removeEventListener('focusout', onBlur, true);
407
+ document.removeEventListener('focusin', onFocus, true);
290
408
  window.removeEventListener('pagehide', flush);
291
409
  }
292
410
 
@@ -352,7 +470,26 @@
352
470
  restore();
353
471
  st.reviewing = false;
354
472
  st.focusBar = true;
355
- note('Sent for approval: ' + esc(plural(n, 'change', 'changes')) + '. An engineer applies the new words, and they come back to you in the studio to approve.');
473
+ st.sent = n;
474
+ paint(sentHtml(n));
475
+ });
476
+ }
477
+ // The artist review (the palette's sheet) for this same page, opened from the
478
+ // editing bar (the words are saved first, and editing carries on under it)
479
+ // or from the sent view (whose work is then done, so the bar closes).
480
+ function openNotes() {
481
+ var btn = bar && bar.querySelector('[data-pb-notes]');
482
+ if (btn) btn.disabled = true;
483
+ st.notesError = '';
484
+ var ready = st.editing ? save() : Promise.resolve(true);
485
+ ready.then(function () { return loadScript('page-notes.js', 'OTBPageNotes'); }).then(function (notes) {
486
+ if (st && !st.editing) close();
487
+ else if (btn) btn.disabled = false;
488
+ notes.open();
489
+ }, function () {
490
+ if (!st || !bar) return;
491
+ st.notesError = 'The artist review could not open. Press Review in the top bar instead.';
492
+ paint(st.editing ? editingHtml() : sentHtml(st.sent));
356
493
  });
357
494
  }
358
495
  function stop() {
@@ -374,6 +511,9 @@
374
511
  else if (btn.hasAttribute('data-pb-back')) back();
375
512
  else if (btn.hasAttribute('data-pb-send')) send();
376
513
  else if (btn.hasAttribute('data-pb-stop')) stop();
514
+ else if (btn.hasAttribute('data-pb-remove')) removeLine();
515
+ else if (btn.hasAttribute('data-pb-keep')) keepLine();
516
+ else if (btn.hasAttribute('data-pb-notes')) openNotes();
377
517
  }
378
518
  function close() {
379
519
  if (st && st.editing) { stop(); return; }
@@ -844,7 +844,9 @@
844
844
  const line = (el === greetEl && lib.matchLine(servedGreeting, wedit.lines)) || lib.matchLine(el.textContent, wedit.lines);
845
845
  if (!line) { el.setAttribute('data-unread', ''); continue; }
846
846
  wedit.keys.set(el, line.key);
847
- el.textContent = wedit.values[line.key];
847
+ // A line marked to be removed (an empty value, task 1004696) keeps its
848
+ // words on screen; the removal stays in the draft until it is typed over.
849
+ el.textContent = wedit.values[line.key] || line.text;
848
850
  el.setAttribute('contenteditable', 'true');
849
851
  el.setAttribute('spellcheck', 'false');
850
852
  el.setAttribute('aria-label', `The studio's words: ${line.text}`);
@@ -1670,6 +1670,22 @@ html.pb-editing main { padding-bottom: 180px; }
1670
1670
  .pb-word:hover { background: color-mix(in srgb, var(--craft, var(--accent)) 8%, transparent); }
1671
1671
  .pb-word:focus, .pb-word:focus-visible { background: color-mix(in srgb, var(--craft, var(--accent)) 12%, transparent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--craft, var(--accent)) 55%, transparent); }
1672
1672
  .pb-word--changed { background: color-mix(in srgb, var(--craft, var(--accent)) 14%, transparent); text-decoration-style: solid; }
1673
+ /* A line marked to be removed (task 1004696): the page's words, struck through
1674
+ and faded, still clickable so "Keep this line" can put it back. */
1675
+ .pb-word--removed {
1676
+ cursor: pointer;
1677
+ opacity: .55;
1678
+ text-decoration: line-through solid var(--ink-soft);
1679
+ text-decoration-thickness: 1px;
1680
+ }
1681
+ /* The line the artist is in, with its one action: remove it, or keep it. */
1682
+ .pb__current {
1683
+ display: flex; align-items: center; justify-content: space-between; gap: 10px;
1684
+ padding: 8px 8px 8px 12px; border-radius: var(--radius-sm); background: var(--glass);
1685
+ }
1686
+ .pb__current-words { min-width: 0; font-size: 13.5px; color: var(--ink-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1687
+ .pb__btn--quiet { flex-shrink: 0; min-height: 32px; padding: 0 14px; font-size: 13px; }
1688
+ .pb__change--remove .pb__was { color: var(--ink); }
1673
1689
  @media (max-width: 640px) {
1674
1690
  .pb { bottom: calc(10px + env(safe-area-inset-bottom, 0px)); width: calc(100% - 20px); border-radius: 16px; }
1675
1691
  .pb__inner { padding: 12px 14px 14px; max-height: 64vh; }
@@ -9,10 +9,12 @@
9
9
  // sectionsOf(lines) consecutive runs of one section, as the page shows
10
10
  // them (a section that recurs gets its heading again —
11
11
  // the Word download's own rule, docx.js)
12
- // noteFor(line, value) the margin note: shared and engineer lines carry
13
- // theirs, a changed line carries "was: <old words>"
14
- // draftBody(...) the WHOLE draft the autosave PUTs: every changed,
15
- // non-empty line as { key, after }
12
+ // noteFor(line, value) the margin note: an emptied line is marked to be
13
+ // removed, shared and engineer lines carry theirs, a
14
+ // changed line carries "was: <old words>"
15
+ // draftBody(...) the WHOLE draft the autosave PUTs: every changed
16
+ // line as { key, after }, an emptied one as
17
+ // { key, remove: true }
16
18
  // counterText(...) "2 of 75 rewritten · saved"
17
19
  // liveUrl(url, loc) the page's live address, {apex} resolved from the
18
20
  // hall's own host (shell.js APEX_ORIGIN's rule)
@@ -42,27 +44,36 @@
42
44
  }
43
45
 
44
46
  // The mock's rule: a shared or engineer line always shows its own note, and
45
- // any other changed line shows the words it replaced.
47
+ // any other changed line shows the words it replaced. An emptied line says it
48
+ // will be removed first, since that is what sending it now does.
46
49
  function noteFor(line, value, notes) {
47
50
  const n = notes || NOTES;
51
+ if (isRemoved(line, value)) return { kind: 'removed', text: `to be removed · goes to an engineer · was: ${line.text}` };
48
52
  if (line.note === 'shared') return { kind: 'shared', text: n.shared || NOTES.shared };
49
53
  if (line.note === 'engineer') return { kind: 'engineer', text: n.engineer || NOTES.engineer };
50
54
  if (isChanged(line, value)) return { kind: 'was', text: `was: ${line.text}` };
51
55
  return null;
52
56
  }
53
57
 
54
- // An empty box is not a rewrite: removing words from a page is a code change
55
- // (the server's line_empty), so an emptied line is left out of the save and
56
- // the page keeps its words until the artist types new ones.
58
+ // An empty box is not a rewrite to nothing: it is the artist recommending the
59
+ // page stop showing the line (task 1004696). Removing words is a code change,
60
+ // so it is saved as { key, remove: true } (an empty `after` alone is the
61
+ // server's line_empty) and the submit hands it to an engineer. A draft line
62
+ // the server holds as a removal reads back as an empty value, so every editor
63
+ // carries it through its next save.
57
64
  function isEmpty(value) {
58
65
  return !String(value == null ? '' : value).trim();
59
66
  }
67
+ function isRemoved(line, value) {
68
+ return isChanged(line, value) && isEmpty(value);
69
+ }
60
70
 
61
71
  function draftBody(readingHash, lines, values) {
62
72
  const out = [];
63
73
  for (const l of lines || []) {
64
74
  const v = values[l.key];
65
- if (isChanged(l, v) && !isEmpty(v)) out.push({ key: l.key, after: v });
75
+ if (isRemoved(l, v)) out.push({ key: l.key, remove: true });
76
+ else if (isChanged(l, v)) out.push({ key: l.key, after: v });
66
77
  }
67
78
  return { reading_hash: readingHash, lines: out };
68
79
  }
@@ -76,11 +87,12 @@
76
87
  return `${changed} of ${total} rewritten · ${SAVE_WORDS[save] || SAVE_WORDS.saved}`;
77
88
  }
78
89
 
79
- // The editor's starting words: the page's own, with the saved draft laid over.
90
+ // The editor's starting words: the page's own, with the saved draft laid over
91
+ // (a removal as an empty value).
80
92
  function initialValues(lines, draft) {
81
93
  const values = {};
82
94
  for (const l of lines || []) values[l.key] = l.text;
83
- for (const d of (draft && draft.lines) || []) if (d && Object.prototype.hasOwnProperty.call(values, d.key)) values[d.key] = d.after;
95
+ for (const d of (draft && draft.lines) || []) if (d && Object.prototype.hasOwnProperty.call(values, d.key)) values[d.key] = d.remove ? '' : d.after;
84
96
  return values;
85
97
  }
86
98
 
@@ -248,7 +260,7 @@
248
260
  return null;
249
261
  }
250
262
 
251
- const api = { OPEN_WORDS, openChoice, clampSplit, splitFromPointer, splitFromKey, canFrame, FRAME_SIBLINGS, splitWindowFeatures, HINT, nextLineKey, confirmedCount, hintText, confirmStoreKey, parseConfirmed, NOTES, sectionsOf, isChanged, noteFor, draftBody, changedCount, counterText, initialValues, rowsFor, liveUrl, uploadedLabel, uploadRefusal, displayTitle, pageIdFrom };
263
+ const api = { OPEN_WORDS, openChoice, clampSplit, splitFromPointer, splitFromKey, canFrame, FRAME_SIBLINGS, splitWindowFeatures, HINT, nextLineKey, confirmedCount, hintText, confirmStoreKey, parseConfirmed, NOTES, sectionsOf, isChanged, isRemoved, noteFor, draftBody, changedCount, counterText, initialValues, rowsFor, liveUrl, uploadedLabel, uploadRefusal, displayTitle, pageIdFrom };
252
264
  if (typeof window !== 'undefined') window.OTBTweakEditor = api;
253
265
  if (typeof module !== 'undefined' && module.exports) module.exports = api;
254
266
  })();
@@ -45,6 +45,11 @@ Covers: every shelled hall page (`craft-mode.js`, `shell.js`, `style.css`), and
45
45
  - **The top bar fits two tools at 390px** because, at phone width, the side packs to a 4px gap, a tool is 28px, the side and the menu do not shrink, and a long page title trims with an ellipsis instead. All 34 hall pages measure 0px of sideways scroll in Artistry at 390px.
46
46
  - `tests/paintbrush.mjs` runs the real script and both shared libs over a small page.
47
47
 
48
+ ### Removing a line, and the artist review alongside (task 1004696)
49
+
50
+ - **An artist can recommend removing a line** (owner, 2026-10-09: pages carry superfluous subtitles and text). The line the artist is in shows in the bar with **Remove this line**; emptying a line's words and leaving it does the same. A removed line shows the page's own words struck through and faded, is no longer typed into but stays focusable, and **Keep this line** puts it back. Review lists it as its own "Remove" row and says it goes to an engineer. It is saved as `{ key, remove: true }` (the copy desk's rule, `modules/copy-desk/CLAUDE.md`), so the send files an engineer task to take it out.
51
+ - **The artist review is one press away**: **Artist review** in the editing bar saves the words and opens the palette's sheet (`page-notes.js`, loaded on demand like a tool) over the page; editing carries on when it closes. Once the words are sent, the bar asks "Review how this page looks too?" with **Start an artist review** (closes the bar, opens the sheet) and **Not now**. The paintbrush still writes no feedback doc itself: `tests/paintbrush.mjs` allows `page-notes.js` as the only page-notes name in the file.
52
+
48
53
  ## The lightbulb (task 1004498)
49
54
 
50
55
  - **Ideating carries one tool, the lightbulb** (`lightbulb.js`, a `TOOLS` entry loaded on first press). Its button wears its own note glyph, not the mode's lightbulb, so it is never read as the Ideating pill beside it (owner, 2026-10-04). It is a watered-down palette: the same side sheet and `.pn` styles, text only, no uploads.
@@ -6,7 +6,7 @@ Files: `tweak-editor.html` (the markup), `tweak-editor.css` (the artboard's inli
6
6
 
7
7
  - **Which page.** `?page=<page id>` (`landing:index`). With none, the page the caller holds (`GET /copy-desk/next`'s `resume`), and with nothing held, the "is in" panel's next up, so a bare link still leads somewhere. The studio (TW16) links here.
8
8
  - **One read opens it.** `GET /copy-desk/pages/:pageId/draft` (added here: `GET /copy-desk/pages/:pageId` carries neither the lines nor the draft) answers every reading line with its margin note, the open round and `held_by_you`, and the saved draft, only on the current reading (`draft_on_older_reading` otherwise). The note is the server's: `engineer` is `pages.goesToEngineer`, the submit's own rule, so a shared line with no registry row says "goes to an engineer", which is what the submit will do with it.
9
- - **The autosave is the whole draft, debounced (700ms) and on blur**, one save in flight at a time, skipped when the body equals the last accepted one; a last `keepalive` PUT on `pagehide`. An emptied box is not saved (the server's `line_empty`: removing words is a code change), and the counter reads "not saved" with the server's own words above the paper when a save is refused.
9
+ - **The autosave is the whole draft, debounced (700ms) and on blur**, one save in flight at a time, skipped when the body equals the last accepted one; a last `keepalive` PUT on `pagehide`. An emptied box is saved as a removal, `{ key, remove: true }` (task 1004696): its margin note says it is to be removed and goes to an engineer, and the submit files that engineer task (an empty `after` alone is still the server's `line_empty`), and the counter reads "not saved" with the server's own words above the paper when a save is refused.
10
10
  - **Holding decides writing.** A page nobody holds is read-only with one button, "take this page" (the claim); another builder's is read-only and names them; a submitted one is frozen. Upload and submit are the holder's only, as their routes are.
11
11
  - **Every write is named** (`WRITES`: the draft PUT, the .docx POST, the submit, the claim on a server-given `take.path`), and `tests/hall_tweak_editor.mjs` pins the list. The upload is a raw `fetch` because the generated client JSON-encodes every body.
12
12
  - **It opens full screen.** The artboard is the whole window, so the first visit sets the shell's switch (task 1004327) on; after that the shell remembers the artist's choice for this page. In full screen the switch button wears the room's glass, since the stage is dark in both themes.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.107",
3
+ "version": "1.21.108",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.107",
9
+ "version": "1.21.108",
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.21.107",
3
+ "version": "1.21.108",
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",
@@ -9488,5 +9488,15 @@
9488
9488
  "id": "1004677",
9489
9489
  "text": "A project now has a switch for letting a runner pick up submitted page tweaks on its own, off by default, with a monthly limit of 20 rounds. The server enforces the limit when the work is claimed, so two runners can never both"
9490
9490
  }
9491
+ ],
9492
+ "1.21.108": [
9493
+ {
9494
+ "id": "1004668",
9495
+ "text": "Adding new tasks to a goal that already has work in it no longer sets off a false alarm saying the tasks were filed in the wrong place. The planning guides also stop telling people that new tasks wait for an owner's go-ahead:"
9496
+ },
9497
+ {
9498
+ "id": "1004696",
9499
+ "text": "Artists can now recommend removing a line while editing a page's words: mark it with \"Remove this line\" or just delete its words, see it crossed out, keep it after all, and send it with their rewrites. Each removal goes to an"
9500
+ }
9491
9501
  ]
9492
9502
  }
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // Run: node scripts/gds/seed-bv2-ideator-surface-tasks.js
15
15
  const os = require('os');
16
- const { expectedCriterionCounts, verifySeedCriteriaLinks } = require('./seed-verify-criteria');
16
+ const { baselineSeedCriteria, expectedCriterionCounts, verifySeedCriteriaLinks } = require('./seed-verify-criteria');
17
17
  const sess = require(os.homedir() + '/.config/cloudbongos/gds-session.json');
18
18
  const BASE = sess.api_base.replace(/\/$/, '');
19
19
  const TOKEN = sess.token;
@@ -328,24 +328,11 @@ async function main() {
328
328
  const ids = {};
329
329
  const criterionIdListsPerTask = [];
330
330
 
331
- // BASELINE. verifySeedCriteriaLinks unions the plan's slugs with EVERY criterion
332
- // the goal carries, so a criterion this seed does not touch is compared against an
333
- // expected zero. Goal 1000095 already held six criteria with live task links before
334
- // this seed existed, so an unbaselined check would report six false mismatches and
335
- // exit(1) on a correct run. Snapshot the untouched criteria's real counts HERE,
336
- // before anything is written, and fold them into the plan — the check then still
337
- // catches any drift on the four this seed owns, which is what it is for.
338
- const OURS = new Set(Object.values(CRIT));
339
- const baseline = {};
340
- const pre = await api('GET', `/goals/${GOAL}`);
341
- if (!pre.ok || !pre.json || !Array.isArray(pre.json.criteria)) {
342
- console.error(`Could not read goal ${GOAL} to baseline its existing criteria (status ${pre.status}). Refusing to seed blind.`);
343
- process.exit(1);
344
- }
345
- for (const row of pre.json.criteria) {
346
- if (!OURS.has(row.criterion_id)) baseline[row.criterion_id] = Number(row.linked_tasks) || 0;
347
- }
348
- console.log(`Baselined ${Object.keys(baseline).length} pre-existing criteria on goal ${GOAL}.`);
331
+ // BASELINE, before anything is written. Goal 1000095 already held six criteria
332
+ // with live task links before this seed existed; verifying against the pre-seed
333
+ // snapshot plus the plan keeps those from reading as false mismatches while still
334
+ // catching any drift (task 1004668 moved this into seed-verify-criteria.js).
335
+ const baseline = await baselineSeedCriteria({ goalId: GOAL, fetchGoal: (gid) => api('GET', `/goals/${gid}`) });
349
336
 
350
337
  console.log(`Seeding ${TASKS.length} tasks into goal ${GOAL} (${VER})…\n`);
351
338
 
@@ -370,7 +357,10 @@ async function main() {
370
357
  continue;
371
358
  }
372
359
  ids[t.ref] = r.json.task.id;
373
- criterionIdListsPerTask.push(t.crit);
360
+ // Count only links THIS run created. A re-run replays an existing task
361
+ // (`idempotent: true`) whose links the baseline already holds; counting it
362
+ // again would expect them twice (task 1004668).
363
+ if (!r.json.idempotent) criterionIdListsPerTask.push(t.crit);
374
364
  console.log(` ${t.ref} → ${r.json.task.id} ${title.slice(0, 74)}`);
375
365
  }
376
366
 
@@ -391,7 +381,8 @@ async function main() {
391
381
  console.log('');
392
382
  await verifySeedCriteriaLinks({
393
383
  goalId: GOAL,
394
- expectedCounts: { ...baseline, ...expectedCriterionCounts(criterionIdListsPerTask) },
384
+ expectedCounts: expectedCriterionCounts(criterionIdListsPerTask),
385
+ baseline,
395
386
  fetchGoal: (gid) => api('GET', `/goals/${gid}`),
396
387
  });
397
388
  }