@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.
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) {
package/src/ui/blocks.css CHANGED
@@ -33,6 +33,7 @@
33
33
  .blk-markdown .md h2 { font-size: 1.2rem; }
34
34
  .blk-markdown .md h3 { font-size: 1.05rem; }
35
35
  .blk-markdown .md p { margin: 10px 0; }
36
+ .blk-markdown .md img.md-img { max-width: 100%; height: auto; border-radius: 8px; margin: 8px 0; }
36
37
  .blk-markdown .md a { color: var(--accent); text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 150ms var(--ease); }
37
38
  .blk-markdown .md a:hover { border-bottom-color: var(--accent); }
38
39
  .blk-markdown .md strong { font-weight: 600; }
@@ -237,6 +238,36 @@
237
238
  }
238
239
  .blk-difftoggle:hover { color: var(--accent); border-color: var(--accent); }
239
240
 
241
+ /* multi-file diff: per-file filename header + a jump bar of file chips */
242
+ .diff-file + .diff-file { margin-top: 6px; }
243
+ .diff-filehead {
244
+ position: sticky; top: 0; z-index: 1;
245
+ padding: 5px 12px; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
246
+ font-size: 0.76rem; color: var(--fg-2);
247
+ background: var(--card); border-bottom: 1px solid var(--border);
248
+ }
249
+ .diff-files {
250
+ display: flex; flex-wrap: wrap; gap: 6px;
251
+ padding: 8px 0;
252
+ }
253
+ .diff-file-chip {
254
+ cursor: pointer; font: inherit; font-size: 0.72rem;
255
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
256
+ color: var(--fg-2); background: var(--bg-sunken);
257
+ border: 1px solid var(--border); border-radius: 6px; padding: 3px 8px;
258
+ max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
259
+ transition: color 150ms var(--ease), border-color 150ms var(--ease);
260
+ }
261
+ .diff-file-chip:hover { color: var(--accent); border-color: var(--accent); }
262
+
263
+ /* clickable line-number gutter: hover a number to comment on that line */
264
+ .blk-gutter-c { padding: 14px 0; }
265
+ .blk-gutter-c .blk-gutterline {
266
+ padding: 0 10px 0 14px; cursor: pointer; position: relative;
267
+ transition: background 120ms var(--ease), color 120ms var(--ease);
268
+ }
269
+ .blk-gutter-c .blk-gutterline:hover { background: var(--accent-soft); color: var(--accent); }
270
+
240
271
  /* ---------- video (iframe embed / local stream / direct URL) ---------- */
241
272
  .blk-videowrap { margin: 0; }
