@bongos/core 1.19.1062 → 1.19.1063

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.
@@ -0,0 +1,447 @@
1
+ // modules/hall-ui/public/tweak-editor.js — the tweak editor, /builders/tweak-editor
2
+ // (task 1004321 / BV2.TW10, goal 1000095; ADR 0341 D4, D11 and D12).
3
+ //
4
+ // The artist's own flow: one paper document holding every word of a page, in
5
+ // page order, typed over in place. It autosaves, round-trips through Word, and
6
+ // submits once; then "<page> is in" names the next page (take next, pick
7
+ // another, back to the studio). No terminal anywhere.
8
+ //
9
+ // Every write is copy-desk's, and the page issues only these (WRITES below):
10
+ // PUT /copy-desk/pages/:pageId/draft the autosave, the WHOLE draft
11
+ // POST /copy-desk/pages/:pageId/draft.docx the Word file, merged into the draft
12
+ // POST /copy-desk/pages/:pageId/submit freeze and queue the page
13
+ // POST <take.path> claim a page: "take this page",
14
+ // and "take next" on GET /copy-desk/next
15
+ // Every target is the server's: the body names a line by its key and says what
16
+ // it should say now, never where it lives. The pure rules are tweak-editor-lib.js.
17
+ (() => {
18
+ 'use strict';
19
+
20
+ const API = '/api/bongos';
21
+ const lib = window.OTBTweakEditor;
22
+ const api = window.BongosClient.createClient({ baseUrl: '', credentials: 'same-origin', throwOnError: false });
23
+ const { escapeHtml, toast, errMessage } = window.OTB;
24
+ const $ = (id) => document.getElementById(id);
25
+
26
+ const WRITES = Object.freeze({
27
+ draft: (id) => `${API}/copy-desk/pages/${id}/draft`,
28
+ upload: (id) => `${API}/copy-desk/pages/${id}/draft.docx`,
29
+ submit: (id) => `${API}/copy-desk/pages/${id}/submit`,
30
+ claim: (path) => `${API}${path}`,
31
+ });
32
+ const SAVE_DEBOUNCE_MS = 700;
33
+
34
+ // THE ARTBOARD IS FULL SCREEN. The first visit opens that way; after that the
35
+ // shell remembers the artist's own choice for this page.
36
+ try {
37
+ if (window.localStorage.getItem('hall-fullscreen:tweak-editor') === null && window.OTBShell) window.OTBShell.setFullscreen(true);
38
+ } catch (_) { /* private mode: the chrome shows, the honest default */ }
39
+
40
+ const st = {
41
+ pageId: null,
42
+ ed: null, // the editor read
43
+ values: {}, // key -> the words in the box
44
+ writable: false, // the caller holds the round
45
+ save: 'saved', // saved | saving | failed
46
+ timer: null,
47
+ inflight: null, // the save promise in flight
48
+ again: false, // another save was asked for while one ran
49
+ lastSent: null, // the JSON of the last body the server accepted
50
+ submitted: false,
51
+ next: null, // GET /copy-desk/next
52
+ pick: null, // the page "take next" takes
53
+ };
54
+
55
+ // ---------------------------------------------------------------------------
56
+ // paint
57
+ // ---------------------------------------------------------------------------
58
+
59
+ function lineById(key) { return st.ed.reading.lines.find((l) => l.key === key); }
60
+
61
+ function paintCounter() {
62
+ if (!st.ed) { $('tw-counter').textContent = ''; return; }
63
+ const changed = lib.changedCount(st.ed.reading.lines, st.values);
64
+ $('tw-counter').textContent = lib.counterText(changed, st.ed.reading.lines.length, st.save);
65
+ }
66
+
67
+ function noteHtml(line) {
68
+ const n = lib.noteFor(line, st.values[line.key], st.ed.notes);
69
+ return n ? { cls: `tw-note tw-note--${n.kind}`, text: n.text } : { cls: 'tw-note', text: '' };
70
+ }
71
+
72
+ function rowHtml(section, l) {
73
+ const v = st.values[l.key];
74
+ const n = noteHtml(l);
75
+ const id = `tw-${l.key}`;
76
+ return `<div class="tw-row" data-key="${escapeHtml(l.key)}">`
77
+ + `<label class="tw-sr" for="${id}">${escapeHtml(section)} line</label>`
78
+ + `<textarea id="${id}" class="tw-line${lib.isChanged(l, v) ? ' tw-line--changed' : ''}" rows="${lib.rowsFor(v)}" data-key="${escapeHtml(l.key)}"${st.writable ? '' : ' readonly'} spellcheck="true">${escapeHtml(v)}</textarea>`
79
+ + `<span class="${n.cls}" id="${id}-note">${escapeHtml(n.text)}</span>`
80
+ + '</div>';
81
+ }
82
+
83
+ function paintDoc() {
84
+ const doc = $('tw-doc');
85
+ const lines = st.ed.reading.lines;
86
+ if (!lines.length) {
87
+ doc.innerHTML = '<p class="tw-loading">This page shows no words the reader could find.</p>';
88
+ return;
89
+ }
90
+ doc.innerHTML = lib.sectionsOf(lines).map((s) => '<section class="tw-section">'
91
+ + `<h2>${escapeHtml(s.name)}</h2>`
92
+ + s.lines.map((l) => rowHtml(s.name, l)).join('')
93
+ + '</section>').join('');
94
+ doc.querySelectorAll('textarea.tw-line').forEach(fit);
95
+ }
96
+
97
+ // Each box grows to its words: the page scrolls, never a box.
98
+ function fit(ta) {
99
+ ta.style.height = 'auto';
100
+ ta.style.height = `${ta.scrollHeight}px`;
101
+ }
102
+
103
+ function paintRow(key) {
104
+ const l = lineById(key);
105
+ const ta = $(`tw-${key}`);
106
+ if (!l || !ta) return;
107
+ ta.classList.toggle('tw-line--changed', lib.isChanged(l, st.values[key]));
108
+ const n = noteHtml(l);
109
+ const note = $(`tw-${key}-note`);
110
+ note.className = n.cls;
111
+ note.textContent = n.text;
112
+ }
113
+
114
+ function paintHead() {
115
+ const p = st.ed.page;
116
+ $('tw-name').textContent = lib.displayTitle(p);
117
+ $('tw-surface').textContent = `· ${p.surface_word}`;
118
+ $('tw-title').textContent = lib.displayTitle(p);
119
+ document.title = `Tweak ${lib.displayTitle(p)} — ${document.title.split(' — ').pop()}`;
120
+ const live = lib.liveUrl(p.url, window.location);
121
+ const open = $('tw-open');
122
+ if (live) open.href = live; else open.hidden = true;
123
+ $('tw-download').disabled = false;
124
+ $('tw-upload').disabled = !st.writable;
125
+ $('tw-submit').disabled = !st.writable;
126
+ }
127
+
128
+ function notice(html) {
129
+ const n = $('tw-notice');
130
+ if (!html) { n.hidden = true; n.innerHTML = ''; return; }
131
+ n.innerHTML = html;
132
+ n.hidden = false;
133
+ }
134
+
135
+ // Why the words are read-only, and the one thing to do about it.
136
+ function roundNotice() {
137
+ const r = st.ed.round;
138
+ if (st.ed.draft_on_older_reading) {
139
+ notice('<p>The page changed after your draft was saved, so the draft is not shown over its new words. Rewrite the lines again here.</p>');
140
+ }
141
+ if (st.writable) return;
142
+ if (!r || r.state === 'draft' || (r.state === 'writing' && !r.holder)) {
143
+ notice('<p>Nobody is writing this page. Take it, and every line here is yours to type over.</p>'
144
+ + '<button class="tw-notice__act" id="tw-claim" type="button">take this page</button>');
145
+ return;
146
+ }
147
+ if (r.state === 'writing') {
148
+ notice(`<p>${escapeHtml(r.holder && r.holder.login ? r.holder.login : 'Another artist')} is writing this page now. You can read it, download it, or take another page from the studio.</p>`);
149
+ return;
150
+ }
151
+ notice('<p>This page has been submitted and is on its way to the site. Its words are frozen until it ships or is sent back.</p>');
152
+ }
153
+
154
+ function failPaper(msg) {
155
+ $('tw-doc').innerHTML = `<p class="tw-loading">${escapeHtml(msg)}</p>`;
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // load
160
+ // ---------------------------------------------------------------------------
161
+
162
+ async function load() {
163
+ st.pageId = lib.pageIdFrom(window.location.search);
164
+ if (!st.pageId) {
165
+ const next = await api.request('GET', `${API}/copy-desk/next`);
166
+ const resume = next.ok && next.data && next.data.resume;
167
+ if (resume && resume.page_id) {
168
+ st.pageId = resume.page_id;
169
+ try { window.history.replaceState(null, '', `?page=${st.pageId}`); } catch (_) { /* old engine */ }
170
+ } else {
171
+ failPaper('No page is open. Take one from the studio, or take the next one below.');
172
+ showDone({ noPage: true });
173
+ return;
174
+ }
175
+ }
176
+ const res = await api.request('GET', `${API}/copy-desk/pages/${st.pageId}/draft`);
177
+ if (!res.ok) {
178
+ failPaper(errMessage(res.data) || 'The page could not be read. Reload to try again.');
179
+ return;
180
+ }
181
+ st.ed = res.data;
182
+ st.values = lib.initialValues(st.ed.reading.lines, st.ed.draft);
183
+ st.writable = !!(st.ed.round && st.ed.round.held_by_you && st.ed.round.state === 'writing');
184
+ st.lastSent = JSON.stringify(lib.draftBody(st.ed.reading.reading_hash, st.ed.reading.lines, st.values));
185
+ paintHead();
186
+ roundNotice();
187
+ paintDoc();
188
+ paintCounter();
189
+ }
190
+
191
+ // ---------------------------------------------------------------------------
192
+ // the autosave
193
+ // ---------------------------------------------------------------------------
194
+
195
+ function schedule() {
196
+ if (!st.writable) return;
197
+ window.clearTimeout(st.timer);
198
+ st.timer = window.setTimeout(() => { flush(); }, SAVE_DEBOUNCE_MS);
199
+ }
200
+
201
+ // Save now, if anything changed since the last accepted save. One save in
202
+ // flight at a time; a save asked for meanwhile runs once after it.
203
+ async function flush() {
204
+ window.clearTimeout(st.timer);
205
+ if (!st.writable || !st.ed) return true;
206
+ if (st.inflight) { st.again = true; await st.inflight; return st.save !== 'failed'; }
207
+ const body = lib.draftBody(st.ed.reading.reading_hash, st.ed.reading.lines, st.values);
208
+ const json = JSON.stringify(body);
209
+ if (json === st.lastSent) { st.save = 'saved'; paintCounter(); return true; }
210
+ st.save = 'saving';
211
+ paintCounter();
212
+ st.inflight = (async () => {
213
+ const r = await api.request('PUT', WRITES.draft(st.pageId), { body });
214
+ if (r.ok) {
215
+ st.lastSent = json;
216
+ st.save = 'saved';
217
+ } else {
218
+ st.save = 'failed';
219
+ refusal(r.data, 'Your words are still here, but the last change was not saved.');
220
+ }
221
+ })().catch(() => { st.save = 'failed'; });
222
+ await st.inflight;
223
+ st.inflight = null;
224
+ paintCounter();
225
+ if (st.again) { st.again = false; return flush(); }
226
+ return st.save !== 'failed';
227
+ }
228
+
229
+ // A refusal in the server's words (its what_this_means), with the lines it
230
+ // names: an upload's moved lines, a submit's refused ones. Every word is
231
+ // escaped, the file's first words included.
232
+ const words = (s) => String(s || '').replace(/_/g, ' ');
233
+ function refusalItems(d) {
234
+ if (Array.isArray(d.moved)) {
235
+ return d.moved.map((m) => `${m.section || 'a line'}${m.first_words ? `: “${m.first_words}”` : ''} (${words(m.change)})`);
236
+ }
237
+ if (Array.isArray(d.refused)) {
238
+ return d.refused.map((l) => `${l.section || l.key}: “${l.before || ''}” (${words(l.code)})`);
239
+ }
240
+ return [];
241
+ }
242
+ function refusal(data, fallback) {
243
+ const d = window.OTB.errDetails(data);
244
+ const msg = (d && d.what_this_means) || errMessage(data) || fallback;
245
+ const items = refusalItems(d || {});
246
+ notice(`<p>${escapeHtml(msg)}</p>${items.length ? `<ul>${items.map((i) => `<li>${escapeHtml(i)}</li>`).join('')}</ul>` : ''}`);
247
+ }
248
+
249
+ // Leaving with an unsaved change: one last save that outlives the page.
250
+ window.addEventListener('pagehide', () => {
251
+ if (!st.writable || !st.ed) return;
252
+ const body = lib.draftBody(st.ed.reading.reading_hash, st.ed.reading.lines, st.values);
253
+ if (JSON.stringify(body) === st.lastSent) return;
254
+ try {
255
+ fetch(WRITES.draft(st.pageId), { method: 'PUT', credentials: 'same-origin', keepalive: true, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
256
+ } catch (_) { /* nothing more to try */ }
257
+ });
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // the doors
261
+ // ---------------------------------------------------------------------------
262
+
263
+ async function download() {
264
+ await flush();
265
+ const a = document.createElement('a');
266
+ a.href = `${API}/copy-desk/pages/${st.pageId}/draft.docx`;
267
+ a.download = '';
268
+ document.body.appendChild(a);
269
+ a.click();
270
+ a.remove();
271
+ $('tw-download').textContent = 'downloaded .docx';
272
+ }
273
+
274
+ async function upload(file) {
275
+ if (!file || !st.writable) return;
276
+ await flush();
277
+ const btn = $('tw-upload');
278
+ btn.disabled = true;
279
+ btn.textContent = 'uploading…';
280
+ let r = null;
281
+ try {
282
+ const res = await fetch(WRITES.upload(st.pageId), {
283
+ method: 'POST',
284
+ credentials: 'same-origin',
285
+ headers: { 'Content-Type': file.type || 'application/octet-stream' },
286
+ body: file,
287
+ });
288
+ let data = null;
289
+ try { data = await res.json(); } catch (_) { data = null; }
290
+ r = { ok: res.ok, data };
291
+ } catch (_) {
292
+ r = { ok: false, data: null };
293
+ }
294
+ btn.disabled = false;
295
+ if (!r.ok) {
296
+ btn.textContent = 'upload .docx';
297
+ refusal(r.data, 'The file could not be read. Nothing was saved.');
298
+ return;
299
+ }
300
+ notice('');
301
+ for (const l of r.data.draft.lines) st.values[l.key] = l.after;
302
+ // A line the file set back to the page's words leaves the draft.
303
+ const inDraft = new Set(r.data.draft.lines.map((l) => l.key));
304
+ for (const l of st.ed.reading.lines) if (!inDraft.has(l.key) && lib.isChanged(l, st.values[l.key])) st.values[l.key] = l.text;
305
+ st.lastSent = JSON.stringify(lib.draftBody(st.ed.reading.reading_hash, st.ed.reading.lines, st.values));
306
+ st.save = 'saved';
307
+ paintDoc();
308
+ paintCounter();
309
+ btn.textContent = lib.uploadedLabel(r.data.merge);
310
+ }
311
+
312
+ async function submit() {
313
+ if (!st.writable || st.submitted) return;
314
+ const btn = $('tw-submit');
315
+ btn.disabled = true;
316
+ const saved = await flush();
317
+ if (!saved) { btn.disabled = false; return; }
318
+ const r = await api.request('POST', WRITES.submit(st.pageId), { body: {} });
319
+ if (!r.ok) {
320
+ btn.disabled = false;
321
+ refusal(r.data, 'The page could not be submitted.');
322
+ return;
323
+ }
324
+ st.submitted = true;
325
+ st.writable = false;
326
+ notice('');
327
+ document.querySelectorAll('textarea.tw-line').forEach((ta) => { ta.readOnly = true; });
328
+ $('tw-upload').disabled = true;
329
+ showDone({});
330
+ }
331
+
332
+ async function claim(path) {
333
+ const r = await api.request('POST', WRITES.claim(path), { body: {} });
334
+ if (!r.ok) {
335
+ refusal(r.data, 'The page could not be taken.');
336
+ return false;
337
+ }
338
+ return true;
339
+ }
340
+
341
+ // ---------------------------------------------------------------------------
342
+ // "<page> is in", and next up
343
+ // ---------------------------------------------------------------------------
344
+
345
+ function paintNext() {
346
+ const p = st.pick;
347
+ $('tw-next-title').textContent = p ? lib.displayTitle(p) : 'Nothing waits right now.';
348
+ $('tw-next-why').textContent = p ? p.why_line : 'Every page is taken or on its way. The studio shows when one comes free.';
349
+ $('tw-take').disabled = !p;
350
+ const others = st.next ? [st.next.recommended, ...(st.next.alternatives || [])].filter((c) => c && (!p || c.page_id !== p.page_id)) : [];
351
+ $('tw-pick').hidden = !others.length;
352
+ $('tw-picks').innerHTML = others.map((c) => `<button class="tw-pick" type="button" data-pick="${escapeHtml(c.page_id)}">${escapeHtml(lib.displayTitle(c))}</button>`).join('');
353
+ }
354
+
355
+ async function showDone({ noPage }) {
356
+ const title = $('tw-done-title');
357
+ if (noPage) {
358
+ title.textContent = 'No page is open.';
359
+ title.nextElementSibling.textContent = 'Take the next page, or pick one from the studio.';
360
+ } else {
361
+ title.textContent = `${lib.displayTitle(st.ed.page)} is in.`;
362
+ }
363
+ $('tw-bar').inert = true;
364
+ $('tw-paper').inert = true;
365
+ $('tw-done').hidden = false;
366
+ title.focus();
367
+ const r = await api.request('GET', `${API}/copy-desk/next`);
368
+ st.next = r.ok ? r.data : null;
369
+ st.pick = st.next ? st.next.recommended : null;
370
+ if (!r.ok) $('tw-next-why').textContent = 'The next page could not be read.';
371
+ paintNext();
372
+ }
373
+
374
+ async function takeNext() {
375
+ const p = st.pick;
376
+ if (!p) return;
377
+ const btn = $('tw-take');
378
+ btn.disabled = true;
379
+ const r = await api.request('POST', WRITES.claim(p.take.path), { body: {} });
380
+ if (!r.ok) {
381
+ btn.disabled = false;
382
+ const err = $('tw-done-error');
383
+ err.textContent = errMessage(r.data) || 'That page could not be taken. Pick another.';
384
+ err.hidden = false;
385
+ return;
386
+ }
387
+ window.location.assign(`${window.location.pathname}?page=${p.page_id}`);
388
+ }
389
+
390
+ // ---------------------------------------------------------------------------
391
+ // wire + boot
392
+ // ---------------------------------------------------------------------------
393
+
394
+ document.addEventListener('input', (ev) => {
395
+ const ta = ev.target;
396
+ if (!ta || !ta.classList || !ta.classList.contains('tw-line') || !st.writable) return;
397
+ st.values[ta.dataset.key] = ta.value;
398
+ fit(ta);
399
+ paintRow(ta.dataset.key);
400
+ paintCounter();
401
+ schedule();
402
+ });
403
+ document.addEventListener('focusout', (ev) => {
404
+ if (ev.target && ev.target.classList && ev.target.classList.contains('tw-line')) flush();
405
+ });
406
+ window.addEventListener('resize', () => { document.querySelectorAll('textarea.tw-line').forEach(fit); });
407
+
408
+ document.addEventListener('click', async (ev) => {
409
+ const t = ev.target;
410
+ if (t.closest('#tw-download')) { download(); return; }
411
+ if (t.closest('#tw-upload')) { $('tw-file').click(); return; }
412
+ if (t.closest('#tw-submit')) { submit(); return; }
413
+ if (t.closest('#tw-take')) { takeNext(); return; }
414
+ if (t.closest('#tw-claim')) {
415
+ const b = t.closest('#tw-claim');
416
+ b.disabled = true;
417
+ if (await claim(st.ed.round ? st.ed.round.claim.path : `/copy-desk/pages/${st.pageId}/claim`)) { notice(''); await load(); }
418
+ else b.disabled = false;
419
+ return;
420
+ }
421
+ if (t.closest('#tw-pick')) {
422
+ const open = $('tw-picks').hidden;
423
+ $('tw-picks').hidden = !open;
424
+ $('tw-pick').setAttribute('aria-expanded', open ? 'true' : 'false');
425
+ return;
426
+ }
427
+ const pick = t.closest('[data-pick]');
428
+ if (pick && st.next) {
429
+ st.pick = [st.next.recommended, ...(st.next.alternatives || [])].find((c) => c && c.page_id === pick.dataset.pick) || st.pick;
430
+ $('tw-picks').hidden = true;
431
+ $('tw-pick').setAttribute('aria-expanded', 'false');
432
+ $('tw-done-error').hidden = true;
433
+ paintNext();
434
+ $('tw-take').focus();
435
+ }
436
+ });
437
+ $('tw-file').addEventListener('change', (ev) => {
438
+ const f = ev.target.files && ev.target.files[0];
439
+ ev.target.value = '';
440
+ upload(f);
441
+ });
442
+
443
+ load().catch(() => {
444
+ failPaper('The page could not be read. Reload to try again.');
445
+ toast('The editor could not load.');
446
+ });
447
+ })();
@@ -0,0 +1,99 @@
1
+ {
2
+ "_": "The tweak editor (task 1004321 / BV2.TW10), built 1:1 on docs/design/mocks/tweak-mode/Editor.dc.html. Against the kit's own stub the page renders its could-not-read state (no /copy-desk feed), a state the floors must hold in. TO SEE IT POPULATED, point the kit at the hall-preview harness: `node scripts/hall-preview/server.js --port 4631 --fixture-me`, then `render.js --page '/builders/tweak-editor?page=landing:index' --base-in http://builders.localhost:4631 --base-out http://builders.localhost:4631`. The harness answers the read from fixtures/copy-desk__pages__draft.json (the real landing:index reading with the artboard's two rewrites as the saved draft), next up from copy-desk__next.json, and the autosave, the submit and the claim from its canned writes.",
3
+ "page": "/builders/tweak-editor?page=landing:index",
4
+ "surface": "hall-ui",
5
+ "stub": {
6
+ "prefix": "/builders/"
7
+ },
8
+ "modeQuery": false,
9
+ "ignoreRequests": [
10
+ "/api/(gds|bongos)/me$",
11
+ "/api/(gds|bongos)/me/",
12
+ "/api/(gds|bongos)/copy-desk/",
13
+ "/api/(gds|bongos)/core-update"
14
+ ],
15
+ "ignoreConsole": [
16
+ "/api/(gds|bongos)/",
17
+ "\\b401\\b",
18
+ "\\b403\\b",
19
+ "\\b404\\b",
20
+ "\\b501\\b",
21
+ "Unauthorized",
22
+ "Forbidden",
23
+ "Not Found",
24
+ "load failed"
25
+ ],
26
+ "states": {
27
+ "doc": {
28
+ "auth": true,
29
+ "_": "The artboard: full screen, the glass bar, the paper, two rewritten lines carrying the accent mark and was:, the shared and engineer margin notes.",
30
+ "actions": [
31
+ ["wait", 900]
32
+ ],
33
+ "expect": {
34
+ "visible": ["#tw-bar", "#tw-paper", "#tw-L0014", "#tw-submit"],
35
+ "hidden": ["#app-sidebar", "#tw-done"]
36
+ }
37
+ },
38
+ "typed": {
39
+ "auth": true,
40
+ "_": "A third line typed over: the counter moves and the autosave answers saved.",
41
+ "actions": [
42
+ ["wait", 900],
43
+ ["fill", "#tw-L0024", "See every project"],
44
+ ["wait", 1400]
45
+ ],
46
+ "expect": {
47
+ "visible": ["#tw-L0024"]
48
+ }
49
+ },
50
+ "chrome": {
51
+ "auth": true,
52
+ "_": "The shell's switch pressed: the editor in the hall chrome.",
53
+ "actions": [
54
+ ["wait", 900],
55
+ ["click", "#fullscreen-toggle"],
56
+ ["wait", 300]
57
+ ],
58
+ "expect": {
59
+ "visible": ["#app-sidebar", "#tw-bar", "#tw-paper"],
60
+ "attr": [["#fullscreen-toggle", "aria-pressed", "false"]]
61
+ }
62
+ },
63
+ "submitted": {
64
+ "auth": true,
65
+ "_": "Submit: Home is in, next up Projects, take next, pick another, back to the studio.",
66
+ "actions": [
67
+ ["wait", 900],
68
+ ["click", "#tw-submit"],
69
+ ["wait", 900]
70
+ ],
71
+ "expect": {
72
+ "visible": ["#tw-done", "#tw-take", "#tw-pick"]
73
+ }
74
+ },
75
+ "picking": {
76
+ "auth": true,
77
+ "_": "Pick another: the other pages open in place.",
78
+ "actions": [
79
+ ["wait", 900],
80
+ ["click", "#tw-submit"],
81
+ ["wait", 900],
82
+ ["click", "#tw-pick"],
83
+ ["wait", 300]
84
+ ],
85
+ "expect": {
86
+ "visible": ["#tw-picks"]
87
+ }
88
+ },
89
+ "out": {
90
+ "auth": false
91
+ }
92
+ },
93
+ "reducedMotion": {
94
+ "auth": true,
95
+ "actions": [
96
+ ["wait", 900]
97
+ ]
98
+ }
99
+ }
@@ -0,0 +1,32 @@
1
+ # The tweak editor — /tweak-editor, the whole page as one document (task 1004321)
2
+
3
+ **BV2.TW10 (goal 1000095; [ADR 0341](../../../docs/adr/0341-the-page-is-the-unit-of-tweak-mode.md) D4, D11, D12).** The artist's own flow, built 1:1 on the owner-approved master design [`docs/design/mocks/tweak-mode/Editor.dc.html`](../../../docs/design/mocks/tweak-mode/README.md): over the night studio, a glass top bar (← studio, the page name and its surface, "N of M rewritten · saved", open the page ↗, download .docx, upload .docx, submit page) and one sheet of paper holding every line of the page in page order, typed over in place and autosaved. A changed line carries the accent mark and a faint "was:"; shared lines say "changes this on every page" and lines the applier cannot place say "goes to an engineer". Submit opens "<page> is in." with next up: take next, pick another, back to the studio.
4
+
5
+ Files: `tweak-editor.html` (the markup), `tweak-editor.css` (the artboard's inline styles, one rule per element), `tweak-editor-lib.js` (the pure rules, `window.OTBTweakEditor`, node-require-safe) and `tweak-editor.js` (the reads, the autosave, the doors). What it settled:
6
+
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
+ - **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.
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
+ - **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
+ - **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.
13
+ - **Names.** The inventory title is the page's `<title>`, which on the apex is a branding template (`{{productName}}: {{tagline}}`), so `displayTitle` names a templated page by what follows the product, and a surface's `/` is "Home" (the artboard's own word).
14
+
15
+ ## The palette is the artboard's, in both modes
16
+
17
+ The paper is paper and the room is the room whatever the hall's theme: the stage (`main.tw-stage`) carries the artboard's values as `--tw-*`, never `:root` or `body`, so the hall chrome around it keeps its own light and dark (the sky precedent, `records/sky.md`). The sheet is therefore its own token tier and sits in `tests/hall_audit.mjs` `LITERAL_EXEMPT`.
18
+
19
+ ## Where it differs from the artboard, and what forced it
20
+
21
+ - **The words are the reading's, not the sample's.** 75 lines for Home, not 33; the section names are the reading's (the page's own headings, from TW04's reader), not the artboard's hand-named "hero", "built on"; a section that recurs gets its heading again (consecutive runs, the .docx rule); the artboard's "page title" line is not on the page, so the reader does not list it.
22
+ - **The shared note says "changes this on every page"**, not "on every landing page": ADR 0341 D4 and the task name those words. A shared line with no registry row says "goes to an engineer" (the submit files it as one).
23
+ - **The paper grows and the window scrolls** (the hall's one-scroller seam), where the artboard's 860px sheet scrolled inside itself. So the bar is sticky, and its .07 glass lies on a backing of the room's black (`--tw-bar-back`), or the white paper passing under it turns the bar white and its words vanish. At rest over the room it reads as the artboard's bar.
24
+ - **The "is in" scrim is .88, not .45.** The panel sits over the white paper, and at .45 its white and grey words measure about 2.9:1; at .88 every word clears 4.5:1 (the AA floor).
25
+ - **A focused line gets a hairline accent ring** beside the artboard's warm wash (the hall's focus rule, `tests/hall_audit.mjs` §10).
26
+ - **"pick another" opens the other pages in place** as chips under the actions (the studio artboard's own "pick another"), where the editor artboard linked to the studio, whose flow panel is TW16's.
27
+ - **Boxes grow to their words** rather than the artboard's rows 1 or 2.
28
+ - **Not on the artboard at all:** the phone layout (the bar becomes a two-row glass card, notes drop under their lines), the shell's full-screen button top right, the read-only and refusal notices on the paper, the chrome view (the switch off), and light mode.
29
+
30
+ ## The proof
31
+
32
+ `tweak-editor.states.json` renders six states (doc, typed, chrome, submitted, picking, out). Against the kit's own stub the page renders its could-not-read state; populated, it renders against the hall-preview harness (`node scripts/hall-preview/server.js --port 4631 --fixture-me`), which answers the read from `copy-desk__pages__draft.json` (the real `landing:index` reading, through the real composer, with the artboard's two rewrites as the draft), next up from `copy-desk__next.json`, and the autosave, the submit and the claim from its CANNED WRITES (fixed answers that change nothing). All clean at 1440, 390 and 320, dark and light. The artboard needs the Design runtime, so for the review sheet it was rendered by expanding its own template over its own `renderVals()`; the ship notes of task 1004321 carry the sheet's path and every difference, which is the list above.
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.1063",
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.1063",
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.1063",
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,11 @@
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
+ }
7676
7682
  ]
7677
7683
  }
@@ -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
  }