@khanglvm/relay 0.12.3 → 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.
@@ -231,11 +231,11 @@
231
231
  label: String(rq.label || rq.question || rq.text || ''),
232
232
  description: typeof rq.description === 'string' ? rq.description : '',
233
233
  required: rq.required === true,
234
- note: rq.note === undefined ? type === 'single' : rq.note === true,
234
+ note: rq.note === undefined ? (type === 'single' || type === 'rank' || type === 'checklist' || type === 'allocate') : rq.note === true,
235
235
  placeholder: typeof rq.placeholder === 'string' ? rq.placeholder : '',
236
236
  blocks: normBlocks(rq.blocks, id + '-'),
237
237
  };
238
- if (type === 'single' || type === 'multi') {
238
+ if (type === 'single' || type === 'multi' || type === 'rank' || type === 'checklist' || type === 'allocate') {
239
239
  const opts = Array.isArray(rq.options) ? rq.options : [];
240
240
  q.options = opts.map((o, j) => {
241
241
  if (typeof o === 'string' || typeof o === 'number') return { value: String(o), label: String(o) };
@@ -249,7 +249,18 @@
249
249
  }
250
250
  return { value: String(o), label: String(o) };
251
251
  });
252
- q.other = type === 'single' ? rq.other !== false : rq.other === true;
252
+ if (type === 'single' || type === 'multi') q.other = type === 'single' ? rq.other !== false : rq.other === true;
253
+ }
254
+ if (type === 'checklist') {
255
+ const rawSt = Array.isArray(rq.statuses) && rq.statuses.length ? rq.statuses
256
+ : [{ value: 'pass', label: 'Pass', tone: 'ok' }, { value: 'fail', label: 'Fail', tone: 'bad' }, { value: 'na', label: 'N/A', tone: 'muted' }];
257
+ q.statuses = rawSt.map((s) => typeof s === 'object' && s
258
+ ? { value: String(s.value != null ? s.value : s.label || ''), label: String(s.label != null ? s.label : s.value || ''), tone: s.tone ? String(s.tone) : undefined }
259
+ : { value: String(s), label: String(s) === 'na' ? 'N/A' : String(s) });
260
+ }
261
+ if (type === 'allocate') {
262
+ q.total = Number.isFinite(rq.total) && rq.total > 0 ? Math.floor(rq.total) : 100;
263
+ if (rq.unit !== undefined) q.unit = String(rq.unit);
253
264
  }
254
265
  if (type === 'scale') {
255
266
  q.min = Number.isFinite(rq.min) ? rq.min : 1;
@@ -260,6 +271,11 @@
260
271
  if (type === 'color' && Array.isArray(rq.presets)) {
261
272
  q.presets = rq.presets.map((c) => String(c)).filter(Boolean);
262
273
  }
274
+ if (type === 'color' && Array.isArray(rq.palette)) {
275
+ q.palette = rq.palette.map((p) => typeof p === 'object' && p
276
+ ? { value: String(p.value != null ? p.value : p.color || ''), label: String(p.label != null ? p.label : (p.name != null ? p.name : (p.value != null ? p.value : p.color || ''))) }
277
+ : { value: String(p), label: String(p) }).filter((p) => p.value);
278
+ }
263
279
  if (rq.default !== undefined) q.default = rq.default;
264
280
  spec.questions.push(q);
265
281
  });
@@ -423,6 +439,32 @@
423
439
 