242
273
  .blk-video, .blk-video-embed {
@@ -277,6 +308,23 @@
277
308
  .blk-table tbody tr:hover { background: var(--bg-sunken); }
278
309
  .blk-table tbody tr:last-child td { border-bottom: 0; }
279
310
 
311
+ /* table toolbar: live filter box + CSV export (opt-in via filterable/exportable) */
312
+ .blk-tablebar { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; }
313
+ .blk-tablefilter {
314
+ flex: 1; min-width: 0; max-width: 280px;
315
+ padding: 6px 10px; font: inherit; font-size: 0.85rem;
316
+ background: var(--bg-sunken); color: var(--fg);
317
+ border: 1px solid var(--border); border-radius: 8px;
318
+ }
319
+ .blk-tablefilter:focus { outline: none; border-color: var(--accent); }
320
+ .blk-tableexport {
321
+ flex: none; padding: 6px 12px; font: inherit; font-size: 0.8rem; cursor: pointer;
322
+ background: var(--card); color: var(--fg-2);
323
+ border: 1px solid var(--border-strong); border-radius: 8px;
324
+ transition: border-color 150ms var(--ease), color 150ms var(--ease);
325
+ }
326
+ .blk-tableexport:hover { border-color: var(--accent); color: var(--accent); }
327
+
280
328
  /* ---------- chart ---------- */
281
329
  /* NO height here: renderChart always sets the inline height, and this class
282
330
  also lands on the outer .blk.blk-chart dispatch wrapper — a CSS height
@@ -316,7 +364,7 @@
316
364
  /* ---------- mermaid ---------- */
317
365
  .blk-mermaid {
318
366
  overflow: auto;
319
- max-height: 1200px;
367
+ max-height: 800px;
320
368
  text-align: center;
321
369
  }
322
370
  .blk-mermaid svg {
@@ -392,7 +440,7 @@
392
440
  /* ---------- graphviz (offline, vendored Viz.js) ---------- */
393
441
  .blk-graphviz {
394
442
  overflow: auto;
395
- max-height: 1200px;
443
+ max-height: 800px;
396
444
  text-align: center;
397
445
  /* graphviz draws on its own white canvas; frame it as a deliberate card so
398
446
  it reads intentional in dark mode instead of a stray white rectangle */
@@ -411,7 +459,7 @@
411
459
  /* ---------- plantuml (server-rendered image) ---------- */
412
460
  .blk-plantuml {
413
461
  overflow: auto;
414
- max-height: 1200px;
462
+ max-height: 800px;
415
463
  text-align: center;
416
464
  background: #fff;
417
465
  border: 1px solid var(--border);
@@ -426,7 +474,7 @@
426
474
  /* ---------- image ---------- */
427
475
  .blk-imagewrap {
428
476
  overflow: auto;
429
- max-height: 1200px;
477
+ max-height: 800px;
430
478
  border: 1px solid var(--border);
431
479
  border-radius: 10px;
432
480
  background: var(--bg-sunken);
@@ -483,6 +531,13 @@ body.blk-full-open { overflow: hidden; }
483
531
  background: var(--card); border-bottom: 1px solid var(--border);
484
532
  z-index: 51;
485
533
  }
534
+ /* When the comment rail is docked open (≥980px), inset the full-screen overlay
535
+ AND its toolbar from the right by the rail width — otherwise the overlay slides
536
+ under the (higher z-index) rail and its close/comment buttons get hidden. */
537
+ @media (min-width: 980px) {
538
+ body.ann-rail-open .blk-full { right: 340px; }
539
+ body.ann-rail-open .blk-full > .blk-tools { right: 340px; }
540
+ }
486
541
  .blk-chart.blk-full {
487
542
  height: 100vh !important; width: auto !important;
488
543
  padding: 44px 16px 16px;
@@ -585,3 +640,108 @@ body.blk-full-open { overflow: hidden; }
585
640
  .pal-tag.tone-nature { background: #EAF3DE; color: #3B6D11; }
586
641
  .pal-tag.tone-bold { background: #FBEAF0; color: #72243E; }
587
642
  .pal-tag.tone-digital { background: #EEEDFE; color: #3C3489; }
643
+
644
+ /* ---------- kpi (stat cards) ---------- */
645
+ .blk-kpi { margin: 4px 0; }
646
+ .kpi-title { font-weight: 600; color: var(--fg-2); margin-bottom: 10px; }
647
+ .kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; }
648
+ .kpi-card {
649
+ border: 1px solid var(--border); border-radius: 12px;
650
+ background: var(--card); padding: 14px 16px;
651
+ }
652
+ .kpi-label { color: var(--muted); font-size: 0.82rem; }
653
+ .kpi-value { font-family: var(--serif); font-size: 1.8rem; line-height: 1.1; margin: 4px 0 2px; letter-spacing: -0.02em; }
654
+ .kpi-delta { font-size: 0.85rem; font-weight: 600; color: var(--fg-2); }
655
+ .kpi-delta.kpi-up { color: var(--ok); }
656
+ .kpi-delta.kpi-down { color: var(--danger); }
657
+ .kpi-sub { color: var(--muted); font-size: 0.78rem; margin-top: 2px; }
658
+
659
+ /* ---------- typography (specimens) ---------- */
660
+ .blk-typography { margin: 4px 0; }
661
+ .typo-title { font-weight: 600; color: var(--fg-2); margin-bottom: 10px; }
662
+ .typo-row { padding: 10px 0; border-bottom: 1px solid var(--border); }
663
+ .typo-row:last-child { border-bottom: 0; }
664
+ .typo-meta { color: var(--muted); font-size: 0.74rem; text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; }
665
+ .typo-sample { color: var(--fg); font-size: 1.1rem; line-height: 1.3; overflow-wrap: anywhere; }
666
+
667
+ /* ---------- compare (before/after slider) ---------- */
668
+ .blk-comparewrap { margin: 4px 0; }
669
+ .cmp-frame {
670
+ position: relative; overflow: hidden; user-select: none; touch-action: none;
671
+ border: 1px solid var(--border); border-radius: 12px; line-height: 0;
672
+ cursor: ew-resize;
673
+ }
674
+ .cmp-img { display: block; width: 100%; height: auto; }
675
+ .cmp-before { position: absolute; inset: 0; width: 50%; overflow: hidden; }
676
+ .cmp-before .cmp-img-before { position: absolute; left: 0; top: 0; height: 100%; max-width: none; }
677
+ .cmp-handle {
678
+ position: absolute; top: 0; bottom: 0; left: 50%; width: 2px;
679
+ background: #fff; box-shadow: 0 0 0 1px rgba(0,0,0,0.25); transform: translateX(-1px);
680
+ }
681
+ .cmp-handle-grip {
682
+ position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
683
+ width: 30px; height: 30px; border-radius: 50%;
684
+ background: #fff; color: #333; box-shadow: 0 1px 4px rgba(0,0,0,0.35);
685
+ display: grid; place-items: center; font-size: 13px; cursor: ew-resize;
686
+ }
687
+ .cmp-handle:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
688
+ .cmp-tag {
689
+ position: absolute; bottom: 10px; padding: 3px 8px; border-radius: 6px;
690
+ font-size: 0.72rem; line-height: 1.4; color: #fff; background: rgba(0,0,0,0.55);
691
+ pointer-events: none;
692
+ }
693
+ .cmp-tag-before { left: 10px; }
694
+ .cmp-tag-after { right: 10px; }
695
+
696
+ /* ---------- reference links (open a visual in a modal) ---------- */
697
+ .rly-reflink {
698
+ display: inline-flex; align-items: center; gap: 0.3em;
699
+ color: var(--accent); cursor: pointer; text-decoration: none;
700
+ border-bottom: 1px solid transparent; border-radius: 4px;
701
+ transition: border-color 150ms var(--ease);
702
+ }
703
+ .rly-reflink:hover { border-bottom-color: var(--accent); }
704
+ .rly-reflink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
705
+ .rly-reflink-ico {
706
+ flex: none; width: 0.85em; height: 0.85em;
707
+ border: 1.6px solid currentColor; border-radius: 50%;
708
+ position: relative;
709
+ }
710
+ .rly-reflink-ico::after {
711
+ content: ''; position: absolute; width: 0.42em; height: 1.6px;
712
+ background: currentColor; right: -0.28em; bottom: 0.02em;
713
+ transform: rotate(45deg); transform-origin: left center;
714
+ }
715
+ /* floating "↩ Back" pill shown after a reference jumps to a non-modal block */
716
+ .rly-refback {
717
+ position: fixed; left: 18px; bottom: 18px; z-index: 60;
718
+ display: inline-flex; align-items: center; gap: 6px;
719
+ padding: 8px 14px; font: inherit; font-size: 0.85rem; font-weight: 500;
720
+ color: var(--accent-fg); background: var(--accent);
721
+ border: 0; border-radius: 999px; cursor: pointer;
722
+ box-shadow: var(--shadow-lift);
723
+ animation: rly-refback-in 180ms var(--ease);
724
+ }
725
+ .rly-refback:hover { filter: brightness(1.06); }
726
+ @keyframes rly-refback-in { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
727
+ /* brief highlight when a reference jumps to a non-modal block */
728
+ .rly-refflash { animation: rly-refflash 1.2s var(--ease); }
729
+ @keyframes rly-refflash {
730
+ 0%, 100% { box-shadow: 0 0 0 0 transparent; }
731
+ 20% { box-shadow: 0 0 0 3px var(--accent); }
732
+ }
733
+
734
+ /* ---------- image coordinate pins (block.pins) ---------- */
735
+ .blk-img-pinnable { cursor: crosshair; }
736
+ .blk-imgpins { position: absolute; inset: 0; pointer-events: none; }
737
+ .blk-imgpin {
738
+ position: absolute; transform: translate(-50%, -50%);
739
+ min-width: 22px; height: 22px; padding: 0 6px;
740
+ border-radius: 11px; border: 2px solid #fff;
741
+ background: var(--accent); color: #fff;
742
+ font: inherit; font-size: 0.72rem; font-weight: 600; line-height: 1;
743
+ cursor: pointer; pointer-events: auto;
744
+ box-shadow: 0 1px 4px rgba(0,0,0,0.35);
745
+ display: grid; place-items: center;
746
+ }
747
+ .blk-imgpin:hover { filter: brightness(1.08); }