@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.
@@ -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
- pop.replaceChildren(el('div', { class: 'ann-pop-label' }, humanize(info.target)));
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.addEventListener('click', () => {
515
- removeAnnotation(a.id);
516
- renderExisting();
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), del),
520
- el('div', { class: 'ann-pop-text' }, a.text)
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.addEventListener('click', (e) => {
724
- e.stopPropagation();
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
- // Normalize any CSS color to the #rrggbb the native <input type=color> needs.
738
- function toHex6(c) {
739
- const s = String(c || '').trim();
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
- return '#000000';
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
- const set = (val) => { state.answers[q.id] = val; clearErr(q.id); scheduleSave(); };
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(); if (/^#?([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(v)) swatch.value = toHex6(v); set(v); });
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', () => { swatch.value = toHex6(c); hex.value = c; set(c); });
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) {