424
440
  function seedDefaults() {
425
441
  for (const q of QS) if (q.default !== undefined) state.answers[q.id] = q.default;
442
+ // Rank questions carry a full, valid permutation so an untouched rank still
443
+ // submits a meaningful order (mirrors the browser board's seedRankOrder).
444
+ for (const q of QS) {
445
+ if (q.type !== 'rank') continue;
446
+ const opts = (q.options || []).map((o) => o.value);
447
+ const prior = Array.isArray(state.answers[q.id]) ? state.answers[q.id] : [];
448
+ const order = prior.filter((v, i) => opts.includes(v) && prior.indexOf(v) === i);
449
+ for (const v of opts) if (!order.includes(v)) order.push(v);
450
+ state.answers[q.id] = order;
451
+ }
452
+ for (const q of QS) if (q.type === 'allocate') state.answers[q.id] = seedAllocate(q, state.answers[q.id]);
453
+ for (const q of QS) if (q.type === 'checklist') state.answers[q.id] = seedChecklist(q, state.answers[q.id]);
454
+ }
455
+ function seedAllocate(q, prior) {
456
+ const m = prior && typeof prior === 'object' && !Array.isArray(prior) ? prior : {};
457
+ const out = {};
458
+ for (const o of q.options || []) out[o.value] = Math.max(0, Number(m[o.value]) || 0);
459
+ return out;
460
+ }
461
+ function seedChecklist(q, prior) {
462
+ const m = prior && typeof prior === 'object' && !Array.isArray(prior) ? prior : {};
463
+ const optVals = new Set((q.options || []).map((o) => o.value));
464
+ const stVals = new Set((q.statuses || []).map((s) => s.value));
465
+ const out = {};
466
+ for (const k of Object.keys(m)) if (optVals.has(k) && stVals.has(m[k])) out[k] = m[k];
467
+ return out;
426
468
  }
427
469
 
