@khanglvm/relay 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/LICENSE +21 -0
- package/README.md +325 -0
- package/bin/rly.js +9 -0
- package/docs/AGENT.md +317 -0
- package/package.json +46 -0
- package/skills/relay/SKILL.md +156 -0
- package/skills/relay/examples/blocks-showcase.json +118 -0
- package/skills/relay/examples/feature-feedback.json +27 -0
- package/skills/relay/examples/prototype-review.json +25 -0
- package/src/cli.js +629 -0
- package/src/open.js +15 -0
- package/src/server.js +371 -0
- package/src/spec.js +435 -0
- package/src/store.js +147 -0
- package/src/ui/annotate.css +151 -0
- package/src/ui/annotate.js +440 -0
- package/src/ui/app.js +605 -0
- package/src/ui/blocks.css +170 -0
- package/src/ui/blocks.js +719 -0
- package/src/ui/index.html +21 -0
- package/src/ui/kit.js +413 -0
- package/src/ui/style.css +231 -0
- package/src/util.js +19 -0
- package/vendor/VERSIONS.json +5 -0
- package/vendor/chart.umd.js +14 -0
- package/vendor/mermaid.min.js +3405 -0
package/src/ui/app.js
ADDED
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const boot = JSON.parse(document.getElementById('boot').textContent);
|
|
5
|
+
const spec = boot.spec;
|
|
6
|
+
const QS = spec.questions || [];
|
|
7
|
+
const app = document.getElementById('app');
|
|
8
|
+
const banner = document.getElementById('banner');
|
|
9
|
+
|
|
10
|
+
// ---------- helpers ----------
|
|
11
|
+
function el(tag, attrs = {}, ...children) {
|
|
12
|
+
const n = document.createElement(tag);
|
|
13
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
14
|
+
if (v === undefined || v === null) continue;
|
|
15
|
+
if (k === 'class') n.className = v;
|
|
16
|
+
else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
|
|
17
|
+
else n.setAttribute(k, v);
|
|
18
|
+
}
|
|
19
|
+
for (const c of children.flat()) {
|
|
20
|
+
if (c !== null && c !== undefined) n.append(c.nodeType ? c : document.createTextNode(c));
|
|
21
|
+
}
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ---------- theme (auto -> light -> dark) ----------
|
|
26
|
+
// Server-side pref wins: boards run on random ports, so localStorage alone
|
|
27
|
+
// can't carry the choice across boards. The server persists it globally.
|
|
28
|
+
const THEME_KEY = 'qb-theme';
|
|
29
|
+
const THEMES = ['auto', 'light', 'dark'];
|
|
30
|
+
let theme = (boot.pref && boot.pref.theme) || localStorage.getItem(THEME_KEY);
|
|
31
|
+
if (!THEMES.includes(theme)) theme = 'auto';
|
|
32
|
+
function effectiveTheme() {
|
|
33
|
+
if (theme !== 'auto') return theme;
|
|
34
|
+
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
35
|
+
}
|
|
36
|
+
function applyTheme() {
|
|
37
|
+
if (theme === 'auto') delete document.documentElement.dataset.theme;
|
|
38
|
+
else document.documentElement.dataset.theme = theme;
|
|
39
|
+
if (themeBtn) themeBtn.textContent = { auto: '◐ auto', light: '☀ light', dark: '☾ dark' }[theme];
|
|
40
|
+
// Custom-HTML iframes get ?theme=light|dark so authors can match the theme.
|
|
41
|
+
for (const f of document.querySelectorAll('iframe.viz')) {
|
|
42
|
+
try {
|
|
43
|
+
const u = new URL(f.src);
|
|
44
|
+
if (u.searchParams.get('theme') !== effectiveTheme()) {
|
|
45
|
+
u.searchParams.set('theme', effectiveTheme());
|
|
46
|
+
f.src = u.toString();
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// ignore malformed src
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// Re-theme native blocks (mermaid re-render, chart grid/tick restyle).
|
|
53
|
+
if (window.RelayBlocks && typeof RelayBlocks.onThemeChange === 'function') {
|
|
54
|
+
try {
|
|
55
|
+
RelayBlocks.onThemeChange(effectiveTheme());
|
|
56
|
+
} catch {
|
|
57
|
+
// blocks may not be rendered yet
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
let themeBtn = null;
|
|
62
|
+
|
|
63
|
+
// ---------- state ----------
|
|
64
|
+
// state.answers holds raw control state; state.other holds the "Other"
|
|
65
|
+
// free-text per question; getValue() derives the final answer value.
|
|
66
|
+
// state.annotations holds element-level comments (managed by RelayAnnotate).
|
|
67
|
+
const state = {
|
|
68
|
+
answers: {},
|
|
69
|
+
other: {},
|
|
70
|
+
notes: {},
|
|
71
|
+
comment: '',
|
|
72
|
+
annotations: (boot.prefill && boot.prefill.annotations) || [],
|
|
73
|
+
};
|
|
74
|
+
let submitted = false;
|
|
75
|
+
|
|
76
|
+
function seedFromPrefill(prefill) {
|
|
77
|
+
state.comment = prefill.comment || '';
|
|
78
|
+
if (prefill.notes && typeof prefill.notes === 'object') state.notes = { ...prefill.notes };
|
|
79
|
+
const ans = prefill.answers || {};
|
|
80
|
+
for (const q of QS) {
|
|
81
|
+
const v = ans[q.id];
|
|
82
|
+
if (v === undefined || v === null) continue;
|
|
83
|
+
if (q.type === 'multi' && Array.isArray(v)) {
|
|
84
|
+
const known = new Set((q.options || []).map((o) => o.value));
|
|
85
|
+
state.answers[q.id] = v.filter((x) => known.has(x));
|
|
86
|
+
const extra = v.filter((x) => !known.has(x));
|
|
87
|
+
if (extra.length && q.other) state.other[q.id] = { on: true, text: extra.join(', ') };
|
|
88
|
+
} else if (q.type === 'single' && typeof v === 'string') {
|
|
89
|
+
if ((q.options || []).some((o) => o.value === v)) state.answers[q.id] = v;
|
|
90
|
+
else if (q.other) state.other[q.id] = { on: true, text: v };
|
|
91
|
+
} else {
|
|
92
|
+
state.answers[q.id] = v;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
if (boot.prefill) seedFromPrefill(boot.prefill);
|
|
97
|
+
else for (const q of QS) if (q.default !== undefined) state.answers[q.id] = q.default;
|
|
98
|
+
|
|
99
|
+
function getValue(q) {
|
|
100
|
+
const v = state.answers[q.id];
|
|
101
|
+
const oth = state.other[q.id];
|
|
102
|
+
switch (q.type) {
|
|
103
|
+
case 'single': {
|
|
104
|
+
if (oth && oth.on) {
|
|
105
|
+
const t = (oth.text || '').trim();
|
|
106
|
+
return t || undefined;
|
|
107
|
+
}
|
|
108
|
+
return typeof v === 'string' && v ? v : undefined;
|
|
109
|
+
}
|
|
110
|
+
case 'multi': {
|
|
111
|
+
const arr = Array.isArray(v) ? [...v] : [];
|
|
112
|
+
if (oth && oth.on) {
|
|
113
|
+
const t = (oth.text || '').trim();
|
|
114
|
+
if (t) arr.push(t);
|
|
115
|
+
}
|
|
116
|
+
return arr.length ? arr : undefined;
|
|
117
|
+
}
|
|
118
|
+
case 'yesno':
|
|
119
|
+
return v === 'yes' || v === 'no' ? v : undefined;
|
|
120
|
+
case 'scale':
|
|
121
|
+
return typeof v === 'number' ? v : undefined;
|
|
122
|
+
default: {
|
|
123
|
+
const t = typeof v === 'string' ? v.trim() : '';
|
|
124
|
+
return t || undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function payload() {
|
|
130
|
+
const answers = {};
|
|
131
|
+
const notes = {};
|
|
132
|
+
for (const q of QS) {
|
|
133
|
+
const v = getValue(q);
|
|
134
|
+
if (v !== undefined) answers[q.id] = v;
|
|
135
|
+
const n = typeof state.notes[q.id] === 'string' ? state.notes[q.id].trim() : '';
|
|
136
|
+
if (n) notes[q.id] = n;
|
|
137
|
+
}
|
|
138
|
+
return { answers, comment: (state.comment || '').trim(), notes, annotations: state.annotations };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---------- real-time autosave ----------
|
|
142
|
+
let saveTimer = null;
|
|
143
|
+
let saveSeq = 0;
|
|
144
|
+
let saveEl = null;
|
|
145
|
+
function scheduleSave() {
|
|
146
|
+
if (submitted) return;
|
|
147
|
+
if (saveEl) saveEl.textContent = 'saving…';
|
|
148
|
+
clearTimeout(saveTimer);
|
|
149
|
+
saveTimer = setTimeout(saveDraft, 450);
|
|
150
|
+
}
|
|
151
|
+
async function saveDraft() {
|
|
152
|
+
const seq = ++saveSeq;
|
|
153
|
+
try {
|
|
154
|
+
await fetch('/api/draft', {
|
|
155
|
+
method: 'POST',
|
|
156
|
+
headers: { 'content-type': 'application/json' },
|
|
157
|
+
body: JSON.stringify(payload()),
|
|
158
|
+
});
|
|
159
|
+
if (seq === saveSeq && saveEl && !submitted) saveEl.textContent = 'draft saved ✓';
|
|
160
|
+
} catch {
|
|
161
|
+
if (seq === saveSeq && saveEl && !submitted) saveEl.textContent = 'draft save failed';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------- annotations ----------
|
|
166
|
+
// RelayAnnotate owns the live annotation list; mirror it into state on every
|
|
167
|
+
// change so payload()/autosave/submit carry it exactly like answers.
|
|
168
|
+
const Annotate = typeof window.RelayAnnotate !== 'undefined' ? window.RelayAnnotate : null;
|
|
169
|
+
if (Annotate) {
|
|
170
|
+
Annotate.init({
|
|
171
|
+
initial: state.annotations,
|
|
172
|
+
onChange: (list) => {
|
|
173
|
+
state.annotations = list;
|
|
174
|
+
scheduleSave();
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract.
|
|
180
|
+
function blockCtx(questionId) {
|
|
181
|
+
return {
|
|
182
|
+
theme: effectiveTheme,
|
|
183
|
+
htmlSrc: (blockId) => '/html/b/' + encodeURIComponent(blockId) + '?theme=' + effectiveTheme(),
|
|
184
|
+
questionId: questionId == null ? null : questionId,
|
|
185
|
+
annotate: Annotate,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// Renders blocks async without blocking the page; on rejection (or a missing
|
|
190
|
+
// RelayBlocks) shows a muted "failed to render" card in place.
|
|
191
|
+
function renderBlocks(container, blocks, questionId) {
|
|
192
|
+
if (!Array.isArray(blocks) || !blocks.length) return;
|
|
193
|
+
const target = el('div', { class: 'blocks' });
|
|
194
|
+
container.append(target);
|
|
195
|
+
if (typeof window.RelayBlocks === 'undefined' || !window.RelayBlocks) {
|
|
196
|
+
target.append(el('div', { class: 'blk' }, el('div', { class: 'blk-error' }, 'block failed to render')));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
Promise.resolve()
|
|
200
|
+
.then(() => window.RelayBlocks.render(target, blocks, blockCtx(questionId)))
|
|
201
|
+
.catch(() => {
|
|
202
|
+
target.append(el('div', { class: 'blk' }, el('div', { class: 'blk-error' }, 'block failed to render')));
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ---------- controls ----------
|
|
207
|
+
const cards = {};
|
|
208
|
+
|
|
209
|
+
function clearErr(qid) {
|
|
210
|
+
if (cards[qid]) cards[qid].classList.remove('error');
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function syncOptSel(group) {
|
|
214
|
+
for (const lab of group.querySelectorAll('label.opt')) {
|
|
215
|
+
const input = lab.querySelector('input');
|
|
216
|
+
lab.classList.toggle('sel', input.checked);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function controlSingle(q) {
|
|
221
|
+
const group = el('div');
|
|
222
|
+
const entries = [];
|
|
223
|
+
let otherRadio = null;
|
|
224
|
+
const otherOn = () => Boolean(state.other[q.id] && state.other[q.id].on);
|
|
225
|
+
const syncSingle = () => {
|
|
226
|
+
for (const { input, value } of entries) input.checked = state.answers[q.id] === value && !otherOn();
|
|
227
|
+
if (otherRadio) otherRadio.checked = otherOn();
|
|
228
|
+
syncOptSel(group);
|
|
229
|
+
clearErr(q.id);
|
|
230
|
+
scheduleSave();
|
|
231
|
+
};
|
|
232
|
+
for (const o of q.options) {
|
|
233
|
+
const input = el('input', { type: 'radio', name: q.id });
|
|
234
|
+
entries.push({ input, value: o.value });
|
|
235
|
+
input.checked = state.answers[q.id] === o.value && !otherOn();
|
|
236
|
+
// Click-to-toggle: clicking the selected option unselects it (answers
|
|
237
|
+
// are optional by default). preventDefault keeps `checked` ours to set.
|
|
238
|
+
input.addEventListener('click', (e) => {
|
|
239
|
+
e.preventDefault();
|
|
240
|
+
if (state.answers[q.id] === o.value && !otherOn()) {
|
|
241
|
+
delete state.answers[q.id];
|
|
242
|
+
} else {
|
|
243
|
+
state.answers[q.id] = o.value;
|
|
244
|
+
if (state.other[q.id]) state.other[q.id].on = false;
|
|
245
|
+
}
|
|
246
|
+
// Defer: after a canceled activation the browser restores the
|
|
247
|
+
// pre-click checked state AFTER this handler, clobbering direct sets.
|
|
248
|
+
setTimeout(syncSingle, 0);
|
|
249
|
+
});
|
|
250
|
+
input.addEventListener('change', () => {
|
|
251
|
+
// keyboard path (arrow keys / space) — no click event fires
|
|
252
|
+
if (!input.checked) return;
|
|
253
|
+
state.answers[q.id] = o.value;
|
|
254
|
+
if (state.other[q.id]) state.other[q.id].on = false;
|
|
255
|
+
syncSingle();
|
|
256
|
+
});
|
|
257
|
+
group.append(
|
|
258
|
+
el('label', { class: 'opt' + (input.checked ? ' sel' : '') },
|
|
259
|
+
input,
|
|
260
|
+
el('div', {},
|
|
261
|
+
el('div', { class: 'ol' }, o.label),
|
|
262
|
+
o.description ? el('div', { class: 'od' }, o.description) : null
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
if (q.other) {
|
|
268
|
+
otherRadio = el('input', { type: 'radio', name: q.id });
|
|
269
|
+
const text = el('input', { type: 'text', placeholder: 'your own answer…' });
|
|
270
|
+
text.value = (state.other[q.id] && state.other[q.id].text) || '';
|
|
271
|
+
otherRadio.checked = otherOn();
|
|
272
|
+
const ensureOther = () => state.other[q.id] || (state.other[q.id] = { on: false, text: text.value });
|
|
273
|
+
otherRadio.addEventListener('click', (e) => {
|
|
274
|
+
e.preventDefault();
|
|
275
|
+
const oth = ensureOther();
|
|
276
|
+
oth.on = !oth.on;
|
|
277
|
+
if (oth.on) delete state.answers[q.id];
|
|
278
|
+
setTimeout(syncSingle, 0);
|
|
279
|
+
});
|
|
280
|
+
otherRadio.addEventListener('change', () => {
|
|
281
|
+
if (!otherRadio.checked) return;
|
|
282
|
+
const oth = ensureOther();
|
|
283
|
+
oth.on = true;
|
|
284
|
+
delete state.answers[q.id];
|
|
285
|
+
syncSingle();
|
|
286
|
+
});
|
|
287
|
+
text.addEventListener('input', () => {
|
|
288
|
+
const oth = ensureOther();
|
|
289
|
+
oth.text = text.value;
|
|
290
|
+
if (!oth.on) {
|
|
291
|
+
oth.on = true;
|
|
292
|
+
delete state.answers[q.id];
|
|
293
|
+
}
|
|
294
|
+
syncSingle();
|
|
295
|
+
});
|
|
296
|
+
group.append(
|
|
297
|
+
el('label', { class: 'opt' + (otherRadio.checked ? ' sel' : '') },
|
|
298
|
+
otherRadio,
|
|
299
|
+
el('div', { style: 'flex:1' }, el('div', { class: 'ol' }, 'Other'), el('div', { class: 'otherbox' }, text))
|
|
300
|
+
)
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
return group;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function controlMulti(q) {
|
|
307
|
+
const group = el('div');
|
|
308
|
+
const selected = new Set(Array.isArray(state.answers[q.id]) ? state.answers[q.id] : []);
|
|
309
|
+
const readChecked = () => {
|
|
310
|
+
state.answers[q.id] = [...group.querySelectorAll('input[data-val]')]
|
|
311
|
+
.filter((i) => i.checked)
|
|
312
|
+
.map((i) => i.dataset.val);
|
|
313
|
+
syncOptSel(group);
|
|
314
|
+
clearErr(q.id);
|
|
315
|
+
scheduleSave();
|
|
316
|
+
};
|
|
317
|
+
for (const o of q.options) {
|
|
318
|
+
const input = el('input', { type: 'checkbox', 'data-val': o.value });
|
|
319
|
+
input.checked = selected.has(o.value);
|
|
320
|
+
input.addEventListener('change', readChecked);
|
|
321
|
+
group.append(
|
|
322
|
+
el('label', { class: 'opt' + (input.checked ? ' sel' : '') },
|
|
323
|
+
input,
|
|
324
|
+
el('div', {},
|
|
325
|
+
el('div', { class: 'ol' }, o.label),
|
|
326
|
+
o.description ? el('div', { class: 'od' }, o.description) : null
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
if (q.other) {
|
|
332
|
+
const oth = state.other[q.id];
|
|
333
|
+
const box = el('input', { type: 'checkbox' });
|
|
334
|
+
const text = el('input', { type: 'text', placeholder: 'your own answer…' });
|
|
335
|
+
box.checked = Boolean(oth && oth.on);
|
|
336
|
+
text.value = (oth && oth.text) || '';
|
|
337
|
+
const sync = () => {
|
|
338
|
+
state.other[q.id] = { on: box.checked, text: text.value };
|
|
339
|
+
syncOptSel(group);
|
|
340
|
+
clearErr(q.id);
|
|
341
|
+
scheduleSave();
|
|
342
|
+
};
|
|
343
|
+
box.addEventListener('change', sync);
|
|
344
|
+
text.addEventListener('input', () => {
|
|
345
|
+
if (!box.checked) box.checked = true;
|
|
346
|
+
sync();
|
|
347
|
+
});
|
|
348
|
+
group.append(
|
|
349
|
+
el('label', { class: 'opt' + (box.checked ? ' sel' : '') },
|
|
350
|
+
box,
|
|
351
|
+
el('div', { style: 'flex:1' }, el('div', { class: 'ol' }, 'Other'), el('div', { class: 'otherbox' }, text))
|
|
352
|
+
)
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
return group;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function segButtons(q, values, labels) {
|
|
359
|
+
const seg = el('div', { class: q.type === 'scale' ? 'scale' : 'seg' });
|
|
360
|
+
const buttons = [];
|
|
361
|
+
values.forEach((v, i) => {
|
|
362
|
+
const b = el('button', { type: 'button' }, labels[i]);
|
|
363
|
+
if (state.answers[q.id] === v) b.classList.add('sel');
|
|
364
|
+
b.addEventListener('click', () => {
|
|
365
|
+
// click again to unselect (everything is optional by default)
|
|
366
|
+
if (state.answers[q.id] === v) delete state.answers[q.id];
|
|
367
|
+
else state.answers[q.id] = v;
|
|
368
|
+
for (const x of buttons) x.classList.toggle('sel', state.answers[q.id] === values[buttons.indexOf(x)]);
|
|
369
|
+
clearErr(q.id);
|
|
370
|
+
scheduleSave();
|
|
371
|
+
});
|
|
372
|
+
buttons.push(b);
|
|
373
|
+
seg.append(b);
|
|
374
|
+
});
|
|
375
|
+
return seg;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function controlScale(q) {
|
|
379
|
+
const values = [];
|
|
380
|
+
for (let i = q.min; i <= q.max; i++) values.push(i);
|
|
381
|
+
const seg = segButtons(q, values, values.map(String));
|
|
382
|
+
const row = el('div', { class: 'scale' });
|
|
383
|
+
if (q.minLabel) row.append(el('span', { class: 'slabel' }, q.minLabel));
|
|
384
|
+
row.append(...seg.children);
|
|
385
|
+
if (q.maxLabel) row.append(el('span', { class: 'slabel' }, q.maxLabel));
|
|
386
|
+
return row;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function controlText(q, multiline) {
|
|
390
|
+
const input = multiline
|
|
391
|
+
? el('textarea', { placeholder: q.placeholder || '' })
|
|
392
|
+
: el('input', { type: 'text', placeholder: q.placeholder || '' });
|
|
393
|
+
input.value = typeof state.answers[q.id] === 'string' ? state.answers[q.id] : '';
|
|
394
|
+
input.addEventListener('input', () => {
|
|
395
|
+
state.answers[q.id] = input.value;
|
|
396
|
+
clearErr(q.id);
|
|
397
|
+
scheduleSave();
|
|
398
|
+
});
|
|
399
|
+
return input;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ---------- render ----------
|
|
403
|
+
themeBtn = el('button', { class: 'theme-btn', type: 'button' }, '');
|
|
404
|
+
themeBtn.addEventListener('click', () => {
|
|
405
|
+
theme = THEMES[(THEMES.indexOf(theme) + 1) % THEMES.length];
|
|
406
|
+
localStorage.setItem(THEME_KEY, theme);
|
|
407
|
+
fetch('/api/pref', {
|
|
408
|
+
method: 'POST',
|
|
409
|
+
headers: { 'content-type': 'application/json' },
|
|
410
|
+
body: JSON.stringify({ theme }),
|
|
411
|
+
}).catch(() => {});
|
|
412
|
+
applyTheme();
|
|
413
|
+
});
|
|
414
|
+
applyTheme();
|
|
415
|
+
|
|
416
|
+
app.append(el('header', { class: 'qb-header' }, el('h1', {}, spec.title), themeBtn));
|
|
417
|
+
if (spec.intro) {
|
|
418
|
+
const intro = el('p', { class: 'intro' }, spec.intro);
|
|
419
|
+
app.append(intro);
|
|
420
|
+
Annotate?.enableTextSelection(intro, { blockId: null, questionId: null });
|
|
421
|
+
}
|
|
422
|
+
// Board-level blocks render above the questions (async; never blocks submit).
|
|
423
|
+
renderBlocks(app, spec.blocks || [], null);
|
|
424
|
+
|
|
425
|
+
QS.forEach((q, idx) => {
|
|
426
|
+
const required = q.required || !spec.allowPartial;
|
|
427
|
+
const card = el('div', { class: 'card' },
|
|
428
|
+
el('div', { class: 'qnum' }, `Q${idx + 1}`),
|
|
429
|
+
el('p', { class: 'qlabel' }, q.label, required ? el('span', { class: 'req' }, ' *') : null),
|
|
430
|
+
q.description ? el('p', { class: 'qdesc' }, q.description) : null
|
|
431
|
+
);
|
|
432
|
+
// Per-question blocks render between the description and the control.
|
|
433
|
+
renderBlocks(card, q.blocks || [], q.id);
|
|
434
|
+
const control = el('div', { class: 'control' });
|
|
435
|
+
if (q.type === 'single') control.append(controlSingle(q));
|
|
436
|
+
else if (q.type === 'multi') control.append(controlMulti(q));
|
|
437
|
+
else if (q.type === 'yesno') control.append(segButtons(q, ['yes', 'no'], ['Yes', 'No']));
|
|
438
|
+
else if (q.type === 'scale') control.append(controlScale(q));
|
|
439
|
+
else control.append(controlText(q, q.type === 'textarea'));
|
|
440
|
+
card.append(control);
|
|
441
|
+
if (q.note) {
|
|
442
|
+
const noteInput = el('input', { type: 'text', class: 'qnote', placeholder: 'optional note about this answer…' });
|
|
443
|
+
noteInput.value = typeof state.notes[q.id] === 'string' ? state.notes[q.id] : '';
|
|
444
|
+
noteInput.addEventListener('input', () => {
|
|
445
|
+
state.notes[q.id] = noteInput.value;
|
|
446
|
+
scheduleSave();
|
|
447
|
+
});
|
|
448
|
+
card.append(el('div', { class: 'qnotewrap' }, noteInput));
|
|
449
|
+
}
|
|
450
|
+
card.append(el('p', { class: 'errmsg' }, 'This question is required.'));
|
|
451
|
+
cards[q.id] = card;
|
|
452
|
+
app.append(card);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
if (spec.note) {
|
|
456
|
+
const note = el('textarea', { placeholder: 'optional note back to the agent…' });
|
|
457
|
+
note.value = state.comment || '';
|
|
458
|
+
note.addEventListener('input', () => {
|
|
459
|
+
state.comment = note.value;
|
|
460
|
+
scheduleSave();
|
|
461
|
+
});
|
|
462
|
+
app.append(el('div', { class: 'card' },
|
|
463
|
+
el('p', { class: 'qlabel' }, 'Anything else?'),
|
|
464
|
+
el('p', { class: 'qdesc' }, 'Free-text note returned to the agent along with your answers.'),
|
|
465
|
+
el('div', { class: 'control' }, note)
|
|
466
|
+
));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// Annotations summary (editable list) sits directly above the submit bar.
|
|
470
|
+
if (Annotate) {
|
|
471
|
+
const summary = el('div', { class: 'ann-summary-wrap' });
|
|
472
|
+
app.append(summary);
|
|
473
|
+
Annotate.renderSummary(summary);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const submitBtn = el('button', { class: 'submit', type: 'button' }, spec.submitLabel);
|
|
477
|
+
saveEl = el('span', { class: 'savestate' }, '');
|
|
478
|
+
const hint = el('span', { class: 'hint' },
|
|
479
|
+
QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
|
|
480
|
+
app.append(el('div', { class: 'submitbar' }, submitBtn, hint, saveEl));
|
|
481
|
+
app.append(el('footer', { class: 'qb-footer' }, `quest-board · ${boot.boardId}`));
|
|
482
|
+
|
|
483
|
+
// ---------- validation & submit ----------
|
|
484
|
+
function validate() {
|
|
485
|
+
let firstBad = null;
|
|
486
|
+
for (const q of QS) {
|
|
487
|
+
const required = q.required || !spec.allowPartial;
|
|
488
|
+
const bad = required && getValue(q) === undefined;
|
|
489
|
+
cards[q.id].classList.toggle('error', bad);
|
|
490
|
+
if (bad && !firstBad) firstBad = cards[q.id];
|
|
491
|
+
}
|
|
492
|
+
if (firstBad) firstBad.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
493
|
+
return !firstBad;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function showDone(closing) {
|
|
497
|
+
stopHeartbeat();
|
|
498
|
+
app.replaceChildren(
|
|
499
|
+
el('div', { class: 'done' },
|
|
500
|
+
el('div', { class: 'mark' }, '✓'),
|
|
501
|
+
el('h2', {}, QS.length ? 'Submitted' : 'Acknowledged'),
|
|
502
|
+
el('p', { id: 'done-note' }, closing ? 'Handing back to your agent — this tab will close itself…' : 'Handed back to your agent. You can close this tab.')
|
|
503
|
+
)
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
submitBtn.addEventListener('click', async () => {
|
|
508
|
+
if (!validate()) return;
|
|
509
|
+
submitBtn.disabled = true;
|
|
510
|
+
submitBtn.textContent = 'Submitting…';
|
|
511
|
+
clearTimeout(saveTimer);
|
|
512
|
+
try {
|
|
513
|
+
const res = await fetch('/api/submit', {
|
|
514
|
+
method: 'POST',
|
|
515
|
+
headers: { 'content-type': 'application/json' },
|
|
516
|
+
body: JSON.stringify(payload()),
|
|
517
|
+
});
|
|
518
|
+
if (!res.ok) throw new Error('submit rejected');
|
|
519
|
+
submitted = true;
|
|
520
|
+
showDone(spec.autoClose);
|
|
521
|
+
if (spec.autoClose) {
|
|
522
|
+
setTimeout(() => {
|
|
523
|
+
window.close();
|
|
524
|
+
// window.close() is best-effort (browsers may block it for
|
|
525
|
+
// user-opened tabs) — fall back to the "close this tab" note.
|
|
526
|
+
setTimeout(() => {
|
|
527
|
+
const note = document.getElementById('done-note');
|
|
528
|
+
if (note) note.textContent = 'Handed back to your agent. You can close this tab.';
|
|
529
|
+
}, 400);
|
|
530
|
+
}, 700);
|
|
531
|
+
}
|
|
532
|
+
} catch {
|
|
533
|
+
submitBtn.disabled = false;
|
|
534
|
+
submitBtn.textContent = spec.submitLabel;
|
|
535
|
+
banner.textContent = 'Submit failed — the board server may have stopped.';
|
|
536
|
+
banner.style.display = 'block';
|
|
537
|
+
setTimeout(() => { if (!submitted) banner.style.display = 'none'; }, 4000);
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// ---------- prefilled load: jump past what's already answered ----------
|
|
542
|
+
// On reload/reopen with saved answers, scroll to the first unanswered
|
|
543
|
+
// question so the user doesn't re-scan questions they already did.
|
|
544
|
+
if (boot.prefill && QS.length) {
|
|
545
|
+
const answered = QS.filter((q) => getValue(q) !== undefined).length;
|
|
546
|
+
const firstOpen = QS.find((q) => getValue(q) === undefined);
|
|
547
|
+
if (answered > 0 && firstOpen) {
|
|
548
|
+
setTimeout(() => {
|
|
549
|
+
cards[firstOpen.id].scrollIntoView({ behavior: 'smooth', block: 'center' });
|
|
550
|
+
}, 350);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// ---------- iframe annotate bridge ----------
|
|
555
|
+
// Custom-HTML iframes (via /kit.js relayKit.commentable) post
|
|
556
|
+
// {relay:'annotate-request', label, detail?}. Match the source to the
|
|
557
|
+
// iframe, read its block/question ids, and open the annotate popover anchored
|
|
558
|
+
// to that iframe.
|
|
559
|
+
if (Annotate) {
|
|
560
|
+
window.addEventListener('message', (e) => {
|
|
561
|
+
const msg = e.data;
|
|
562
|
+
if (!msg || typeof msg !== 'object' || msg.relay !== 'annotate-request') return;
|
|
563
|
+
let frame = null;
|
|
564
|
+
for (const f of document.querySelectorAll('iframe.viz')) {
|
|
565
|
+
if (f.contentWindow === e.source) { frame = f; break; }
|
|
566
|
+
}
|
|
567
|
+
if (!frame) return;
|
|
568
|
+
const blockId = frame.getAttribute('data-block-id') || null;
|
|
569
|
+
const questionId = frame.getAttribute('data-question-id') || null;
|
|
570
|
+
Annotate.openExternal(
|
|
571
|
+
{
|
|
572
|
+
blockId,
|
|
573
|
+
questionId: questionId || null,
|
|
574
|
+
target: {
|
|
575
|
+
kind: 'html-element',
|
|
576
|
+
label: typeof msg.label === 'string' ? msg.label : 'Element',
|
|
577
|
+
detail: typeof msg.detail === 'string' ? msg.detail : undefined,
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
frame
|
|
581
|
+
);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---------- heartbeat ----------
|
|
586
|
+
let misses = 0;
|
|
587
|
+
let hb = setInterval(async () => {
|
|
588
|
+
try {
|
|
589
|
+
const r = await fetch('/api/status', { cache: 'no-store' });
|
|
590
|
+
if (!r.ok) throw new Error('bad status');
|
|
591
|
+
misses = 0;
|
|
592
|
+
} catch {
|
|
593
|
+
if (++misses >= 2 && !submitted) {
|
|
594
|
+
banner.textContent = 'This board is closed — the server has stopped. Answers up to your last edit were autosaved.';
|
|
595
|
+
banner.style.display = 'block';
|
|
596
|
+
submitBtn.disabled = true;
|
|
597
|
+
stopHeartbeat();
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
}, 3000);
|
|
601
|
+
function stopHeartbeat() {
|
|
602
|
+
if (hb) clearInterval(hb);
|
|
603
|
+
hb = null;
|
|
604
|
+
}
|
|
605
|
+
})();
|