@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.
- package/.bongos-core.json +123 -48
- package/.claude/skills/grade-recover/SKILL.md +1 -0
- package/.claude/skills/tweak/SKILL.md +70 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +8 -0
- package/clients/bongos-client/index.cjs +8 -0
- package/clients/bongos-client/index.d.ts +12 -0
- package/clients/bongos-client/index.mjs +8 -0
- package/docs/api/openapi.json +301 -3
- package/docs/api-reference.md +7 -3
- package/docs/copy-inventory.md +44 -7
- package/docs/copy-registry.json +393 -26
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +36 -4
- package/docs/page-readings.json +34 -1
- package/modules/copy-desk/page-status.js +63 -0
- package/modules/copy-desk/pages.js +1 -0
- package/modules/copy-desk/routes/copy-desk.js +30 -0
- package/modules/hall-ui/public/tweak-editor-lib.js +137 -0
- package/modules/hall-ui/public/tweak-editor.css +437 -0
- package/modules/hall-ui/public/tweak-editor.html +111 -0
- package/modules/hall-ui/public/tweak-editor.js +447 -0
- package/modules/hall-ui/public/tweak-editor.states.json +99 -0
- package/modules/hall-ui/records/tweak-editor.md +32 -0
- package/modules/lifecycle/db-grade.js +16 -0
- package/modules/lifecycle/migrations/lifecycle_013_task_visual_slots.sql +52 -0
- package/modules/lifecycle/page-tweak-hold.js +74 -0
- package/modules/lifecycle/page-tweak-reads.js +33 -1
- package/modules/lifecycle/routes/tasks.js +4 -2
- package/modules/lifecycle/routes/visuals.js +82 -0
- package/modules/lifecycle/task-visual-db.js +40 -1
- package/modules/lifecycle/task-visuals.js +24 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/gds/copy-apply.js +277 -1
- package/scripts/gds/page-reader.js +2 -0
- package/scripts/gds/ship-finish.js +27 -2
- package/scripts/gds/ship-flow.js +17 -1
- package/scripts/gds/ship-land.js +30 -7
- package/scripts/gds/ship-merge.js +43 -15
- package/scripts/gds/strand-watch.js +41 -1
- package/scripts/gds/task.js +23 -5
- package/scripts/gds/tweak-renders.js +200 -0
- package/scripts/hall-preview/server.js +21 -0
- package/src/bongos/serve-internal.js +5 -1
- package/src/module-api.js +1 -1
- package/tests/copy_desk_page_editor.mjs +181 -0
- package/tests/hall_audit.mjs +5 -0
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_tweak_editor.mjs +432 -0
- package/tests/page_tweak_hold.mjs +265 -0
- package/tests/publish_status_branch.mjs +23 -0
- package/tests/task_visual_slots.mjs +161 -0
- package/tests/tweak_batch_apply.mjs +265 -0
|
@@ -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.
|
|
@@ -15,6 +15,7 @@ const { pool, withTx } = api;
|
|
|
15
15
|
const seams = api;
|
|
16
16
|
const { logGradeAttempt } = require('./db-analytics.js');
|
|
17
17
|
const { mostRecentClaimHolderId } = require('./db-shared.js');
|
|
18
|
+
const tweakHold = require('./page-tweak-hold.js');
|
|
18
19
|
|
|
19
20
|
// applyGrade — V3.R18 (#209). Writes the subagent grader result to task_grades
|
|
20
21
|
// and, if the grade passed, promotes completed→confirmed + awards credits.
|
|
@@ -132,7 +133,18 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
|
|
|
132
133
|
// state-machine SQL (FOR UPDATE, task_grades upsert, UPDATE tasks status,
|
|
133
134
|
// claim-holder lookup, logGradeAttempt) stays here in the lifecycle.
|
|
134
135
|
const reward = seams.resolveOptional('reward');
|
|
136
|
+
|
|
137
|
+
// THE HOLD (ADR 0341 D7; task 1004322 / BV2.TW11). A page tweak whose grade
|
|
138
|
+
// passes WAITS at completed for its artist: the grade row above is written,
|
|
139
|
+
// and the promotion below is withheld. TW12's approve performs it. Asked only
|
|
140
|
+
// on a pass at completed, and false for every task whose source is not
|
|
141
|
+
// exactly 'page-tweak', so every other grade takes the unchanged path below.
|
|
142
|
+
let held = false;
|
|
135
143
|
if (gradeResult.passed && task.status === 'completed') {
|
|
144
|
+
const { rows: srcRows } = await client.query(`SELECT source FROM tasks WHERE id = $1`, [taskId]);
|
|
145
|
+
held = tweakHold.isPageTweak(srcRows[0] || null);
|
|
146
|
+
}
|
|
147
|
+
if (gradeResult.passed && task.status === 'completed' && !held) {
|
|
136
148
|
await client.query(
|
|
137
149
|
`UPDATE tasks
|
|
138
150
|
SET status = 'confirmed',
|
|
@@ -195,6 +207,7 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
|
|
|
195
207
|
// !passed → task_grades row written above; task stays at completed; no
|
|
196
208
|
// credits, no achievements. Builder can manual-confirm via /confirm if the
|
|
197
209
|
// grade was wrong.
|
|
210
|
+
// held → the same row, the same completed; the pass is recorded and waits.
|
|
198
211
|
|
|
199
212
|
return {
|
|
200
213
|
taskId,
|
|
@@ -207,6 +220,9 @@ async function applyGrade({ taskId, builderId, gradeResult, committedFiles = nul
|
|
|
207
220
|
ideaBonus, // V3.R27 (#247): { ideaBonusAwarded, bonusBuilderId, ideaId } | null
|
|
208
221
|
netNegativeBonus,
|
|
209
222
|
advanceToMerge: finalStatus === 'confirmed',
|
|
223
|
+
// task 1004322: true only for a page tweak whose pass is waiting for its
|
|
224
|
+
// artist. ship.js reads it to publish the branch without landing it.
|
|
225
|
+
held,
|
|
210
226
|
justUnlocked,
|
|
211
227
|
grade: {
|
|
212
228
|
score: gradeResult.score,
|