428
470
  function getValue(q) {
@@ -442,6 +484,17 @@
442
484
  return v === 'yes' || v === 'no' ? v : undefined;
443
485
  case 'scale':
444
486
  return typeof v === 'number' ? v : undefined;
487
+ case 'rank':
488
+ return Array.isArray(v) && v.length ? [...v] : undefined;
489
+ case 'checklist': {
490
+ const m = v && typeof v === 'object' && !Array.isArray(v) ? v : {};
491
+ return Object.keys(m).length ? { ...m } : undefined;
492
+ }
493
+ case 'allocate': {
494
+ const m = v && typeof v === 'object' && !Array.isArray(v) ? v : {};
495
+ const sum = Object.values(m).reduce((a, n) => a + (Number(n) || 0), 0);
496
+ return sum > 0 ? { ...m } : undefined;
497
+ }
445
498
  default: {
446
499
  const t = typeof v === 'string' ? v.trim() : '';
447
500
  return t || undefined;
@@ -595,35 +648,182 @@
595
648
  input.addEventListener('input', () => { state.answers[q.id] = input.value; clearErr(q.id); });
596
649
  return input;
597
650
  }
651
+ // Checklist / allocate — see the browser board's controls (no autosave here).
652
+ function controlChecklist(q) {
653
+ const wrap = el('div', { class: 'checklist' });
654
+ const cur = () => (state.answers[q.id] && typeof state.answers[q.id] === 'object' && !Array.isArray(state.answers[q.id])
655
+ ? state.answers[q.id] : (state.answers[q.id] = {}));
656
+ for (const o of q.options) {
657
+ const seg = el('div', { class: 'chk-seg' });
658
+ const buttons = [];
659
+ for (const s of q.statuses) {
660
+ const b = el('button', { type: 'button', class: 'chk-status' + (s.tone ? ' tone-' + s.tone : '') }, s.label);
661
+ if (cur()[o.value] === s.value) b.classList.add('sel');
662
+ b.addEventListener('click', () => {
663
+ const m = cur();
664
+ if (m[o.value] === s.value) delete m[o.value];
665
+ else m[o.value] = s.value;
666
+ for (const x of buttons) x.btn.classList.toggle('sel', m[o.value] === x.val);
667
+ clearErr(q.id);
668
+ });
669
+ buttons.push({ btn: b, val: s.value });
670
+ seg.append(b);
671
+ }
672
+ wrap.append(el('div', { class: 'chk-row' },
673
+ el('div', { class: 'chk-body' }, el('div', { class: 'ol' }, o.label), o.description ? el('div', { class: 'od' }, o.description) : null),
674
+ seg));
675
+ }
676
+ return wrap;
677
+ }
678
+ function controlAllocate(q) {
679
+ const wrap = el('div', { class: 'allocate' });
680
+ const total = q.total || 100;
681
+ const unit = q.unit ? ' ' + q.unit : '';
682
+ const cur = () => (state.answers[q.id] = seedAllocate(q, state.answers[q.id]));
683
+ const sumEl = el('span', { class: 'alloc-sum' });
684
+ const fill = el('div', { class: 'alloc-bar-fill' });
685
+ const nums = [];
686
+ const refresh = () => {
687
+ const m = cur();
688
+ const sum = (q.options || []).reduce((a, o) => a + (Number(m[o.value]) || 0), 0);
689
+ const tail = sum > total ? ' (over by ' + (sum - total) + ')' : sum < total ? ' (' + (total - sum) + ' left)' : ' ✓';
690
+ sumEl.textContent = sum + ' / ' + total + unit + tail;
691
+ sumEl.classList.toggle('over', sum > total);
692
+ sumEl.classList.toggle('exact', sum === total);
693
+ fill.style.width = Math.min(100, (sum / total) * 100) + '%';
694
+ fill.classList.toggle('over', sum > total);
695
+ for (const n of nums) n.el.textContent = String(m[n.value] || 0);
696
+ };
697
+ for (const o of q.options) {
698
+ const m = cur();
699
+ const range = el('input', { type: 'range', min: '0', max: String(total), step: '1', class: 'alloc-range', 'aria-label': o.label });
700
+ range.value = String(m[o.value] || 0);
701
+ const num = el('span', { class: 'alloc-num' }, String(m[o.value] || 0));
702
+ range.addEventListener('input', () => { cur()[o.value] = Number(range.value) || 0; refresh(); clearErr(q.id); });
703
+ nums.push({ value: o.value, el: num });
704
+ wrap.append(el('div', { class: 'alloc-row' },
705
+ el('div', { class: 'alloc-rowhead' }, el('div', { class: 'ol' }, o.label), num),
706
+ range,
707
+ o.description ? el('div', { class: 'od' }, o.description) : null));
708
+ }
709
+ wrap.append(el('div', { class: 'alloc-total' },
710
+ el('div', { class: 'alloc-bar' }, fill),
711
+ el('div', { class: 'alloc-sumwrap' }, 'Total: ', sumEl)));
712
+ refresh();
713
+ return wrap;
714
+ }
715
+ // Reorderable priority list — see the browser board's controlRank. Answer is
716
+ // the ordered array of option values (highest first); always a full permutation.
717
+ function controlRank(q) {
718
+ const wrap = el('div', { class: 'rank' });
719
+ const byVal = new Map(q.options.map((o) => [o.value, o]));
720
+ let dragFrom = null;
721
+ function move(from, to) {
722
+ const arr = state.answers[q.id];
723
+ if (!Array.isArray(arr) || to < 0 || to >= arr.length || from === to) return;
724
+ const [x] = arr.splice(from, 1);
725
+ arr.splice(to, 0, x);
726
+ paint();
727
+ clearErr(q.id);
728
+ }
729
+ function paint() {
730
+ wrap.replaceChildren();
731
+ const order = state.answers[q.id] || [];
732
+ order.forEach((val, i) => {
733
+ const o = byVal.get(val);
734
+ if (!o) return;
735
+ const up = el('button', { type: 'button', class: 'rank-btn', title: 'Move up', 'aria-label': 'Move "' + o.label + '" up' }, '↑');
736
+ const down = el('button', { type: 'button', class: 'rank-btn', title: 'Move down', 'aria-label': 'Move "' + o.label + '" down' }, '↓');
737
+ up.disabled = i === 0;
738
+ down.disabled = i === order.length - 1;
739
+ up.addEventListener('click', () => move(i, i - 1));
740
+ down.addEventListener('click', () => move(i, i + 1));
741
+ const item = el('div', { class: 'rank-item', draggable: 'true' },
742
+ el('span', { class: 'rank-badge', 'aria-hidden': 'true' }, String(i + 1)),
743
+ el('div', { class: 'rank-body' },
744
+ el('div', { class: 'ol' }, o.label),
745
+ o.description ? el('div', { class: 'od' }, o.description) : null),
746
+ el('div', { class: 'rank-ctrls' }, up, down)
747
+ );
748
+ item.addEventListener('dragstart', (e) => {
749
+ dragFrom = i; item.classList.add('dragging');
750
+ try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); } catch (_) {}
751
+ });
752
+ item.addEventListener('dragend', () => { dragFrom = null; item.classList.remove('dragging'); });
753
+ item.addEventListener('dragover', (e) => { if (dragFrom !== null) { e.preventDefault(); item.classList.add('drop-into'); } });
754
+ item.addEventListener('dragleave', () => item.classList.remove('drop-into'));
755
+ item.addEventListener('drop', (e) => {
756
+ e.preventDefault();
757
+ const from = dragFrom; dragFrom = null;
758
+ if (from !== null && from !== i) move(from, i);
759
+ });
760
+ wrap.append(item);
761
+ });
762
+ }
763
+ paint();
764
+ return wrap;
765
+ }
598
766
 
