@khanglvm/relay 0.12.2 → 0.13.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/README.md +1 -0
- package/docs/AGENT.md +75 -6
- package/package.json +1 -1
- package/skills/relay/SKILL.md +180 -4
- package/src/cli.js +98 -17
- package/src/mcp-ui/board.js +213 -10
- package/src/server.js +8 -3
- package/src/spec.js +272 -47
- package/src/ui/annotate.css +75 -10
- package/src/ui/annotate.js +90 -12
- package/src/ui/app.js +232 -8
- package/src/ui/blocks.css +164 -4
- package/src/ui/blocks.js +430 -33
- package/src/ui/style.css +90 -0
package/src/ui/annotate.js
CHANGED
|
@@ -46,11 +46,15 @@
|
|
|
46
46
|
return 'Diagram · ' + truncate(t.text || 'element', 50);
|
|
47
47
|
case 'image':
|
|
48
48
|
return truncate(t.label || 'Image', 50);
|
|
49
|
+
case 'image-point':
|
|
50
|
+
return (t.label ? truncate(t.label, 30) + ' · ' : 'Image · ') + 'pin ' + Math.round((t.x || 0) * 100) + '%, ' + Math.round((t.y || 0) * 100) + '%';
|
|
49
51
|
case 'table-cell': {
|
|
50
52
|
let s = `Table · row ${(Number(t.row) || 0) + 1} · ${t.col}`;
|
|
51
53
|
if (t.value !== undefined && t.value !== null && t.value !== '') s += ` — “${truncate(t.value, 30)}”`;
|
|
52
54
|
return s;
|
|
53
55
|
}
|
|
56
|
+
case 'code-line':
|
|
57
|
+
return 'Line ' + t.line + (t.file ? ' · ' + truncate(t.file, 40) : '');
|
|
54
58
|
case 'text':
|
|
55
59
|
return `“${truncate(t.quote || '', 60)}”`;
|
|
56
60
|
case 'html-element':
|
|
@@ -494,13 +498,60 @@
|
|
|
494
498
|
dom.pop.replaceChildren();
|
|
495
499
|
}
|
|
496
500
|
|
|
501
|
+
// ---------- delete a comment (with confirm) ----------
|
|
502
|
+
// The × that used to delete a comment read as "close the popup". Delete is now
|
|
503
|
+
// a distinct trash button that opens a confirm modal; "Don't ask again"
|
|
504
|
+
// (default OFF) suppresses future confirms for the session (persisted best-effort).
|
|
505
|
+
const ICON_TRASH =
|
|
506
|
+
'<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
507
|
+
'<path d="M2.5 4h11M6 4V2.5h4V4M5 4l.5 9h5l.5-9"/></svg>';
|
|
508
|
+
const ICON_PENCIL =
|
|
509
|
+
'<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' +
|
|
510
|
+
'<path d="M11.5 2.5l2 2L6 12l-2.5.5.5-2.5 7.5-7.5z"/></svg>';
|
|
511
|
+
const DEL_SKIP_KEY = 'relay-ann-del-skip';
|
|
512
|
+
let delConfirmSkip = false;
|
|
513
|
+
try { delConfirmSkip = localStorage.getItem(DEL_SKIP_KEY) === '1'; } catch { /* sandbox: no storage */ }
|
|
514
|
+
|
|
515
|
+
function requestDelete(id, after) {
|
|
516
|
+
const done = () => { removeAnnotation(id); if (after) after(); };
|
|
517
|
+
if (delConfirmSkip) { done(); return; }
|
|
518
|
+
showDeleteConfirm(done);
|
|
519
|
+
}
|
|
520
|
+
function showDeleteConfirm(onConfirm) {
|
|
521
|
+
const scrim = el('div', { class: 'ann-confirm-scrim' });
|
|
522
|
+
const skip = el('input', { type: 'checkbox' });
|
|
523
|
+
const cancel = el('button', { class: 'ann-confirm-cancel', type: 'button' }, 'Cancel');
|
|
524
|
+
const del = el('button', { class: 'ann-confirm-del', type: 'button' }, 'Delete');
|
|
525
|
+
const close = () => { scrim.remove(); document.removeEventListener('keydown', onKey, true); };
|
|
526
|
+
const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); close(); } };
|
|
527
|
+
cancel.addEventListener('click', close);
|
|
528
|
+
scrim.addEventListener('mousedown', (e) => { if (e.target === scrim) close(); });
|
|
529
|
+
del.addEventListener('click', () => {
|
|
530
|
+
if (skip.checked) { delConfirmSkip = true; try { localStorage.setItem(DEL_SKIP_KEY, '1'); } catch { /* sandbox */ } }
|
|
531
|
+
close();
|
|
532
|
+
onConfirm();
|
|
533
|
+
});
|
|
534
|
+
scrim.append(el('div', { class: 'ann-confirm', role: 'alertdialog', 'aria-label': 'Delete comment' },
|
|
535
|
+
el('div', { class: 'ann-confirm-title' }, 'Delete this comment?'),
|
|
536
|
+
el('div', { class: 'ann-confirm-msg' }, 'This removes the comment and its replies. It can’t be undone.'),
|
|
537
|
+
el('label', { class: 'ann-confirm-skip' }, skip, el('span', {}, 'Don’t ask again')),
|
|
538
|
+
el('div', { class: 'ann-confirm-actions' }, cancel, del)
|
|
539
|
+
));
|
|
540
|
+
document.body.append(scrim);
|
|
541
|
+
document.addEventListener('keydown', onKey, true);
|
|
542
|
+
setTimeout(() => { try { cancel.focus(); } catch { /* ignore */ } }, 0);
|
|
543
|
+
}
|
|
544
|
+
|
|
497
545
|
function openPopover(info, anchorEl) {
|
|
498
546
|
if (disabled) return;
|
|
499
547
|
ensureDom();
|
|
500
548
|
closePopover();
|
|
501
549
|
hidePin();
|
|
502
550
|
const pop = dom.pop;
|
|
503
|
-
|
|
551
|
+
// Header: target label + an explicit CLOSE button, so × always means "close".
|
|
552
|
+
const popClose = el('button', { class: 'ann-pop-close', type: 'button', title: 'Close', 'aria-label': 'Close' }, '×');
|
|
553
|
+
popClose.addEventListener('click', closePopover);
|
|
554
|
+
pop.replaceChildren(el('div', { class: 'ann-pop-head' }, el('div', { class: 'ann-pop-label' }, humanize(info.target)), popClose));
|
|
504
555
|
|
|
505
556
|
// Existing comments on this exact target, rendered as threads: author
|
|
506
557
|
// chip + time + delete, the comment text, its replies indented below,
|
|
@@ -510,14 +561,33 @@
|
|
|
510
561
|
const renderExisting = () => {
|
|
511
562
|
existingWrap.replaceChildren();
|
|
512
563
|
for (const a of matching(info)) {
|
|
513
|
-
const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' }
|
|
514
|
-
del.
|
|
515
|
-
|
|
516
|
-
|
|
564
|
+
const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' });
|
|
565
|
+
del.innerHTML = ICON_TRASH;
|
|
566
|
+
del.addEventListener('click', () => requestDelete(a.id, renderExisting));
|
|
567
|
+
const edit = el('button', { class: 'ann-edit', type: 'button', title: 'Edit comment', 'aria-label': 'Edit comment' });
|
|
568
|
+
edit.innerHTML = ICON_PENCIL;
|
|
569
|
+
const textEl = el('div', { class: 'ann-pop-text' }, a.text);
|
|
570
|
+
// Inline edit: swap the text for a textarea + Save/Cancel; Save commits
|
|
571
|
+
// via editAnnotation, then re-renders. Only the user's own comment is editable.
|
|
572
|
+
edit.addEventListener('click', () => {
|
|
573
|
+
const ta = el('textarea', { class: 'ann-ta ann-edit-ta', rows: '3' });
|
|
574
|
+
ta.value = a.text;
|
|
575
|
+
const save = el('button', { class: 'ann-save', type: 'button' }, 'Save');
|
|
576
|
+
const cancel = el('button', { class: 'ann-cancel', type: 'button' }, 'Cancel');
|
|
577
|
+
save.addEventListener('click', () => { editAnnotation(a.id, ta.value); renderExisting(); });
|
|
578
|
+
cancel.addEventListener('click', renderExisting);
|
|
579
|
+
ta.addEventListener('keydown', (e) => {
|
|
580
|
+
if (e.key === 'Escape') { e.stopPropagation(); renderExisting(); }
|
|
581
|
+
else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); editAnnotation(a.id, ta.value); renderExisting(); }
|
|
582
|
+
});
|
|
583
|
+
textEl.replaceWith(el('div', { class: 'ann-edit-form' }, ta, el('div', { class: 'ann-pop-actions' }, save, cancel)));
|
|
584
|
+
ta.focus();
|
|
517
585
|
});
|
|
586
|
+
// agent replies aren't editable here — only the top-level user comment
|
|
587
|
+
const actions = el('div', { class: 'ann-thread-actions' }, a.author === 'user' ? edit : null, del);
|
|
518
588
|
const thread = el('div', { class: 'ann-thread' },
|
|
519
|
-
el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt),
|
|
520
|
-
|
|
589
|
+
el('div', { class: 'ann-thread-head' }, chip(a.author), timeEl(a.createdAt), actions),
|
|
590
|
+
textEl
|
|
521
591
|
);
|
|
522
592
|
if (Array.isArray(a.replies) && a.replies.length) {
|
|
523
593
|
const list = el('div', { class: 'ann-replies' });
|
|
@@ -629,6 +699,16 @@
|
|
|
629
699
|
changed();
|
|
630
700
|
}
|
|
631
701
|
|
|
702
|
+
// Edit a top-level comment's text in place (author edits their own comment).
|
|
703
|
+
function editAnnotation(id, text) {
|
|
704
|
+
const a = annotations.find((x) => x.id === id);
|
|
705
|
+
if (!a) return;
|
|
706
|
+
const t = String(text).slice(0, 5000).trim();
|
|
707
|
+
if (!t) return;
|
|
708
|
+
a.text = t;
|
|
709
|
+
changed();
|
|
710
|
+
}
|
|
711
|
+
|
|
632
712
|
function removeAnnotation(id) {
|
|
633
713
|
const i = annotations.findIndex((a) => a.id === id);
|
|
634
714
|
if (i < 0) return;
|
|
@@ -719,11 +799,9 @@
|
|
|
719
799
|
// The rail has its own header chrome; standalone summaries get a heading.
|
|
720
800
|
if (!isRail) target.append(el('h3', { class: 'ann-sum-head' }, `Comments (${annotations.length})`));
|
|
721
801
|
for (const a of annotations) {
|
|
722
|
-
const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' }
|
|
723
|
-
del.
|
|
724
|
-
|
|
725
|
-
removeAnnotation(a.id);
|
|
726
|
-
});
|
|
802
|
+
const del = el('button', { class: 'ann-del', type: 'button', title: 'Delete comment', 'aria-label': 'Delete comment' });
|
|
803
|
+
del.innerHTML = ICON_TRASH;
|
|
804
|
+
del.addEventListener('click', (e) => { e.stopPropagation(); requestDelete(a.id); });
|
|
727
805
|
// Thread meta: reply count, plus an "agent" chip when the latest entry
|
|
728
806
|
// in the thread (last reply, or the comment itself) is agent-authored.
|
|
729
807
|
const replyCount = Array.isArray(a.replies) ? a.replies.length : 0;
|
package/src/ui/app.js
CHANGED
|
@@ -212,8 +212,41 @@
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
|
+
// Coerce any prior rank value (draft/default) into a complete, deduped
|
|
216
|
+
// permutation of the option values: keep the known ones in their given order,
|
|
217
|
+
// then append any options the prior list missed (in authored order).
|
|
218
|
+
function seedRankOrder(q, prior) {
|
|
219
|
+
const opts = (q.options || []).map((o) => o.value);
|
|
220
|
+
const arr = Array.isArray(prior) ? prior : [];
|
|
221
|
+
const order = arr.filter((v, i) => opts.includes(v) && arr.indexOf(v) === i);
|
|
222
|
+
for (const v of opts) if (!order.includes(v)) order.push(v);
|
|
223
|
+
return order;
|
|
224
|
+
}
|
|
225
|
+
// Allocate: a complete map {optionValue: number≥0}, seeded from a prior/default.
|
|
226
|
+
function seedAllocate(q, prior) {
|
|
227
|
+
const m = prior && typeof prior === 'object' && !Array.isArray(prior) ? prior : {};
|
|
228
|
+
const out = {};
|
|
229
|
+
for (const o of q.options || []) out[o.value] = Math.max(0, Number(m[o.value]) || 0);
|
|
230
|
+
return out;
|
|
231
|
+
}
|
|
232
|
+
// Checklist: keep only {knownOption: knownStatus} entries from a prior/default.
|
|
233
|
+
function seedChecklist(q, prior) {
|
|
234
|
+
const m = prior && typeof prior === 'object' && !Array.isArray(prior) ? prior : {};
|
|
235
|
+
const optVals = new Set((q.options || []).map((o) => o.value));
|
|
236
|
+
const stVals = new Set((q.statuses || []).map((s) => s.value));
|
|
237
|
+
const out = {};
|
|
238
|
+
for (const k of Object.keys(m)) if (optVals.has(k) && stVals.has(m[k])) out[k] = m[k];
|
|
239
|
+
return out;
|
|
240
|
+
}
|
|
241
|
+
|
|
215
242
|
if (initialPrefill) seedFromPrefill(initialPrefill);
|
|
216
243
|
else for (const q of QS) if (q.default !== undefined) state.answers[q.id] = q.default;
|
|
244
|
+
// Rank questions always carry a full, valid permutation of their option values
|
|
245
|
+
// (from a draft/default if present, else the authored order), so a never-touched
|
|
246
|
+
// rank still returns a meaningful ordering.
|
|
247
|
+
for (const q of QS) if (q.type === 'rank') state.answers[q.id] = seedRankOrder(q, state.answers[q.id]);
|
|
248
|
+
for (const q of QS) if (q.type === 'allocate') state.answers[q.id] = seedAllocate(q, state.answers[q.id]);
|
|
249
|
+
for (const q of QS) if (q.type === 'checklist') state.answers[q.id] = seedChecklist(q, state.answers[q.id]);
|
|
217
250
|
// If the local mirror was newer than the server (or the server had nothing),
|
|
218
251
|
// the in-memory state now holds input the server hasn't seen — flush it once
|
|
219
252
|
// the rest of the app is wired (see the post-init flush near the heartbeat).
|
|
@@ -241,6 +274,18 @@
|
|
|
241
274
|
return v === 'yes' || v === 'no' ? v : undefined;
|
|
242
275
|
case 'scale':
|
|
243
276
|
return typeof v === 'number' ? v : undefined;
|
|
277
|
+
case 'rank':
|
|
278
|
+
// always a full, valid permutation (seeded below) → always answered
|
|
279
|
+
return Array.isArray(v) && v.length ? [...v] : undefined;
|
|
280
|
+
case 'checklist': {
|
|
281
|
+
const m = v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
282
|
+
return Object.keys(m).length ? { ...m } : undefined;
|
|
283
|
+
}
|
|
284
|
+
case 'allocate': {
|
|
285
|
+
const m = v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
286
|
+
const sum = Object.values(m).reduce((a, n) => a + (Number(n) || 0), 0);
|
|
287
|
+
return sum > 0 ? { ...m } : undefined;
|
|
288
|
+
}
|
|
244
289
|
default: {
|
|
245
290
|
const t = typeof v === 'string' ? v.trim() : '';
|
|
246
291
|
return t || undefined;
|
|
@@ -721,6 +766,144 @@
|
|
|
721
766
|
return row;
|
|
722
767
|
}
|
|
723
768
|
|
|
769
|
+
// Reorderable priority list. The user drags an item or uses ↑/↓; the answer is
|
|
770
|
+
// the ordered array of option values (highest priority first). state.answers[q.id]
|
|
771
|
+
// is kept a full, valid permutation by seedRankOrder so it's always submittable.
|
|
772
|
+
function controlRank(q) {
|
|
773
|
+
const wrap = el('div', { class: 'rank' });
|
|
774
|
+
const byVal = new Map(q.options.map((o) => [o.value, o]));
|
|
775
|
+
let dragFrom = null;
|
|
776
|
+
function move(from, to) {
|
|
777
|
+
const arr = state.answers[q.id];
|
|
778
|
+
if (!Array.isArray(arr) || to < 0 || to >= arr.length || from === to) return;
|
|
779
|
+
const [x] = arr.splice(from, 1);
|
|
780
|
+
arr.splice(to, 0, x);
|
|
781
|
+
paint();
|
|
782
|
+
clearErr(q.id);
|
|
783
|
+
scheduleSave();
|
|
784
|
+
}
|
|
785
|
+
function paint() {
|
|
786
|
+
wrap.replaceChildren();
|
|
787
|
+
const order = state.answers[q.id] || [];
|
|
788
|
+
order.forEach((val, i) => {
|
|
789
|
+
const o = byVal.get(val);
|
|
790
|
+
if (!o) return;
|
|
791
|
+
const up = el('button', { type: 'button', class: 'rank-btn', title: 'Move up', 'aria-label': `Move "${o.label}" up` }, '↑');
|
|
792
|
+
const down = el('button', { type: 'button', class: 'rank-btn', title: 'Move down', 'aria-label': `Move "${o.label}" down` }, '↓');
|
|
793
|
+
up.disabled = i === 0;
|
|
794
|
+
down.disabled = i === order.length - 1;
|
|
795
|
+
up.addEventListener('click', () => move(i, i - 1));
|
|
796
|
+
down.addEventListener('click', () => move(i, i + 1));
|
|
797
|
+
const item = el('div', { class: 'rank-item', draggable: 'true' },
|
|
798
|
+
el('span', { class: 'rank-badge', 'aria-hidden': 'true' }, String(i + 1)),
|
|
799
|
+
el('div', { class: 'rank-body' },
|
|
800
|
+
el('div', { class: 'ol' }, o.label),
|
|
801
|
+
o.description ? el('div', { class: 'od' }, o.description) : null),
|
|
802
|
+
el('div', { class: 'rank-ctrls' }, up, down)
|
|
803
|
+
);
|
|
804
|
+
item.addEventListener('dragstart', (e) => {
|
|
805
|
+
dragFrom = i; item.classList.add('dragging');
|
|
806
|
+
try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); } catch (_) {}
|
|
807
|
+
});
|
|
808
|
+
item.addEventListener('dragend', () => { dragFrom = null; item.classList.remove('dragging'); });
|
|
809
|
+
item.addEventListener('dragover', (e) => { if (dragFrom !== null) { e.preventDefault(); item.classList.add('drop-into'); } });
|
|
810
|
+
item.addEventListener('dragleave', () => item.classList.remove('drop-into'));
|
|
811
|
+
item.addEventListener('drop', (e) => {
|
|
812
|
+
e.preventDefault();
|
|
813
|
+
const from = dragFrom; dragFrom = null;
|
|
814
|
+
if (from !== null && from !== i) move(from, i);
|
|
815
|
+
});
|
|
816
|
+
wrap.append(item);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
paint();
|
|
820
|
+
return wrap;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// Checklist: each option is a row with a segmented status control (Pass/Fail/
|
|
824
|
+
// N/A by default). Answer is a map {optionValue: statusValue} for set rows.
|
|
825
|
+
function controlChecklist(q) {
|
|
826
|
+
const wrap = el('div', { class: 'checklist' });
|
|
827
|
+
const cur = () => (state.answers[q.id] && typeof state.answers[q.id] === 'object' && !Array.isArray(state.answers[q.id])
|
|
828
|
+
? state.answers[q.id]
|
|
829
|
+
: (state.answers[q.id] = {}));
|
|
830
|
+
for (const o of q.options) {
|
|
831
|
+
const seg = el('div', { class: 'chk-seg' });
|
|
832
|
+
const buttons = [];
|
|
833
|
+
for (const s of q.statuses) {
|
|
834
|
+
const b = el('button', { type: 'button', class: 'chk-status' + (s.tone ? ' tone-' + s.tone : '') }, s.label);
|
|
835
|
+
if (cur()[o.value] === s.value) b.classList.add('sel');
|
|
836
|
+
b.addEventListener('click', () => {
|
|
837
|
+
const m = cur();
|
|
838
|
+
if (m[o.value] === s.value) delete m[o.value];
|
|
839
|
+
else m[o.value] = s.value;
|
|
840
|
+
for (const x of buttons) x.btn.classList.toggle('sel', m[o.value] === x.val);
|
|
841
|
+
clearErr(q.id);
|
|
842
|
+
scheduleSave();
|
|
843
|
+
});
|
|
844
|
+
buttons.push({ btn: b, val: s.value });
|
|
845
|
+
seg.append(b);
|
|
846
|
+
}
|
|
847
|
+
wrap.append(el('div', { class: 'chk-row' },
|
|
848
|
+
el('div', { class: 'chk-body' },
|
|
849
|
+
el('div', { class: 'ol' }, o.label),
|
|
850
|
+
o.description ? el('div', { class: 'od' }, o.description) : null),
|
|
851
|
+
seg
|
|
852
|
+
));
|
|
853
|
+
}
|
|
854
|
+
return wrap;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// Allocate: distribute q.total across the options with per-option sliders; a
|
|
858
|
+
// live total bar shows the running sum / remaining / overage. Answer is a map
|
|
859
|
+
// {optionValue: number}; an all-zero allocation submits as unanswered.
|
|
860
|
+
function controlAllocate(q) {
|
|
861
|
+
const wrap = el('div', { class: 'allocate' });
|
|
862
|
+
const total = q.total || 100;
|
|
863
|
+
const unit = q.unit ? ' ' + q.unit : '';
|
|
864
|
+
const cur = () => (state.answers[q.id] = seedAllocate(q, state.answers[q.id]));
|
|
865
|
+
const sumEl = el('span', { class: 'alloc-sum' });
|
|
866
|
+
const fill = el('div', { class: 'alloc-bar-fill' });
|
|
867
|
+
const nums = [];
|
|
868
|
+
const refresh = () => {
|
|
869
|
+
const m = cur();
|
|
870
|
+
const sum = (q.options || []).reduce((a, o) => a + (Number(m[o.value]) || 0), 0);
|
|
871
|
+
const tail = sum > total ? ` (over by ${sum - total})` : sum < total ? ` (${total - sum} left)` : ' ✓';
|
|
872
|
+
sumEl.textContent = `${sum} / ${total}${unit}${tail}`;
|
|
873
|
+
sumEl.classList.toggle('over', sum > total);
|
|
874
|
+
sumEl.classList.toggle('exact', sum === total);
|
|
875
|
+
fill.style.width = Math.min(100, (sum / total) * 100) + '%';
|
|
876
|
+
fill.classList.toggle('over', sum > total);
|
|
877
|
+
for (const n of nums) n.el.textContent = String(m[n.value] || 0);
|
|
878
|
+
};
|
|
879
|
+
for (const o of q.options) {
|
|
880
|
+
const m = cur();
|
|
881
|
+
const range = el('input', { type: 'range', min: '0', max: String(total), step: '1', class: 'alloc-range', 'aria-label': o.label });
|
|
882
|
+
range.value = String(m[o.value] || 0);
|
|
883
|
+
const num = el('span', { class: 'alloc-num' }, String(m[o.value] || 0));
|
|
884
|
+
range.addEventListener('input', () => {
|
|
885
|
+
cur()[o.value] = Number(range.value) || 0;
|
|
886
|
+
refresh();
|
|
887
|
+
clearErr(q.id);
|
|
888
|
+
scheduleSave();
|
|
889
|
+
});
|
|
890
|
+
nums.push({ value: o.value, el: num });
|
|
891
|
+
wrap.append(el('div', { class: 'alloc-row' },
|
|
892
|
+
el('div', { class: 'alloc-rowhead' },
|
|
893
|
+
el('div', { class: 'ol' }, o.label),
|
|
894
|
+
num),
|
|
895
|
+
range,
|
|
896
|
+
o.description ? el('div', { class: 'od' }, o.description) : null
|
|
897
|
+
));
|
|
898
|
+
}
|
|
899
|
+
wrap.append(el('div', { class: 'alloc-total' },
|
|
900
|
+
el('div', { class: 'alloc-bar' }, fill),
|
|
901
|
+
el('div', { class: 'alloc-sumwrap' }, 'Total: ', sumEl)
|
|
902
|
+
));
|
|
903
|
+
refresh();
|
|
904
|
+
return wrap;
|
|
905
|
+
}
|
|
906
|
+
|
|
724
907
|
function controlText(q, multiline) {
|
|
725
908
|
const input = multiline
|
|
726
909
|
? el('textarea', { placeholder: q.placeholder || '' })
|
|
@@ -734,35 +917,73 @@
|
|
|
734
917
|
return input;
|
|
735
918
|
}
|
|
736
919
|
|
|
737
|
-
//
|
|
738
|
-
|
|
739
|
-
|
|
920
|
+
// Resolve ANY CSS color (named / rgb() / hsl() / hex) to #rrggbb for the
|
|
921
|
+
// native <input type=color>, using the browser's own parser — multi color-
|
|
922
|
+
// system support with no library. Returns null for an unparseable color.
|
|
923
|
+
function cssColorToHex(str) {
|
|
924
|
+
const s = String(str || '').trim();
|
|
740
925
|
let m = /^#?([0-9a-fA-F]{6})$/.exec(s);
|
|
741
926
|
if (m) return '#' + m[1].toLowerCase();
|
|
742
927
|
m = /^#?([0-9a-fA-F]{3})$/.exec(s);
|
|
743
928
|
if (m) return '#' + m[1].split('').map((x) => x + x).join('').toLowerCase();
|
|
744
|
-
|
|
929
|
+
try {
|
|
930
|
+
const d = document.createElement('div');
|
|
931
|
+
d.style.color = '';
|
|
932
|
+
d.style.color = s;
|
|
933
|
+
if (!d.style.color) return null; // browser rejected it → invalid color
|
|
934
|
+
d.style.display = 'none';
|
|
935
|
+
document.body.appendChild(d);
|
|
936
|
+
const rgb = getComputedStyle(d).color;
|
|
937
|
+
document.body.removeChild(d);
|
|
938
|
+
const mm = rgb.match(/(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
|
|
939
|
+
if (!mm) return null;
|
|
940
|
+
const h = (n) => Math.max(0, Math.min(255, Number(n))).toString(16).padStart(2, '0');
|
|
941
|
+
return '#' + h(mm[1]) + h(mm[2]) + h(mm[3]);
|
|
942
|
+
} catch { return null; }
|
|
745
943
|
}
|
|
944
|
+
function toHex6(c) { return cssColorToHex(c) || '#000000'; }
|
|
945
|
+
|
|
746
946
|
function controlColor(q) {
|
|
747
947
|
const wrap = el('div', { class: 'colorpick' });
|
|
748
948
|
const init = (typeof state.answers[q.id] === 'string' && state.answers[q.id]) || (typeof q.default === 'string' ? q.default : '');
|
|
749
949
|
const swatch = el('input', { type: 'color', class: 'colorswatch' });
|
|
750
|
-
const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb', spellcheck: 'false', autocapitalize: 'off' });
|
|
950
|
+
const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb / rgb() / name', spellcheck: 'false', autocapitalize: 'off' });
|
|
751
951
|
swatch.value = toHex6(init || '#888888');
|
|
752
952
|
if (init) { hex.value = init; state.answers[q.id] = init; }
|
|
753
|
-
|
|
953
|
+
let syncPaletteSel = () => {};
|
|
954
|
+
const set = (val) => { state.answers[q.id] = val; syncPaletteSel(); clearErr(q.id); scheduleSave(); };
|
|
754
955
|
swatch.addEventListener('input', () => { hex.value = swatch.value; set(swatch.value); });
|
|
755
|
-
hex.addEventListener('input', () => { const v = hex.value.trim();
|
|
956
|
+
hex.addEventListener('input', () => { const v = hex.value.trim(); const h = cssColorToHex(v); if (h) swatch.value = h; set(v); });
|
|
957
|
+
|
|
958
|
+
// "Pick from a palette" — labeled swatch cards. Click selects that color as
|
|
959
|
+
// the answer; each card is registered for annotation (per-color comments).
|
|
960
|
+
if (Array.isArray(q.palette) && q.palette.length) {
|
|
961
|
+
const grid = el('div', { class: 'colorpalette' });
|
|
962
|
+
const cards = [];
|
|
963
|
+
for (const p of q.palette) {
|
|
964
|
+
const labelled = p.label && p.label !== p.value;
|
|
965
|
+
const card = el('button', { type: 'button', class: 'colorpal-sw', style: 'background:' + p.value, title: (labelled ? p.label + ' · ' : '') + p.value });
|
|
966
|
+
if (labelled) card.append(el('span', { class: 'colorpal-label' }, p.label));
|
|
967
|
+
card.addEventListener('click', () => { const h = cssColorToHex(p.value); if (h) swatch.value = h; hex.value = p.value; set(p.value); });
|
|
968
|
+
if (Annotate) Annotate.register(card, { blockId: null, questionId: q.id, target: { kind: 'swatch', label: (labelled ? p.label + ' · ' : '') + p.value } });
|
|
969
|
+
cards.push({ card, val: p.value });
|
|
970
|
+
grid.append(card);
|
|
971
|
+
}
|
|
972
|
+
syncPaletteSel = () => { for (const c of cards) c.card.classList.toggle('sel', state.answers[q.id] === c.val); };
|
|
973
|
+
wrap.append(grid);
|
|
974
|
+
}
|
|
975
|
+
|
|
756
976
|
wrap.append(el('div', { class: 'colorrow' }, swatch, hex));
|
|
757
977
|
if (Array.isArray(q.presets) && q.presets.length) {
|
|
758
978
|
const presets = el('div', { class: 'colorpresets' });
|
|
759
979
|
for (const c of q.presets) {
|
|
760
980
|
const b = el('button', { type: 'button', class: 'colorpreset', style: 'background:' + c, title: c });
|
|
761
|
-
b.addEventListener('click', () => {
|
|
981
|
+
b.addEventListener('click', () => { const h = cssColorToHex(c); if (h) swatch.value = h; hex.value = c; set(c); });
|
|
762
982
|
presets.append(b);
|
|
763
983
|
}
|
|
764
984
|
wrap.append(presets);
|
|
765
985
|
}
|
|
986
|
+
syncPaletteSel();
|
|
766
987
|
return wrap;
|
|
767
988
|
}
|
|
768
989
|
|
|
@@ -832,6 +1053,9 @@
|
|
|
832
1053
|
else if (q.type === 'yesno') control.append(segButtons(q, ['yes', 'no'], ['Yes', 'No']));
|
|
833
1054
|
else if (q.type === 'scale') control.append(controlScale(q));
|
|
834
1055
|
else if (q.type === 'color') control.append(controlColor(q));
|
|
1056
|
+
else if (q.type === 'rank') control.append(controlRank(q));
|
|
1057
|
+
else if (q.type === 'checklist') control.append(controlChecklist(q));
|
|
1058
|
+
else if (q.type === 'allocate') control.append(controlAllocate(q));
|
|
835
1059
|
else control.append(controlText(q, q.type === 'textarea'));
|
|
836
1060
|
card.append(control);
|
|
837
1061
|
if (q.note) {
|