grilling-workbench 0.2.0

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/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "grilling-workbench",
3
+ "version": "0.2.0",
4
+ "description": "Local question forms with whole-form submission and event-driven agent delivery",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/PavingLayer/grilling-workbench.git"
9
+ },
10
+ "homepage": "https://github.com/PavingLayer/grilling-workbench#readme",
11
+ "bugs": { "url": "https://github.com/PavingLayer/grilling-workbench/issues" },
12
+ "publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
13
+ "type": "module",
14
+ "bin": { "grilling-workbench": "bin/grilling-workbench.js" },
15
+ "files": ["bin/", "src/", "public/", "data/questions.json", "skills/", "docs/*.md"],
16
+ "engines": {
17
+ "node": ">=22"
18
+ },
19
+ "scripts": {
20
+ "dev": "node src/dev.js",
21
+ "start": "node src/dev.js",
22
+ "check": "node --check bin/grilling-workbench.js && node --check src/dev.js && node --check src/cli.js && node --check src/runtime.js && node --check src/core.js && node --check src/server.js && node --check src/storage.js && node --check src/delivery.js && node --check src/monitor.js && node --check src/submission-socket.js && node --check public/app.js",
23
+ "test": "node --test test/*.test.js",
24
+ "test:package": "node scripts/test-package.js && node scripts/test-package.js --npx"
25
+ },
26
+ "devDependencies": {
27
+ "fast-check": "^4.9.0"
28
+ }
29
+ }
package/public/app.js ADDED
@@ -0,0 +1,345 @@
1
+ import { transition, draftFor, questionById, hasAnswer, isStale, statusFor, progress, formSubmitted, makeReview as makeSubmission, formatSubmission } from '/core.js';
2
+
3
+ const app = document.querySelector('#app');
4
+ const dialog = document.querySelector('#app-dialog');
5
+ const escape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
6
+ let state, version, currentId, running = false, error = '', conflict = false, connectionWarning = '';
7
+ let pending = [], shownSubmission = null, receivedIds = new Set();
8
+ let toastTimer, modalMode = '', refreshing = false, sidebarCollapsed = false;
9
+ const narrowViewport = matchMedia('(max-width: 640px)');
10
+ const iconPaths = {
11
+ check: '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
12
+ clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
13
+ clear: '<path d="m4 13 9-9a2 2 0 0 1 3 0l5 5a2 2 0 0 1 0 3l-8 8H9l-5-4a2 2 0 0 1 0-3Z"/><path d="m8 9 9 9M13 20h8"/>',
14
+ balance: '<path d="M12 3v17M7 21h10M4 7h16M6 7l-4 8h8L6 7Zm12 0-4 8h8l-4-8Z"/>',
15
+ list: '<path d="M9 6h12M9 12h12M9 18h12M3 6h1M3 12h1M3 18h1"/>',
16
+ left: '<path d="m14 5-7 7 7 7"/>',
17
+ right: '<path d="M4 12h16m-6-6 6 6-6 6"/>',
18
+ };
19
+ const icon = name => `<svg class="icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">${iconPaths[name]}</svg>`;
20
+
21
+ function toast(message) {
22
+ const notice = document.querySelector('#notice');
23
+ notice.textContent = message;
24
+ notice.hidden = false;
25
+ clearTimeout(toastTimer);
26
+ toastTimer = setTimeout(() => { notice.hidden = true; }, 5000);
27
+ }
28
+
29
+ async function request(path, options) {
30
+ const response = await fetch(path, { cache: 'no-store', signal: AbortSignal.timeout(10000), ...options });
31
+ const data = await response.json();
32
+ if (!response.ok) throw Object.assign(new Error(data.error || 'The local server could not complete this request.'), { status: response.status });
33
+ return data;
34
+ }
35
+
36
+ function saveText() {
37
+ if (error) return 'Not saved';
38
+ if (connectionWarning) return 'Refresh unavailable';
39
+ return pending.length ? 'Saving…' : 'Saved';
40
+ }
41
+
42
+ function badge(id) {
43
+ return `<span class="badge">${hasAnswer(state.drafts[id]) ? 'Answered' : 'Not answered'}</span>`;
44
+ }
45
+
46
+ function renderChrome() {
47
+ if (!state) return;
48
+ const stats = progress(state);
49
+ const save = document.querySelector('#save-state');
50
+ if (save) { save.innerHTML = `${icon(error || connectionWarning || pending.length ? 'clock' : 'check')}<span>${escape(saveText())}</span>`; save.classList.toggle('warning', Boolean(error || connectionWarning || pending.length)); save.title = error || connectionWarning || (pending.length ? 'Saving your changes' : 'Saved on this computer'); }
51
+ document.querySelector('#session-title').textContent = state.questionnaire.title;
52
+ document.querySelector('#session-title').title = state.questionnaire.description;
53
+ document.querySelector('#demo-label').hidden = !state.questionnaire.id.includes('demo');
54
+ document.querySelector('#progress-label').innerHTML = `<strong>${stats.answered} of ${stats.total}</strong> answered`;
55
+ const meter = document.querySelector('progress');
56
+ meter.max = stats.total; meter.value = stats.answered;
57
+ meter.setAttribute('aria-label', 'Answered questions');
58
+ document.querySelector('#footer-status').textContent = formSubmitted(state) ? 'Form submitted' : `${stats.total - stats.answered} not answered`;
59
+ document.querySelector('#compact-progress').textContent = `${stats.answered} of ${stats.total} answered · ${formSubmitted(state) ? 'Form submitted' : `${stats.total - stats.answered} not answered`}`;
60
+ const sidebar = document.querySelector('#sidebar-questions');
61
+ const list = questionItems();
62
+ if (sidebar.innerHTML !== list) { const scroll = sidebar.scrollTop; sidebar.innerHTML = list; sidebar.scrollTop = scroll; }
63
+ document.querySelector('#question-badges').innerHTML = badge(currentId);
64
+ document.querySelector('[data-action="clear"]').disabled = !hasAnswer(draftFor(state, currentId));
65
+ document.querySelector('#history-button').textContent = `Submissions (${state.submissions.length})`;
66
+ document.querySelector('#history-button').hidden = !state.submissions.length;
67
+ // Draft autosaves must not toggle the submit button while the user types.
68
+ document.querySelector('#submit-form').disabled = conflict || pending.some(item => item.action.type === 'submit');
69
+ const warning = document.querySelector('#save-warning');
70
+ warning.hidden = !error && !connectionWarning;
71
+ warning.innerHTML = warning.hidden ? '' : `<p>${escape(error || connectionWarning)} Your work in this tab is retained. Unsaved changes will be lost if you close or reload it.</p><button class="button small" data-action="${conflict ? 'load-saved' : 'retry'}">${conflict ? 'Load saved version…' : 'Retry saving'}</button><button class="button small" data-action="recover">Download recovery copy</button>`;
72
+ }
73
+
74
+ function oldAnswer(draft) {
75
+ return `${draft.question.title}\n${draft.question.context}\n\n${draft.optionIds.map(id => { const o = draft.question.options.find(item => item.id === id); return `${o.label}\n${o.description}\nBenefit: ${o.benefit}\nTrade-off: ${o.tradeoff}`; }).join('\n\n')}${draft.text ? `\n\n${draft.text}` : ''}`;
76
+ }
77
+
78
+ function render() {
79
+ if (!state) return;
80
+ const focused = document.activeElement;
81
+ const textFocus = focused?.id === 'answer-text' ? { start: focused.selectionStart, end: focused.selectionEnd } : null;
82
+ const mainScroll = document.querySelector('.main')?.scrollTop || 0;
83
+ const sidebarScroll = document.querySelector('#sidebar-questions')?.scrollTop || 0;
84
+ currentId = questionById(state, currentId) ? currentId : state.questionnaire.questions[0].id;
85
+ const q = questionById(state, currentId), draft = draftFor(state, currentId);
86
+ const index = state.questionnaire.questions.findIndex(item => item.id === currentId);
87
+ const stale = isStale(state, currentId) && hasAnswer(draft);
88
+ const canAdopt = draft.optionIds.every(id => q.options.some(o => o.id === id)) && (q.type !== 'single' || draft.optionIds.length <= 1) && (q.type !== 'text' || !draft.optionIds.length);
89
+ document.title = `${state.questionnaire.title} · Workbench`;
90
+ app.innerHTML = `<div class="workspace">
91
+ <aside class="sidebar" id="question-sidebar" aria-label="Question sidebar"><div class="sidebar-heading">Questions <span>${state.questionnaire.questions.length}</span></div><nav id="sidebar-questions" class="sidebar-questions" aria-label="Questions"></nav><div class="sidebar-summary"><progress class="progress-track" aria-label="Submitted questions" max="1" value="0"></progress><p><span id="progress-label"></span><span aria-hidden="true"> · </span><span id="footer-status"></span></p><button id="history-button" class="button sidebar-button" data-action="history" hidden></button></div></aside>
92
+ <main class="main"><div class="form-content"><p class="compact-progress" id="compact-progress"></p>
93
+ <div id="save-warning" class="alert" role="alert" hidden></div>
94
+ <article class="question-sheet" id="question" aria-labelledby="question-title"><div class="question-meta"><span class="question-position">Question ${index + 1} of ${state.questionnaire.questions.length}</span><button class="button small question-selector" id="question-selector" data-action="questions" aria-haspopup="dialog" aria-controls="app-dialog">Question ${index + 1} of ${state.questionnaire.questions.length} <span aria-hidden="true">▾</span></button><span id="question-badges"></span></div><h1 id="question-title" tabindex="-1">${escape(q.title)}</h1><p class="question-context">${escape(q.context)}</p>
95
+ ${stale ? `<div class="alert"><p><strong>This question was updated.</strong> Your earlier answer is kept below. ${canAdopt ? 'Review the new wording, then keep or edit your answer.' : 'An earlier choice is no longer available. Choose a new answer to continue.'}</p><details><summary>Earlier question and answer</summary><div class="old-answer">${escape(oldAnswer(draft))}</div></details>${canAdopt ? '<button class="button small" data-action="adopt">Keep my answer with this wording</button>' : ''}</div>` : ''}
96
+ ${q.type !== 'text' ? `<p class="answer-hint">${q.type === 'single' ? 'Choose one, or write your own answer below.' : 'Choose any that apply, or write your own answer below.'}</p><fieldset class="options" aria-labelledby="question-title">${q.options.map(o => `<label class="option ${draft.optionIds.includes(o.id) ? 'selected' : ''}" id="option-${escape(q.id)}-${escape(o.id)}"><span class="option-top"><input type="${q.type === 'single' ? 'radio' : 'checkbox'}" name="answer-option" value="${escape(o.id)}" ${draft.optionIds.includes(o.id) ? 'checked' : ''} aria-labelledby="label-${escape(q.id)}-${escape(o.id)}"><span class="option-title" id="label-${escape(q.id)}-${escape(o.id)}">${escape(o.label)}</span>${o.recommended ? '<span class="badge recommended">Recommended</span>' : ''}</span><div class="option-body"><p class="option-description">${escape(o.description)}</p><div class="tradeoffs"><p><strong class="offers">${icon('check')} Offers</strong><span>${escape(o.benefit)}</span></p><p><strong class="tradeoff">${icon('balance')} Trade-off</strong><span>${escape(o.tradeoff)}</span></p></div></div></label>`).join('')}</fieldset>` : ''}
97
+ <label for="answer-text" class="field-label">${q.type === 'text' ? 'Your answer' : 'Your answer <span class="optional">In your own words</span>'}</label><textarea id="answer-text" maxlength="20000" rows="${q.type === 'text' ? 7 : 3}">${escape(draft.text)}</textarea>
98
+ <div class="question-actions"><button class="button" data-action="defer">${icon('clock')}Answer later</button><button class="button" data-action="clear" ${hasAnswer(draft) ? '' : 'disabled'}>${icon('clear')}Clear answer</button></div></article></div></main>
99
+ </div><footer class="footer-bar"><nav class="footer-buttons ${index === state.questionnaire.questions.length - 1 ? 'last-question' : ''}" aria-label="Question navigation"><button class="button previous-question" ${index > 0 ? `data-go="${escape(state.questionnaire.questions[index - 1].id)}"` : 'disabled'} aria-label="Previous question">${icon('left')}<span>Previous</span></button><button class="button ${index === state.questionnaire.questions.length - 1 ? 'primary' : ''}" id="submit-form" data-action="submit">${icon('check')}Submit form</button>${index < state.questionnaire.questions.length - 1 ? `<button class="button primary" data-go="${escape(state.questionnaire.questions[index + 1].id)}">Next question ${icon('right')}</button>` : ''}</nav></footer>`;
100
+ renderChrome();
101
+ document.querySelector('.main').scrollTop = mainScroll;
102
+ document.querySelector('#sidebar-questions').scrollTop = sidebarScroll;
103
+ if (textFocus) { const field = document.querySelector('#answer-text'); field.focus({ preventScroll: true }); field.setSelectionRange(textFocus.start, textFocus.end); }
104
+ }
105
+
106
+ function stage(action, redraw = true) {
107
+ try { state = transition(state, action); }
108
+ catch (failure) { toast(failure.message); return; }
109
+ pending.push({ requestId: crypto.randomUUID(), action });
110
+ if (redraw) render(); else renderChrome();
111
+ void flush();
112
+ }
113
+
114
+ async function flush() {
115
+ if (running || conflict) return;
116
+ running = true;
117
+ try {
118
+ while (pending.length) {
119
+ const item = pending[0];
120
+ item.version ??= version;
121
+ const result = await request('/api/actions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(item) });
122
+ version = result.version;
123
+ pending.shift();
124
+ if (!pending.length) state = result.state;
125
+ error = ''; connectionWarning = '';
126
+ renderChrome();
127
+ }
128
+ } catch (failure) {
129
+ error = failure.message;
130
+ conflict = failure.status === 409;
131
+ } finally {
132
+ running = false;
133
+ renderChrome();
134
+ if (modalMode === 'saving') {
135
+ if (pending.length || error) renderSaveFailure();
136
+ else showSubmission(shownSubmission);
137
+ }
138
+ }
139
+ }
140
+
141
+ function navigate(id) {
142
+ currentId = id;
143
+ history.replaceState(null, '', `#${encodeURIComponent(id)}`);
144
+ render();
145
+ document.querySelector('.main').scrollTop = 0;
146
+ document.querySelector('#sidebar-questions [aria-current="step"]')?.scrollIntoView({ block: 'nearest' });
147
+ document.querySelector('#question-title').focus({ preventScroll: true });
148
+ }
149
+
150
+ function readAnswer() {
151
+ return { optionIds: [...document.querySelectorAll('input[name="answer-option"]:checked')].map(input => input.value), text: document.querySelector('#answer-text').value };
152
+ }
153
+
154
+ app.addEventListener('input', event => {
155
+ if (event.target.id === 'answer-text') stage({ type: 'edit', questionId: currentId, answer: readAnswer() }, false);
156
+ });
157
+ app.addEventListener('change', event => {
158
+ if (event.target.name === 'answer-option') {
159
+ const optionId = event.target.value;
160
+ stage({ type: 'edit', questionId: currentId, answer: readAnswer() });
161
+ [...document.querySelectorAll('[name="answer-option"]')].find(input => input.value === optionId)?.focus({ preventScroll: true });
162
+ }
163
+ });
164
+ app.addEventListener('click', event => {
165
+ const go = event.target.closest('[data-go]');
166
+ if (go) return navigate(go.dataset.go);
167
+ const action = event.target.closest('[data-action]')?.dataset.action;
168
+ if (action === 'defer') {
169
+ const index = state.questionnaire.questions.findIndex(q => q.id === currentId);
170
+ if (index < state.questionnaire.questions.length - 1) navigate(state.questionnaire.questions[index + 1].id);
171
+ else showQuestions();
172
+ }
173
+ if (action === 'clear') stage({ type: 'edit', questionId: currentId, answer: { optionIds: [], text: '' } });
174
+ if (action === 'adopt') stage({ type: 'adopt', questionId: currentId });
175
+ if (action === 'submit') submitForm();
176
+ if (action === 'questions') showQuestions();
177
+ if (action === 'history') showHistory();
178
+ if (action === 'recover') downloadRecovery();
179
+ if (action === 'retry') retry();
180
+ if (action === 'load-saved') loadSaved();
181
+ });
182
+
183
+ function openDialog(html, mode) {
184
+ modalMode = mode;
185
+ dialog.innerHTML = html;
186
+ if (!dialog.open) dialog.showModal();
187
+ const heading = dialog.querySelector('#dialog-title');
188
+ heading.tabIndex = -1;
189
+ heading.focus({ preventScroll: true });
190
+ }
191
+ const dialogHead = (title, description = '') => `<div class="dialog-head"><div><h2 id="dialog-title">${title}</h2>${description ? `<p>${description}</p>` : ''}</div><button class="close" data-modal="close" aria-label="Close dialog">×</button></div>`;
192
+
193
+ function showQuestions() {
194
+ const stats = progress(state);
195
+ openDialog(`${dialogHead('Questions', `${stats.answered} of ${stats.total} answered · ${stats.total - stats.answered} not answered`)}<nav class="question-list" aria-label="All questions">${questionItems()}</nav>${state.submissions.length ? `<div class="dialog-foot"><button class="button" data-modal="history">Submissions (${state.submissions.length})</button></div>` : ''}`, 'questions');
196
+ dialog.querySelector('[aria-current="step"]')?.scrollIntoView({ block: 'nearest' });
197
+ }
198
+
199
+ function questionItems() {
200
+ return state.questionnaire.questions.map((q, index) => {
201
+ const status = statusFor(state, q.id);
202
+ const answered = hasAnswer(state.drafts[q.id]);
203
+ const updated = isStale(state, q.id) && hasAnswer(state.drafts[q.id]);
204
+ return `<button class="nav-item ${q.id === currentId ? 'active' : ''} ${status} " data-go="${escape(q.id)}" title="${escape(q.title)}" ${q.id === currentId ? 'aria-current="step"' : ''}><span class="nav-dot" aria-hidden="true">${status === 'submitted' ? '✓' : ''}</span><span class="nav-number">${String(index + 1).padStart(2, '0')}</span><span class="nav-text"><span class="nav-title">${escape(state.questionnaire.navigationLabels?.[q.id] || q.title)}</span><span class="nav-status">${answered ? 'Answered' : 'Not answered'}${updated ? ' · Updated' : ''}</span></span></button>`;
205
+ }).join('');
206
+ }
207
+
208
+ function updateSidebarToggle() {
209
+ document.body.classList.toggle('sidebar-collapsed', sidebarCollapsed);
210
+ const toggle = document.querySelector('#toggle-sidebar');
211
+ toggle.setAttribute('aria-expanded', String(!sidebarCollapsed && !narrowViewport.matches));
212
+ toggle.setAttribute('aria-label', narrowViewport.matches ? 'Show questions' : sidebarCollapsed ? 'Show question sidebar' : 'Hide question sidebar');
213
+ }
214
+ document.querySelector('#toggle-sidebar').addEventListener('click', () => {
215
+ if (!state) return;
216
+ if (narrowViewport.matches) return showQuestions();
217
+ sidebarCollapsed = !sidebarCollapsed;
218
+ updateSidebarToggle();
219
+ });
220
+ narrowViewport.addEventListener('change', updateSidebarToggle);
221
+ updateSidebarToggle();
222
+
223
+ function submitForm() {
224
+ if (conflict || pending.some(item => item.action.type === 'submit')) return;
225
+ let snapshot;
226
+ try {
227
+ // Capture exactly what is in the form at the explicit click, including edits
228
+ // still saving. The existing queue persists those edits before this snapshot.
229
+ snapshot = makeSubmission(state, crypto.randomUUID(), new Date().toISOString());
230
+ } catch (failure) {
231
+ const changed = state.questionnaire.questions.find(q => hasAnswer(state.drafts[q.id]) && isStale(state, q.id));
232
+ if (changed) navigate(changed.id);
233
+ toast(failure.message);
234
+ return;
235
+ }
236
+ shownSubmission = snapshot;
237
+ openDialog(`${dialogHead('Submitting form')}<div class="dialog-body"><p role="status">Saving your whole form…</p></div>`, 'saving');
238
+ stage({ type: 'submit', review: snapshot });
239
+ }
240
+
241
+ function renderSaveFailure() {
242
+ openDialog(`${dialogHead('Your form is not saved yet')}<div class="dialog-body"><p>Your confirmed form is retained in this tab. ${escape(error)}</p><p>Keep this page open or download a recovery copy before leaving.</p><button class="button" data-modal="recover">Download recovery copy</button></div><div class="dialog-foot"><button class="button" data-modal="close">Back to form</button>${!conflict ? '<button class="button primary" data-modal="retry">Retry saving</button>' : ''}</div>`, 'save-failed');
243
+ }
244
+
245
+ function renderReceipt() {
246
+ const status = document.querySelector('#delivery-status');
247
+ if (status && shownSubmission) status.textContent = receivedIds.has(shownSubmission.id) ? 'Received by the agent in chat.' : 'Saved. Waiting for the agent to receive this form.';
248
+ }
249
+
250
+ async function refreshReceipt() {
251
+ if (!state?.submissions.length) return;
252
+ try {
253
+ const receipts = await request('/api/delivery');
254
+ receivedIds = new Set(receipts.received.map(r => r.submissionId));
255
+ renderReceipt();
256
+ } catch {
257
+ const status = document.querySelector('#delivery-status');
258
+ if (status) status.textContent = 'Your form is saved. Agent receipt could not be checked yet.';
259
+ }
260
+ }
261
+
262
+ function showSubmission(submission) {
263
+ shownSubmission = submission;
264
+ openDialog(`${dialogHead('Form submitted', 'Your entire form is saved. You can continue in chat without copying or pasting anything.')}<div class="dialog-body"><div class="success-symbol" aria-hidden="true">✓</div><p id="delivery-status" role="status"></p><details class="submission-details"><summary>View submitted form</summary><pre class="export-text">${escape(formatSubmission(submission))}</pre></details></div><div class="dialog-foot"><button class="button primary" data-modal="close">Back to form</button></div>`, 'submission');
265
+ render(); renderReceipt(); void refreshReceipt();
266
+ }
267
+
268
+ function showHistory() {
269
+ const saved = state.submissions.filter(s => !pending.some(item => item.action.type === 'submit' && item.action.review.id === s.id));
270
+ openDialog(`${dialogHead('Submitted answers', 'Earlier submissions keep their original questions and answers.')}<div class="dialog-body">${saved.map(s => `<button class="history-item" data-submission="${escape(s.id)}"><strong>Form · ${escape(new Date(s.createdAt).toLocaleString())}</strong><span>${s.answers.map(a => escape(a.question.title)).join('<br>')}</span></button>`).join('') || '<p>No saved submissions yet.</p>'}</div>`, 'history');
271
+ }
272
+
273
+ dialog.addEventListener('click', async event => {
274
+ const go = event.target.closest('[data-go]');
275
+ if (go) { dialog.close(); navigate(go.dataset.go); return; }
276
+ const prior = event.target.closest('[data-submission]');
277
+ if (prior) return showSubmission(state.submissions.find(s => s.id === prior.dataset.submission));
278
+ const action = event.target.closest('[data-modal]')?.dataset.modal;
279
+ if (action === 'close') dialog.close();
280
+ if (action === 'history') showHistory();
281
+ if (action === 'recover') downloadRecovery();
282
+ if (action === 'retry') { modalMode = 'saving'; retry(); }
283
+
284
+ });
285
+ dialog.addEventListener('close', () => {
286
+ const wasQuestionPicker = modalMode === 'questions';
287
+ modalMode = '';
288
+ const candidates = wasQuestionPicker ? ['#question-selector', '#toggle-sidebar'] : ['#submit-form:not(:disabled)', '#history-button:not([hidden])', '#toggle-sidebar'];
289
+ const returnTarget = candidates.map(selector => document.querySelector(selector)).find(element => element?.getClientRects().length);
290
+ returnTarget?.focus({ preventScroll: true });
291
+ });
292
+
293
+ function downloadRecovery() {
294
+ const blob = new Blob([JSON.stringify({ version, state, pending }, null, 2)], { type: 'application/json' });
295
+ const url = URL.createObjectURL(blob);
296
+ const link = document.createElement('a'); link.href = url; link.download = 'workbench-recovery.json'; link.click();
297
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
298
+ }
299
+
300
+ async function retry() {
301
+ error = ''; connectionWarning = '';
302
+ if (pending.length) await flush(); else await refresh();
303
+ renderChrome();
304
+ }
305
+
306
+ async function loadSaved() {
307
+ if (!confirm('Replace this tab’s unsaved changes with the saved version? Download a recovery copy first if you want to keep them.')) return;
308
+ try {
309
+ const result = await request('/api/session');
310
+ state = result.state; version = result.version; pending = []; error = ''; conflict = false; connectionWarning = '';
311
+ dialog.close(); render();
312
+ } catch (failure) { error = failure.message; renderChrome(); }
313
+ }
314
+
315
+ async function refresh() {
316
+ void refreshReceipt();
317
+ if (running || pending.length || conflict || refreshing) return;
318
+ refreshing = true;
319
+ try {
320
+ const result = await request('/api/session');
321
+ // A poll begun before typing must never replace a newly staged answer.
322
+ if (running || pending.length || conflict || result.version < version) return;
323
+ connectionWarning = '';
324
+ if (result.version !== version) {
325
+ state = result.state; version = result.version;
326
+ render();
327
+ if (modalMode === 'questions') showQuestions();
328
+ } else renderChrome();
329
+ } catch (failure) { connectionWarning = failure.message; renderChrome(); }
330
+ finally { refreshing = false; }
331
+ }
332
+
333
+ async function start() {
334
+ try {
335
+ const result = await request('/api/session'); state = result.state; version = result.version;
336
+ currentId = decodeURIComponent(location.hash.slice(1)) || state.questionnaire.questions[0].id;
337
+ render();
338
+ setInterval(refresh, 2500);
339
+ } catch (failure) {
340
+ app.innerHTML = `<div class="loading alert"><h1>Could not open the form</h1><p>${escape(failure.message)}</p><button class="button" id="retry-start">Try again</button></div>`;
341
+ document.querySelector('#retry-start').addEventListener('click', start);
342
+ }
343
+ }
344
+ window.addEventListener('beforeunload', event => { if (pending.length) { event.preventDefault(); event.returnValue = ''; } });
345
+ void start();
@@ -0,0 +1,19 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <meta name="description" content="A local question-answering workbench. Keep drafts, revisit questions, and submit the whole form in one action.">
7
+ <title>Reading room · Workbench</title>
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='6' fill='%2300656b'/%3E%3Cpath d='M8 9v14h6V9m4 0v14h6V9' fill='none' stroke='white' stroke-width='2'/%3E%3C/svg%3E">
9
+ <link rel="stylesheet" href="/styles.css">
10
+ <script type="module" src="/app.js"></script>
11
+ </head>
12
+ <body>
13
+ <a class="skip-link" href="#question">Skip to question</a>
14
+ <header class="topbar"><div class="brand-area"><button id="toggle-sidebar" class="menu-button" aria-label="Hide question sidebar" aria-expanded="true" aria-controls="question-sidebar"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 6h16M4 12h16M4 18h16"/></svg></button><span class="brand">Workbench</span></div><div class="session-bar"><div class="session-name"><span class="session-prefix">Session:</span> <strong id="session-title">Opening questions…</strong><span id="demo-label" class="demo-label" hidden>Demo</span></div><span id="save-state" class="save-indicator" role="status" aria-live="polite"></span></div></header>
15
+ <div id="app"><p class="loading" role="status">Opening your questions…</p></div>
16
+ <dialog id="app-dialog" aria-labelledby="dialog-title"></dialog>
17
+ <div id="notice" class="toast" role="status" aria-live="polite" hidden></div>
18
+ </body>
19
+ </html>
@@ -0,0 +1,212 @@
1
+ :root {
2
+ color-scheme: light;
3
+ --ink: #20282d;
4
+ --muted: #626c72;
5
+ --line: #d9dfe2;
6
+ --paper: #fff;
7
+ --canvas: #fff;
8
+ --blue: #00676d;
9
+ --blue-soft: #eef8f8;
10
+ --green: #177548;
11
+ --amber: #a56909;
12
+ --sidebar-width: 196px;
13
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
14
+ font-size: 16px;
15
+ color: var(--ink);
16
+ background: var(--canvas);
17
+ font-synthesis: none;
18
+ }
19
+ * { box-sizing: border-box; }
20
+ html { height: 100%; }
21
+ body { margin: 0; height: 100vh; height: 100dvh; display: flex; flex-direction: column; overflow: hidden; }
22
+ button, input, textarea { font: inherit; }
23
+ button, a, input, textarea { touch-action: manipulation; }
24
+ button { cursor: pointer; border: 0; background: none; color: inherit; }
25
+ button:disabled { cursor: not-allowed; opacity: .42; }
26
+ a { color: inherit; text-decoration: none; }
27
+ button:focus-visible, a:focus-visible, input:focus-visible, textarea:focus-visible, summary:focus-visible {
28
+ outline: 3px solid #57b3bf; outline-offset: 3px;
29
+ }
30
+ p { line-height: 1.55; margin: 0 0 1rem; }
31
+ h1, h2, h3 { line-height: 1.35; margin: 0; font-weight: 650; letter-spacing: -.015em; }
32
+ h1 { font-size: 1.375rem; }
33
+ h2 { font-size: 1.25rem; }
34
+ h3 { font-size: 1rem; }
35
+ .icon, .menu-button svg { width: 20px; height: 20px; flex: 0 0 20px; fill: none; stroke: currentColor; stroke-width: 1.65; stroke-linecap: round; stroke-linejoin: round; }
36
+ .topbar { height: 64px; flex: 0 0 64px; display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); border-bottom: 1px solid var(--line); background: white; }
37
+ .brand-area { background: #191e22; color: #f7f8f8; display: flex; align-items: center; gap: 9px; padding: 0 14px; }
38
+ .brand { font-weight: 600; font-size: 1.125rem; letter-spacing: -.025em; }
39
+ .menu-button { display: grid; place-items: center; border-radius: 4px; width: 30px; height: 34px; padding: 5px; }
40
+ .menu-button:hover { background: #343d42; }
41
+ .session-bar { display: flex; gap: 16px; align-items: center; justify-content: space-between; min-width: 0; padding: 0 28px; }
42
+ .session-name { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: .875rem; }
43
+ .session-prefix { color: var(--muted); }
44
+ #session-title { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 600; }
45
+ .demo-label { color: var(--muted); border: 1px solid var(--line); border-radius: 3px; font-size: .75rem; padding: 1px 5px; margin-left: 2px; }
46
+ .save-indicator { display: flex; align-items: center; gap: 7px; flex-shrink: 0; font-size: .875rem; color: var(--green); }
47
+ .save-indicator.warning { color: var(--amber); }
48
+ #app { flex: 1; min-height: 0; display: grid; grid-template-rows: minmax(0, 1fr) auto; }
49
+ .workspace { display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 0; }
50
+ .sidebar { min-height: 0; display: flex; flex-direction: column; background: #272e33; color: #f3f5f5; border-right: 1px solid #323a40; }
51
+ .sidebar-heading { flex-shrink: 0; padding: 21px 16px 19px; border-bottom: 1px solid #42494d; font-size: .875rem; font-weight: 600; display: flex; align-items: center; justify-content: space-between; }
52
+ .sidebar-heading > span { color: #b8c1c6; font-weight: 400; font-size: .75rem; }
53
+ .sidebar-questions { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 6px 0; scrollbar-width: thin; scrollbar-color: #89969b #272e33; }
54
+ .nav-item { display: grid; grid-template-columns: 12px 20px minmax(0, 1fr); gap: 8px; text-align: left; padding: 15px 14px; align-items: start; width: 100%; color: #f1f4f4; }
55
+ .nav-item:hover { background: #343e44; }
56
+ .nav-item.active { background: #00666c; color: white; }
57
+ .nav-text { min-width: 0; }
58
+ .nav-title { font-size: .875rem; line-height: 1.45; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; overflow-wrap: anywhere; font-weight: 500; }
59
+ .nav-number { font-size: .8125rem; line-height: 1.55; font-variant-numeric: tabular-nums; color: #d5dce0; }
60
+ .nav-dot { width: 11px; height: 11px; margin-top: 5px; border-radius: 50%; background: #b5bdc1; color: #203027; display: grid; place-items: center; font-size: .625rem; line-height: 1; }
61
+ .nav-item.active .nav-dot { background: transparent; border: 1.5px solid white; }
62
+ .nav-item.draft .nav-dot { background: #69c9cd; }
63
+ .nav-item.deferred .nav-dot { background: #e1b759; }
64
+ .nav-item.submitted .nav-dot { background: #a3d547; }
65
+ .nav-status { font-size: .75rem; color: #b6c1c6; display: block; margin-top: 4px; line-height: 1.35; }
66
+ .nav-item.active .nav-status, .nav-item.active .nav-number { color: #e0f0f1; }
67
+ .sidebar-summary { flex-shrink: 0; padding: 16px; border-top: 1px solid #42494d; }
68
+ .sidebar-summary p { margin: 12px 0 0; font-size: .75rem; line-height: 1.6; }
69
+ #progress-label strong { color: #a3d547; font-weight: 650; }
70
+ .progress-track { display: block; width: 100%; height: 3px; appearance: none; border: 0; background: #505b60; border-radius: 2px; overflow: hidden; }
71
+ .progress-track::-webkit-progress-bar { background: #505b60; }
72
+ .progress-track::-webkit-progress-value { background: #a3d547; }
73
+ .progress-track::-moz-progress-bar { background: #a3d547; }
74
+ .button.sidebar-button { width: 100%; min-height: 36px; padding: 8px; margin-top: 14px; background: transparent; color: #e3eaed; border-color: #69757b; font-size: .8125rem; }
75
+ .button.sidebar-button:hover { background: #3c464c; }
76
+ .main { min-width: 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 28px 30px 32px; scrollbar-gutter: stable; }
77
+ .form-content { max-width: 940px; margin: 0 auto; }
78
+ .question-sheet { background: white; }
79
+ .question-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 16px; }
80
+ .question-position { color: var(--blue); font-size: .875rem; font-weight: 550; }
81
+ .button.question-selector, .compact-progress { display: none; }
82
+ .question-context { margin: 15px 0 18px; color: #515b62; font-size: 1rem; }
83
+ .answer-hint { margin: 0 0 13px; font-size: .875rem; color: var(--muted); }
84
+ .badge { display: inline-block; padding: 2px 6px; border-radius: 3px; background: #f0f3f4; color: #5e6b72; font-size: .75rem; line-height: 1.5; font-weight: 500; }
85
+ .badge.recommended { color: #00676d; border: 1px solid #a0d0d3; background: #f0fafa; border-radius: 12px; padding: 2px 8px; white-space: nowrap; }
86
+ .badge.submitted { color: var(--green); background: #edf7f0; }
87
+ .badge.deferred { color: var(--amber); background: #fff5e0; margin-left: 5px; }
88
+ .options { border: 1px solid var(--line); padding: 0; margin: 0; min-width: 0; }
89
+ .option { display: block; padding: 20px 18px 16px; cursor: pointer; border-bottom: 1px solid var(--line); transition: background .12s; }
90
+ .option:last-child { border-bottom: 0; }
91
+ .option:hover { background: #f8fbfb; }
92
+ .option.selected { background: #f0f9f9; box-shadow: inset 3px 0 var(--blue); }
93
+ .option-top { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; }
94
+ .option input { accent-color: var(--blue); height: 22px; width: 22px; flex: 0 0 22px; margin: 1px 0 0; }
95
+ .option-title { font-size: 1.0625rem; line-height: 1.4; font-weight: 600; flex: 1; min-width: 120px; }
96
+ .option-body { margin: 10px 0 0 34px; }
97
+ .option-description { font-size: 1rem; line-height: 1.55; margin: 0 0 12px; color: #424d54; }
98
+ .tradeoffs p { display: grid; grid-template-columns: 100px minmax(0, 1fr); align-items: start; gap: 10px; padding: 11px 0; margin: 0; border-bottom: 1px solid #e5e9eb; font-size: .9375rem; line-height: 1.5; }
99
+ .tradeoffs p:last-child { border: 0; padding-bottom: 0; }
100
+ .tradeoffs strong { display: flex; align-items: center; gap: 8px; font-weight: 550; font-size: .875rem; padding-top: 1px; }
101
+ .tradeoffs .offers { color: var(--blue); }
102
+ .tradeoffs .tradeoff { color: #ad7000; }
103
+ .tradeoffs .icon { width: 18px; height: 18px; flex-basis: 18px; }
104
+ .field-label { display: flex; justify-content: space-between; gap: 12px; font-size: .875rem; font-weight: 500; margin: 23px 0 10px; }
105
+ .optional { font-weight: 400; color: var(--muted); }
106
+ textarea { resize: vertical; display: block; width: 100%; border: 1px solid #aeb9bf; border-radius: 4px; padding: 12px; color: var(--ink); background: white; line-height: 1.55; min-height: 84px; }
107
+ textarea::placeholder { color: #7c878c; }
108
+ .question-actions { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin-top: 22px; }
109
+ .button { border: 1px solid #92a2ab; background: white; border-radius: 4px; padding: 10px 16px; font-size: .875rem; font-weight: 500; display: inline-flex; gap: 10px; justify-content: center; align-items: center; min-height: 44px; color: #33454f; }
110
+ .button:hover { background: #f0f6f6; }
111
+ .button.primary { background: var(--blue); color: white; border-color: var(--blue); font-weight: 600; }
112
+ .button.primary:hover { background: #00535a; }
113
+ .button.small { padding: 6px 10px; min-height: 34px; }
114
+ .question-actions .button { min-width: 145px; }
115
+ .footer-bar { background: white; border-top: 1px solid #cbd3d7; padding: 19px 22px max(19px, env(safe-area-inset-bottom)); z-index: 5; }
116
+ .footer-buttons { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 30px; }
117
+ .footer-buttons.last-question { grid-template-columns: repeat(2, minmax(0, 1fr)); }
118
+ .footer-buttons .button { min-height: 46px; }
119
+ .alert { padding: 16px; border: 1px solid #e1cc98; background: #fffaf0; border-radius: 5px; font-size: .875rem; line-height: 1.6; margin: 0 0 18px; color: #795a20; }
120
+ .alert p { margin: 0 0 10px; }
121
+ .alert .button { margin: 4px 6px 0 0; }
122
+ .alert details { margin: 12px 0; }
123
+ .old-answer { white-space: pre-wrap; overflow-wrap: anywhere; margin-top: 10px; }
124
+ .loading { margin: 50px auto; padding: 24px; max-width: 700px; }
125
+ .skip-link { position: absolute; left: 12px; top: -100px; padding: 12px; background: white; z-index: 20; }
126
+ .skip-link:focus { top: 10px; }
127
+ dialog { color: var(--ink); border: 1px solid var(--line); border-radius: 8px; max-width: 740px; width: calc(100% - 32px); padding: 0; max-height: 88dvh; box-shadow: 0 20px 70px #10191c40; }
128
+ dialog::backdrop { background: #111c2470; }
129
+ .dialog-head { padding: 22px 26px; display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; border-bottom: 1px solid var(--line); }
130
+ .dialog-head h2 { font-size: 1.25rem; }
131
+ .dialog-head p { font-size: .875rem; color: var(--muted); margin: 7px 0 0; }
132
+ .close { font-size: 1.5rem; padding: 0 6px; color: var(--muted); }
133
+ .dialog-body { padding: 22px 26px; }
134
+ .dialog-foot { position: sticky; bottom: 0; padding: 16px 26px; background: white; border-top: 1px solid var(--line); display: flex; gap: 10px; justify-content: flex-end; }
135
+ .question-list { display: grid; gap: 2px; padding: 8px; max-height: min(60dvh, 560px); overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; }
136
+ .question-list .nav-item { color: var(--ink); border-radius: 4px; }
137
+ .question-list .nav-item:hover { background: #f0f5f5; }
138
+ .question-list .nav-item.active { background: #e5f4f4; color: var(--blue); }
139
+ .question-list .nav-title { display: block; overflow: visible; }
140
+ .question-list .nav-status, .question-list .nav-number { color: var(--muted); }
141
+ .question-list .nav-item.active .nav-dot { border-color: var(--blue); }
142
+ .submission-details summary { cursor: pointer; font-size: .875rem; color: var(--muted); }
143
+ .export-text { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: .875rem; line-height: 1.6; background: #f6f8f8; border: 1px solid var(--line); padding: 16px; border-radius: 5px; max-height: 340px; overflow: auto; margin-top: 16px; }
144
+ .pending-list { font-size: .875rem; color: var(--muted); line-height: 1.6; margin-top: 16px; }
145
+ .success-symbol { height: 42px; width: 42px; display: grid; place-items: center; background: #eaf5ef; color: var(--green); border-radius: 50%; font-size: 1.5rem; margin-bottom: 14px; }
146
+ .toast { position: fixed; bottom: 104px; left: 50%; transform: translateX(-50%); padding: 12px 18px; background: #26363d; color: white; border-radius: 5px; box-shadow: 0 5px 30px #0002; max-width: 90vw; font-size: .875rem; z-index: 30; }
147
+ .muted { color: var(--muted); }
148
+ .history-item { display: block; width: 100%; text-align: left; border-bottom: 1px solid var(--line); padding: 16px 0; }
149
+ .history-item strong { display: block; margin-bottom: 5px; }
150
+ .history-item span { font-size: .875rem; color: var(--muted); }
151
+ .sidebar-collapsed .workspace { grid-template-columns: minmax(0, 1fr); }
152
+ .sidebar-collapsed .sidebar, .sidebar-collapsed .question-position { display: none; }
153
+ .sidebar-collapsed .question-selector { display: inline-flex; }
154
+ .sidebar-collapsed .compact-progress { display: block; color: var(--muted); font-size: .8125rem; margin-bottom: 14px; }
155
+ @media (min-width: 1200px) {
156
+ :root { --sidebar-width: 228px; }
157
+ .main { padding: 32px 42px; }
158
+ .nav-item { padding: 16px 18px; }
159
+ }
160
+ @media (max-width: 850px) {
161
+ .session-bar { padding: 0 20px; gap: 12px; }
162
+ .session-prefix { display: none; }
163
+ .main { padding: 26px 22px 28px; }
164
+ .option { padding: 18px 16px 16px; }
165
+ .option-top { gap: 10px; }
166
+ .option-body { margin-left: 32px; }
167
+ .tradeoffs p { grid-template-columns: 92px minmax(0, 1fr); }
168
+ }
169
+ @media (max-width: 640px) {
170
+ :root { --sidebar-width: 168px; }
171
+ .topbar { height: 58px; flex-basis: 58px; }
172
+ .brand-area { gap: 5px; padding: 0 10px; }
173
+ .brand { font-size: 1rem; }
174
+ .session-bar { gap: 8px; padding: 0 12px; }
175
+ .session-name { font-size: .8125rem; }
176
+ .demo-label { display: none; }
177
+ .save-indicator { font-size: .75rem; gap: 4px; }
178
+ .save-indicator .icon { width: 16px; height: 16px; flex-basis: 16px; }
179
+ .workspace { grid-template-columns: minmax(0, 1fr); }
180
+ .sidebar, .question-position { display: none; }
181
+ .button.question-selector { display: inline-flex; }
182
+ .compact-progress { display: block; font-size: .8125rem; color: var(--muted); margin-bottom: 14px; }
183
+ .main { padding: 22px 16px 26px; }
184
+ .option-top .badge.recommended { margin-left: 32px; }
185
+ .option-top .option-title { flex-basis: calc(100% - 34px); }
186
+ .tradeoffs p { grid-template-columns: 1fr; gap: 5px; }
187
+ .tradeoffs strong { gap: 7px; }
188
+ .question-actions .button { min-width: 0; }
189
+ .footer-bar { padding: 12px 12px max(12px, env(safe-area-inset-bottom)); }
190
+ .footer-buttons { gap: 10px; grid-template-columns: 44px minmax(0, 1fr) minmax(0, 1fr); }
191
+ .footer-buttons.last-question { grid-template-columns: 44px minmax(0, 1fr); }
192
+ .footer-buttons .button { padding: 10px 8px; gap: 7px; font-size: .875rem; }
193
+ .previous-question span { display: none; }
194
+ .dialog-head, .dialog-body { padding: 20px; }
195
+ .dialog-foot { padding: 16px 20px; }
196
+ .toast { bottom: 90px; }
197
+ }
198
+ @media (max-width: 420px) {
199
+ .topbar { grid-template-columns: 146px minmax(0, 1fr); }
200
+ .brand-area { padding: 0 8px; gap: 2px; }
201
+ .brand { font-size: .9375rem; }
202
+ .session-bar { flex-direction: column; align-items: flex-start; justify-content: center; gap: 3px; }
203
+ .session-name { width: 100%; }
204
+ .option { padding: 16px 12px; }
205
+ .option-body { margin-left: 0; }
206
+ .question-actions { gap: 8px; }
207
+ .question-actions .button { padding: 10px; font-size: .8125rem; }
208
+ .footer-buttons .button .icon { width: 18px; height: 18px; flex-basis: 18px; }
209
+ #submit-form .icon { display: none; }
210
+ }
211
+ [hidden] { display: none !important; }
212
+ @media (prefers-reduced-motion: reduce) { * { transition: none !important; scroll-behavior: auto !important; } }