599
- function toHex6(c) {
600
- const s = String(c || '').trim();
767
+ // Resolve any CSS color (named/rgb/hsl/hex) to #rrggbb via the browser parser.
768
+ function cssColorToHex(str) {
769
+ const s = String(str || '').trim();
601
770
  let m = /^#?([0-9a-fA-F]{6})$/.exec(s);
602
771
  if (m) return '#' + m[1].toLowerCase();
603
772
  m = /^#?([0-9a-fA-F]{3})$/.exec(s);
604
773
  if (m) return '#' + m[1].split('').map((x) => x + x).join('').toLowerCase();
605
- return '#000000';
774
+ try {
775
+ const d = document.createElement('div');
776
+ d.style.color = '';
777
+ d.style.color = s;
778
+ if (!d.style.color) return null;
779
+ d.style.display = 'none';
780
+ document.body.appendChild(d);
781
+ const rgb = getComputedStyle(d).color;
782
+ document.body.removeChild(d);
783
+ const mm = rgb.match(/(\d+)[,\s]+(\d+)[,\s]+(\d+)/);
784
+ if (!mm) return null;
785
+ const h = (n) => Math.max(0, Math.min(255, Number(n))).toString(16).padStart(2, '0');
786
+ return '#' + h(mm[1]) + h(mm[2]) + h(mm[3]);
787
+ } catch { return null; }
606
788
  }
789
+ function toHex6(c) { return cssColorToHex(c) || '#000000'; }
607
790
  function controlColor(q) {
608
791
  const wrap = el('div', { class: 'colorpick' });
609
792
  const init = (typeof state.answers[q.id] === 'string' && state.answers[q.id]) || (typeof q.default === 'string' ? q.default : '');
610
793
  const swatch = el('input', { type: 'color', class: 'colorswatch' });
611
- const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb', spellcheck: 'false', autocapitalize: 'off' });
794
+ const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb / rgb() / name', spellcheck: 'false', autocapitalize: 'off' });
612
795
  swatch.value = toHex6(init || '#888888');
613
796
  if (init) { hex.value = init; state.answers[q.id] = init; }
614
- const set = (val) => { state.answers[q.id] = val; clearErr(q.id); };
797
+ let syncPaletteSel = () => {};
798
+ const set = (val) => { state.answers[q.id] = val; syncPaletteSel(); clearErr(q.id); };
615
799
  swatch.addEventListener('input', () => { hex.value = swatch.value; set(swatch.value); });
616
- 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); });
800
+ hex.addEventListener('input', () => { const v = hex.value.trim(); const h = cssColorToHex(v); if (h) swatch.value = h; set(v); });
801
+ if (Array.isArray(q.palette) && q.palette.length) {
802
+ const grid = el('div', { class: 'colorpalette' });
803
+ const cards = [];
804
+ for (const p of q.palette) {
805
+ const labelled = p.label && p.label !== p.value;
806
+ const card = el('button', { type: 'button', class: 'colorpal-sw', style: 'background:' + p.value, title: (labelled ? p.label + ' · ' : '') + p.value });
807
+ if (labelled) card.append(el('span', { class: 'colorpal-label' }, p.label));
808
+ card.addEventListener('click', () => { const h = cssColorToHex(p.value); if (h) swatch.value = h; hex.value = p.value; set(p.value); });
809
+ if (Annotate && !composing) Annotate.register(card, { blockId: null, questionId: q.id, target: { kind: 'swatch', label: (labelled ? p.label + ' · ' : '') + p.value } });
810
+ cards.push({ card, val: p.value });
811
+ grid.append(card);
812
+ }
813
+ syncPaletteSel = () => { for (const c of cards) c.card.classList.toggle('sel', state.answers[q.id] === c.val); };
814
+ wrap.append(grid);
815
+ }
617
816
  wrap.append(el('div', { class: 'colorrow' }, swatch, hex));
618
817
  if (Array.isArray(q.presets) && q.presets.length) {
619
818
  const presets = el('div', { class: 'colorpresets' });
620
819
  for (const c of q.presets) {
621
820
  const b = el('button', { type: 'button', class: 'colorpreset', style: 'background:' + c, title: c });
622
- b.addEventListener('click', () => { swatch.value = toHex6(c); hex.value = c; set(c); });
821
+ b.addEventListener('click', () => { const h = cssColorToHex(c); if (h) swatch.value = h; hex.value = c; set(c); });
623
822
  presets.append(b);
624
823
  }
625
824
  wrap.append(presets);
626
825
  }
826
+ syncPaletteSel();
627
827
  return wrap;
628
828
  }
629
829
 
@@ -659,6 +859,9 @@
659
859
  else if (q.type === 'yesno') control.append(segButtons(q, ['yes', 'no'], ['Yes', 'No']));
660
860
  else if (q.type === 'scale') control.append(controlScale(q));
661
861
  else if (q.type === 'color') control.append(controlColor(q));
862
+ else if (q.type === 'rank') control.append(controlRank(q));
863
+ else if (q.type === 'checklist') control.append(controlChecklist(q));
864
+ else if (q.type === 'allocate') control.append(controlAllocate(q));
662
865
  else control.append(controlText(q, q.type === 'textarea'));
663
866
  card.append(control);
664
867
  if (q.note) {
package/src/server.js CHANGED
@@ -42,16 +42,21 @@ function readUi(name) {
42
42
  // (their bodies are served via /html/b/<id>), embedded images ship only
43
43
  // metadata (bytes served via /img/b/<id>), everything else ships as-is.
44
44
  function clientBlock(b) {
45
+ // Cross-block fields preserved when we ship metadata-only (ref = reference-link
46
+ // target name; pins = image coordinate comments). The default `return b` path
47
+ // already carries them.
48
+ const extra = {};
49
+ if (b && b.ref !== undefined) extra.ref = b.ref;
45
50
  if (b && b.type === 'html') {
46
- return { id: b.id, type: 'html', height: b.height, hasHtml: Boolean(b.html) };
51
+ return { id: b.id, type: 'html', height: b.height, hasHtml: Boolean(b.html), ...extra };
47
52
  }
48
53
  if (b && b.type === 'image' && typeof b.src === 'string' && b.src.startsWith('data:')) {
49
- return { id: b.id, type: 'image', alt: b.alt, height: b.height, hasData: true };
54
+ return { id: b.id, type: 'image', alt: b.alt, height: b.height, hasData: true, ...(b.pins ? { pins: true } : {}), ...extra };
50
55
  }
51
56
  // Local video: the absolute file path stays server-side; the client gets a
52
57
  // flag + mime and loads the bytes (Range-streamed) from /video/b/<id>.
53
58
  if (b && b.type === 'video' && typeof b.file === 'string') {
54
- return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true };
59
+ return { id: b.id, type: 'video', title: b.title, height: b.height, mime: b.mime, hasFile: true, ...extra };
55
60
  }
56
61
  return b;
57
62
  }