@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/src/ui/blocks.js CHANGED
@@ -95,6 +95,23 @@
95
95
  s = s.replace(/`([^`]+)`/g, (_m, c) =>
96
96
  looksLikeLocalPath(c) ? keep(fileLinkHtml(c.trim(), c, true)) : keep(`<code>${c}</code>`)
97
97
  );
98
+ // reference links [label](#ref:name) / [label](#block:id) open the named/identified
99
+ // block in a full-screen modal — so a question can point back to a chart/image
100
+ // shown earlier without the user scrolling up. Runs before the generic link
101
+ // pass so the #ref:/#block: target isn't rendered as a plain href.
102
+ s = s.replace(/\[([^\]]+)\]\(#(ref|block):([^)\s]+)\)/g, (_m, label, kind, target) =>
103
+ keep(`<a class="rly-reflink" role="button" tabindex="0" data-rly-${kind === 'block' ? 'refid' : 'ref'}="${target}" title="Open this visual">` +
104
+ `<span class="rly-reflink-ico" aria-hidden="true"></span><span>${label}</span></a>`));
105
+ // images ![alt](src) BEFORE links so the leading "!" isn't stranded as text.
106
+ // A remote/data image embeds inline; a local path stays a click-to-open link
107
+ // (the board server doesn't serve the source file's directory).
108
+ // ponytail: remote/data images embed; local-relative images open in the app
109
+ // rather than render — add directory serving only if users need inline locals.
110
+ s = s.replace(/!\[([^\]]*)\]\(([^)\s]+)\)/g, (_m, alt, url) => {
111
+ if (/^(https?:|data:image\/)/i.test(url)) return keep(`<img class="md-img" src="${url}" alt="${alt}" loading="lazy">`);
112
+ if (looksLikeLocalPath(url)) return keep(fileLinkHtml(url, alt || url, false));
113
+ return `![${alt}](${url})`; // unknown scheme — leave literal, don't link
114
+ });
98
115
  // links [text](url) — url is already escaped; a local-path target becomes a
99
116
  // click-to-open link, otherwise a normal link (guarding odd schemes).
100
117
  s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
@@ -107,6 +124,10 @@
107
124
  // bold then italic (bold uses ** so must run before single *)
108
125
  s = s.replace(/\*\*([^*]+)\*\*/g, (_m, c) => `<strong>${c}</strong>`);
109
126
  s = s.replace(/\*([^*]+)\*/g, (_m, c) => `<em>${c}</em>`);
127
+ // underscore emphasis __bold__ / _italic_, only at word boundaries so
128
+ // snake_case and RLY_BOARD_ID are left alone (CommonMark intraword rule).
129
+ s = s.replace(/(^|[^A-Za-z0-9_])__([^_]+)__(?![A-Za-z0-9_])/g, (_m, p, c) => `${p}<strong>${c}</strong>`);
130
+ s = s.replace(/(^|[^A-Za-z0-9_])_([^_]+)_(?![A-Za-z0-9_])/g, (_m, p, c) => `${p}<em>${c}</em>`);
110
131
  // restore the stashed HTML — loop so a placeholder nested inside another
111
132
  // stashed fragment (e.g. a code span inside a link's text) is also resolved.
112
133
  let guard = 0;
@@ -373,14 +394,25 @@
373
394
  return out;
374
395
  }
375
396
 
376
- // A line-number gutter element for `text` (aria-hidden + non-selectable so a
377
- // select-to-comment grabs only the code, not the numbers). `startAt` default 1.
378
- function lineGutter(text, startAt) {
397
+ // A line-number gutter for `text` (non-selectable so a select-to-comment grabs
398
+ // only the code, not the numbers). When annotation is on, each number becomes a
399
+ // hover/click target to comment on that exact line (kind:'code-line'); the
400
+ // numbers still don't enter a text selection. `startAt` default 1.
401
+ function lineGutter(text, startAt, ctx, blockId, filename) {
379
402
  const n = Math.max(1, String(text).replace(/\n+$/, '').split('\n').length);
380
- const g = el('div', { class: 'blk-gutter', 'aria-hidden': 'true' });
381
- let s = '';
382
- for (let i = 0; i < n; i++) s += (startAt || 1) + i + '\n';
383
- g.textContent = s;
403
+ const annotate = ctx && ctx.annotate;
404
+ const g = el('div', { class: 'blk-gutter' + (annotate ? ' blk-gutter-c' : ''), 'aria-hidden': annotate ? null : 'true' });
405
+ for (let i = 0; i < n; i++) {
406
+ const ln = (startAt || 1) + i;
407
+ const lineEl = el('div', { class: 'blk-gutterline' }, String(ln));
408
+ if (annotate) {
409
+ annotate.register(lineEl, {
410
+ blockId, questionId: ctx.questionId,
411
+ target: { kind: 'code-line', line: ln, file: filename || '' },
412
+ });
413
+ }
414
+ g.append(lineEl);
415
+ }
384
416
  return g;
385
417
  }
386
418
 
@@ -391,7 +423,7 @@
391
423
  const code = el('code');
392
424
  code.innerHTML = tintCode(raw, block.lang);
393
425
  const pre = el('pre', { class: 'blk-pre', 'data-lang': block.lang || '' }, code);
394
- const row = el('div', { class: 'blk-coderow' }, lineGutter(raw), pre);
426
+ const row = el('div', { class: 'blk-coderow' }, lineGutter(raw, 1, ctx, blockId, block.filename), pre);
395
427
  const wrap = el('div', { class: 'blk-codewrap' });
396
428
  // Optional file-name + language header above the code.
397
429
  if (block.filename || block.lang) {
@@ -513,18 +545,62 @@
513
545
  return el('table', { class: 'blk-difftable blk-difftable-split' }, tbody);
514
546
  }
515
547
 
548
+ // Split a (possibly multi-file) unified diff into per-file chunks. Each
549
+ // `diff --git a/x b/x` starts a new file; the name comes from that line (or a
550
+ // following `+++ b/...`). A plain hunk-only diff (no `diff --git`) is one
551
+ // unnamed file, preserving the single-file path that uses block.filename.
552
+ function splitDiffFiles(text) {
553
+ const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n');
554
+ const files = [];
555
+ let cur = null;
556
+ for (const line of lines) {
557
+ if (/^diff --git /.test(line)) {
558
+ const m = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
559
+ cur = { name: m ? m[2] : 'file', lines: [line] };
560
+ files.push(cur);
561
+ } else {
562
+ if (!cur) { cur = { name: null, lines: [] }; files.push(cur); }
563
+ cur.lines.push(line);
564
+ if (cur.name === null) {
565
+ const pm = line.match(/^\+\+\+ b?\/(.+)$/);
566
+ if (pm) cur.name = pm[1];
567
+ }
568
+ }
569
+ }
570
+ return files.map((f) => ({ name: f.name, text: f.lines.join('\n') }));
571
+ }
572
+
573
+ // Per-file header lines are redundant once we render a filename header bar.
574
+ const DIFF_NOISE = /^(diff --git |index |--- |\+\+\+ )/;
575
+
516
576
  function renderDiff(block, ctx, blockId) {
517
- const rows = parseDiff(block.diff);
577
+ const files = splitDiffFiles(block.diff);
578
+ const named = files.filter((f) => f.name);
579
+ const multi = named.length > 1;
518
580
  let view = block.view === 'split' ? 'split' : 'unified';
519
581
  const scroll = el('div', { class: 'blk-diffscroll' });
582
+ const sections = [];
583
+
520
584
  const paint = () => {
521
- scroll.innerHTML = '';
522
- scroll.append(view === 'split' ? buildSplitDiff(rows, block.lang) : buildUnifiedDiff(rows, block.lang));
585
+ scroll.replaceChildren();
586
+ sections.length = 0;
587
+ files.forEach((f) => {
588
+ const name = f.name || (files.length === 1 ? (block.filename || '') : '');
589
+ let rows = parseDiff(f.text);
590
+ // drop the pure file-header noise when we show a filename bar
591
+ if (name) rows = rows.filter((r) => !(r.kind === 'meta' && DIFF_NOISE.test(r.text)));
592
+ if (!rows.length) return;
593
+ const sec = el('div', { class: 'diff-file' });
594
+ if (name) sec.append(el('div', { class: 'diff-filehead' }, name));
595
+ sec.append(view === 'split' ? buildSplitDiff(rows, block.lang) : buildUnifiedDiff(rows, block.lang));
596
+ scroll.append(sec);
597
+ sections.push({ name, el: sec });
598
+ });
523
599
  };
524
600
  paint();
525
601
 
526
602
  const wrap = el('div', { class: 'blk-codewrap blk-diffwrap' });
527
- // header: file name (left) + a Unified/Split view toggle (right)
603
+ // header: file count / single name (left) + a Unified/Split view toggle (right)
528
604
  const toggle = el('button', { class: 'blk-difftoggle', type: 'button', title: 'Toggle side-by-side view' });
529
605
  const syncToggle = () => { toggle.textContent = view === 'split' ? 'Unified view' : 'Split view'; };
530
606
  syncToggle();
@@ -537,10 +613,26 @@
537
613
  syncToggle();
538
614
  paint();
539
615
  });
616
+ const headLabel = multi ? `${named.length} files changed` : (named[0] && named[0].name) || block.filename || '';
540
617
  wrap.append(el('div', { class: 'blk-codehead' },
541
- el('span', { class: 'blk-codename' }, block.filename || ''),
618
+ el('span', { class: 'blk-codename' }, headLabel),
542
619
  toggle
543
620
  ));
621
+ // jump bar of file chips for a multi-file diff (click scrolls to that file)
622
+ if (multi) {
623
+ const bar = el('div', { class: 'diff-files' });
624
+ named.forEach((f) => {
625
+ const chip = el('button', { type: 'button', class: 'diff-file-chip' }, f.name);
626
+ chip.addEventListener('mousedown', (e) => e.stopPropagation());
627
+ chip.addEventListener('click', (e) => {
628
+ e.stopPropagation();
629
+ const s = sections.find((x) => x.name === f.name);
630
+ if (s) s.el.scrollIntoView({ behavior: 'smooth', block: 'start' });
631
+ });
632
+ bar.append(chip);
633
+ });
634
+ wrap.append(bar);
635
+ }
544
636
  wrap.classList.toggle('is-split', view === 'split');
545
637
  wrap.append(scroll);
546
638
  // select-to-comment stays bound to the stable scroll container across toggles
@@ -615,10 +707,32 @@
615
707
  return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: 'base' });
616
708
  }
617
709
 
710
+ // CSV-encode a value (quote when it contains a comma/quote/newline).
711
+ function csvCell(v) {
712
+ const s = v === undefined || v === null ? '' : String(v);
713
+ return /[",\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s;
714
+ }
715
+ // Trigger a client-side download of `text` as `filename` (browser board). In a
716
+ // sandboxed/MCP host this may be blocked — best effort, no error if it no-ops.
717
+ function downloadText(filename, text, mime) {
718
+ try {
719
+ const blob = new Blob([text], { type: mime || 'text/plain' });
720
+ const url = URL.createObjectURL(blob);
721
+ const a = el('a', { href: url, download: filename });
722
+ document.body.append(a);
723
+ a.click();
724
+ a.remove();
725
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
726
+ } catch (_) { /* sandbox without download permission */ }
727
+ }
728
+
618
729
  function renderTable(block, ctx, blockId) {
619
730
  const cols = normalizeColumns(block.columns);
620
731
  const rows = block.rows || [];
621
732
  const sortable = block.sortable === true;
733
+ const filterable = block.filterable === true;
734
+ const exportable = block.exportable === true;
735
+ let filterText = '';
622
736
  // sortState: column index in cols (-1 none) + dir
623
737
  let sortCol = -1;
624
738
  let sortDir = 0; // 0 none, 1 asc, -1 desc
@@ -647,9 +761,17 @@
647
761
  const tbody = el('tbody');
648
762
  table.append(thead, tbody);
649
763
 
650
- function rebuild() {
651
- // keep original row indices stable across sorts
764
+ // rows (as original indices) passing the current filter — used by both the
765
+ // table body and the CSV export so export honors the visible/filtered set.
766
+ function filteredOrder() {
652
767
  let order = rows.map((_r, idx) => idx);
768
+ if (filterText) {
769
+ const q = filterText.toLowerCase();
770
+ order = order.filter((idx) => cols.some((col) => {
771
+ const v = cellValue(rows[idx], col);
772
+ return v !== undefined && v !== null && String(v).toLowerCase().includes(q);
773
+ }));
774
+ }
653
775
  if (sortable && sortCol >= 0 && sortDir !== 0) {
654
776
  const col = cols[sortCol];
655
777
  order.sort((ia, ib) => {
@@ -657,6 +779,11 @@
657
779
  return sortDir === 1 ? r : -r;
658
780
  });
659
781
  }
782
+ return order;
783
+ }
784
+
785
+ function rebuild() {
786
+ const order = filteredOrder();
660
787
  headCells.forEach((hc, ci) => {
661
788
  hc.arrow.textContent = sortable && ci === sortCol && sortDir !== 0 ? (sortDir === 1 ? ' ↑' : ' ↓') : '';
662
789
  });
@@ -681,7 +808,32 @@
681
808
  rebuild();
682
809
  // Tables are visual too: wrap so they get the full-screen viewer like
683
810
  // every other visual block (wide tables squeeze in the column).
684
- const wrap = el('div', { class: 'blk-tablewrap' }, table);
811
+ const wrap = el('div', { class: 'blk-tablewrap' });
812
+ // Optional toolbar: live filter box + CSV export of the filtered rows.
813
+ if (filterable || exportable) {
814
+ const bar = el('div', { class: 'blk-tablebar' });
815
+ if (filterable) {
816
+ const input = el('input', { type: 'text', class: 'blk-tablefilter', placeholder: 'Filter rows…', 'aria-label': 'Filter table rows' });
817
+ input.addEventListener('input', () => { filterText = input.value.trim(); rebuild(); });
818
+ // don't let a click in the filter start a drag-pan / full-screen
819
+ input.addEventListener('mousedown', (e) => e.stopPropagation());
820
+ bar.append(input);
821
+ }
822
+ if (exportable) {
823
+ const btn = el('button', { type: 'button', class: 'blk-tableexport', title: 'Download the filtered rows as CSV' }, 'CSV');
824
+ btn.addEventListener('mousedown', (e) => e.stopPropagation());
825
+ btn.addEventListener('click', (e) => {
826
+ e.stopPropagation();
827
+ const order = filteredOrder();
828
+ const head = cols.map((c) => csvCell(c.label)).join(',');
829
+ const body = order.map((idx) => cols.map((c) => csvCell(cellValue(rows[idx], c))).join(',')).join('\n');
830
+ downloadText('table.csv', head + '\n' + body + '\n', 'text/csv');
831
+ });
832
+ bar.append(btn);
833
+ }
834
+ wrap.append(bar);
835
+ }
836
+ wrap.append(table);
685
837
  attachViewer(wrap, { zoomEl: null });
686
838
  return wrap;
687
839
  }
@@ -1401,10 +1553,12 @@
1401
1553
  alt: block.alt || 'image',
1402
1554
  loading: 'lazy',
1403
1555
  });
1404
- // The per-block height only caps the COMPACT inline preview the viewer
1405
- // lifts it on zoom / full-screen so the user can always reach full detail.
1406
- const fitMaxHeight = block.height ? clampHeight(block.height, 360) : null;
1407
- if (fitMaxHeight) img.style.maxHeight = fitMaxHeight + 'px';
1556
+ // Inline default: the image fills its full width (container width, never
1557
+ // upscaled past natural) so it's readable without manual zoom; the CONTAINER
1558
+ // caps the height (default 800 via CSS, or block.height) and SCROLLS — we
1559
+ // never cap the image's own height, which would shrink its width. Full-screen
1560
+ // lifts the cap (the .blk-full rule).
1561
+ if (block.height != null) container.style.maxHeight = clampHeight(block.height, 800) + 'px';
1408
1562
  img.addEventListener('error', () => {
1409
1563
  container.replaceChildren(el('div', { class: 'blk-error' }, 'Image failed to load'));
1410
1564
  });
@@ -1415,11 +1569,12 @@
1415
1569
  natural: () => (img.naturalWidth > 0 ? { w: img.naturalWidth, h: img.naturalHeight } : null),
1416
1570
  label: 'image',
1417
1571
  comment: wholeBlockComment(ctx, blockId, 'image'),
1418
- fitMaxHeight,
1419
1572
  });
1420
1573
  if (img.complete && img.naturalWidth > 0) attachImgViewer();
1421
1574
  else img.addEventListener('load', attachImgViewer, { once: true });
1422
- if (ctx.annotate) {
1575
+ if (ctx.annotate && block.pins === true) {
1576
+ enableImagePins(container, img, ctx, blockId, block.alt || 'Image');
1577
+ } else if (ctx.annotate) {
1423
1578
  ctx.annotate.register(img, {
1424
1579
  blockId,
1425
1580
  questionId: ctx.questionId,
@@ -1429,6 +1584,56 @@
1429
1584
  return container;
1430
1585
  }
1431
1586
 
1587
+ // Coordinate pin-comments on an image (block.pins): click any point to drop a
1588
+ // comment anchored to that (x,y) fraction; a numbered pin marks each one and
1589
+ // tracks the image as it scales/scrolls (positions are % of the displayed img,
1590
+ // so they ride zoom/full-screen/pan for free). Mirrors the chart-badge model.
1591
+ function enableImagePins(container, img, ctx, blockId, label) {
1592
+ const layer = el('div', { class: 'blk-imgpins' });
1593
+ container.append(layer);
1594
+ img.classList.add('blk-img-pinnable');
1595
+ let down = null; // {x,y} at pointerdown — used to reject drags (pan) as clicks
1596
+ img.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; });
1597
+ img.addEventListener('click', (e) => {
1598
+ if (down && (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4)) { down = null; return; }
1599
+ const r = img.getBoundingClientRect();
1600
+ if (!r.width || !r.height) return;
1601
+ const x = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width));
1602
+ const y = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height));
1603
+ ctx.annotate.openExternal(
1604
+ { blockId, questionId: ctx.questionId, target: { kind: 'image-point', x: Math.round(x * 1000) / 1000, y: Math.round(y * 1000) / 1000, label } },
1605
+ img
1606
+ );
1607
+ });
1608
+ const syncPins = () => {
1609
+ layer.replaceChildren();
1610
+ const groups = new Map(); // "x:y" -> {target, count}
1611
+ let n = 0;
1612
+ for (const a of ctx.annotate.list()) {
1613
+ if (a.blockId !== blockId) continue;
1614
+ const t = a.target || {};
1615
+ if (t.kind !== 'image-point') continue;
1616
+ const k = t.x + ':' + t.y;
1617
+ const g = groups.get(k) || { target: t, count: 0 };
1618
+ g.count++; groups.set(k, g);
1619
+ }
1620
+ for (const { target, count } of groups.values()) {
1621
+ n++;
1622
+ const pin = el('button', { class: 'blk-imgpin', type: 'button', title: count + (count === 1 ? ' comment' : ' comments') }, String(count));
1623
+ pin.style.left = (target.x * 100) + '%';
1624
+ pin.style.top = (target.y * 100) + '%';
1625
+ pin.addEventListener('mousedown', (e) => e.stopPropagation());
1626
+ pin.addEventListener('click', (e) => {
1627
+ e.stopPropagation(); e.preventDefault();
1628
+ ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, pin);
1629
+ });
1630
+ layer.append(pin);
1631
+ }
1632
+ };
1633
+ if (ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncPins);
1634
+ syncPins();
1635
+ }
1636
+
1432
1637
  // ---------- palette ----------
1433
1638
  // Color palettes as swatch cards: hover a swatch to reveal its hex, click to
1434
1639
  // copy it. One palette may be {featured:true} → a larger spotlight row. Lets
@@ -1488,6 +1693,104 @@
1488
1693
  return wrap;
1489
1694
  }
1490
1695
 
1696
+ // ---------- kpi (stat cards) ----------
1697
+ function renderKpi(block, ctx, blockId) {
1698
+ const wrap = el('div', { class: 'blk-kpi' });
1699
+ if (block.title) wrap.append(el('div', { class: 'kpi-title' }, block.title));
1700
+ const grid = el('div', { class: 'kpi-grid' });
1701
+ for (const it of block.items || []) {
1702
+ const card = el('div', { class: 'kpi-card' });
1703
+ if (it.label) card.append(el('div', { class: 'kpi-label' }, it.label));
1704
+ card.append(el('div', { class: 'kpi-value' }, it.value || ''));
1705
+ if (it.delta) {
1706
+ const glyph = it.dir === 'up' ? '▲ ' : it.dir === 'down' ? '▼ ' : it.dir === 'flat' ? '▬ ' : '';
1707
+ card.append(el('div', { class: 'kpi-delta' + (it.dir ? ' kpi-' + it.dir : '') }, glyph + it.delta));
1708
+ }
1709
+ if (it.sub) card.append(el('div', { class: 'kpi-sub' }, it.sub));
1710
+ if (ctx.annotate) {
1711
+ ctx.annotate.register(card, {
1712
+ blockId, questionId: ctx.questionId,
1713
+ target: { kind: 'html-element', label: ((it.label ? it.label + ' · ' : '') + (it.value || 'KPI')) },
1714
+ });
1715
+ }
1716
+ grid.append(card);
1717
+ }
1718
+ wrap.append(grid);
1719
+ return wrap;
1720
+ }
1721
+
1722
+ // ---------- typography (type specimens) ----------
1723
+ function renderTypography(block, ctx, blockId) {
1724
+ const wrap = el('div', { class: 'blk-typography' });
1725
+ if (block.title) wrap.append(el('div', { class: 'typo-title' }, block.title));
1726
+ for (const s of block.specimens || []) {
1727
+ const meta = [];
1728
+ if (s.label) meta.push(s.label);
1729
+ if (s.size) meta.push(s.size);
1730
+ if (s.weight) meta.push('w' + s.weight);
1731
+ const sample = el('div', { class: 'typo-sample' }, s.text);
1732
+ // set via DOM style props (browser ignores invalid values — no injection)
1733
+ if (s.size) sample.style.fontSize = s.size;
1734
+ if (s.weight) sample.style.fontWeight = s.weight;
1735
+ if (s.font || block.font) sample.style.fontFamily = s.font || block.font;
1736
+ if (s.lineHeight) sample.style.lineHeight = s.lineHeight;
1737
+ if (s.letterSpacing) sample.style.letterSpacing = s.letterSpacing;
1738
+ wrap.append(el('div', { class: 'typo-row' },
1739
+ meta.length ? el('div', { class: 'typo-meta' }, meta.join(' · ')) : null,
1740
+ sample
1741
+ ));
1742
+ }
1743
+ ctx.annotate && ctx.annotate.enableTextSelection(wrap, { blockId, questionId: ctx.questionId });
1744
+ return wrap;
1745
+ }
1746
+
1747
+ // ---------- compare (before/after image slider) ----------
1748
+ // The "after" image is the base layer (defines the frame size); the "before"
1749
+ // image sits in an overflow-clipped layer whose width the divider drives, so
1750
+ // dragging reveals more of one image and less of the other.
1751
+ function renderCompare(block, ctx, blockId) {
1752
+ const wrap = el('div', { class: 'blk-comparewrap' });
1753
+ const frame = el('div', { class: 'cmp-frame' });
1754
+ if (block.height) frame.style.maxHeight = clampHeight(block.height, 1200) + 'px';
1755
+ const afterImg = el('img', { class: 'cmp-img', src: block.after, alt: block.afterLabel || 'after', draggable: 'false', loading: 'lazy' });
1756
+ const beforeImg = el('img', { class: 'cmp-img cmp-img-before', src: block.before, alt: block.beforeLabel || 'before', draggable: 'false', loading: 'lazy' });
1757
+ const beforeLayer = el('div', { class: 'cmp-before' }, beforeImg);
1758
+ const handle = el('div', { class: 'cmp-handle', tabindex: '0', role: 'slider', 'aria-label': 'Compare before and after' },
1759
+ el('div', { class: 'cmp-handle-grip' }, '⟺'));
1760
+ frame.append(
1761
+ afterImg, beforeLayer, handle,
1762
+ el('span', { class: 'cmp-tag cmp-tag-before' }, block.beforeLabel || 'Before'),
1763
+ el('span', { class: 'cmp-tag cmp-tag-after' }, block.afterLabel || 'After')
1764
+ );
1765
+ wrap.append(frame);
1766
+
1767
+ let pos = 50;
1768
+ const apply = () => { beforeLayer.style.width = pos + '%'; handle.style.left = pos + '%'; };
1769
+ // pin the before image to the frame's pixel width so the clip reveals it in
1770
+ // place (rather than squishing it as the clip narrows)
1771
+ const sizeBefore = () => { beforeImg.style.width = frame.clientWidth + 'px'; };
1772
+ const refresh = () => { sizeBefore(); apply(); };
1773
+ if (afterImg.complete && afterImg.naturalWidth) refresh();
1774
+ else afterImg.addEventListener('load', refresh, { once: true });
1775
+ window.addEventListener('resize', sizeBefore);
1776
+
1777
+ let dragging = false;
1778
+ const setFromX = (clientX) => {
1779
+ const r = frame.getBoundingClientRect();
1780
+ if (r.width) { pos = Math.max(0, Math.min(100, ((clientX - r.left) / r.width) * 100)); apply(); }
1781
+ };
1782
+ frame.addEventListener('pointerdown', (e) => { dragging = true; setFromX(e.clientX); try { frame.setPointerCapture(e.pointerId); } catch (_) {} e.preventDefault(); });
1783
+ frame.addEventListener('pointermove', (e) => { if (dragging) setFromX(e.clientX); });
1784
+ frame.addEventListener('pointerup', () => { dragging = false; });
1785
+ frame.addEventListener('pointercancel', () => { dragging = false; });
1786
+ handle.addEventListener('keydown', (e) => {
1787
+ if (e.key === 'ArrowLeft') { pos = Math.max(0, pos - 2); apply(); e.preventDefault(); }
1788
+ else if (e.key === 'ArrowRight') { pos = Math.min(100, pos + 2); apply(); e.preventDefault(); }
1789
+ });
1790
+ attachViewer(wrap, { zoomEl: null, label: 'comparison', comment: wholeBlockComment(ctx, blockId, 'comparison') });
1791
+ return wrap;
1792
+ }
1793
+
1491
1794
  // ---------- viewer controls (zoom / fit / full-screen) ----------
1492
1795
  // Large diagrams get squeezed to the column width; these controls let the
1493
1796
  // user zoom (buttons or cmd/ctrl+wheel) and expand any visual block into a
@@ -1525,6 +1828,91 @@
1525
1828
  if (e.key === 'Escape' && fullOpen) exitFull();
1526
1829
  });
1527
1830
 
1831
+ // Enter full-screen for a viewer container (shared by the toolbar button and
1832
+ // the markdown reference-link → modal path).
1833
+ function enterFull(container) {
1834
+ if (fullOpen === container) return;
1835
+ exitFull();
1836
+ container.classList.add('blk-full');
1837
+ document.body.classList.add('blk-full-open');
1838
+ fullOpen = container;
1839
+ const btn = container.querySelector('.blk-tools .tool-full');
1840
+ if (btn) btn.textContent = '✕';
1841
+ window.dispatchEvent(new Event('resize'));
1842
+ if (container._rlyToolsSync) container._rlyToolsSync();
1843
+ if (container._rlyZoom) container._rlyZoom.reapply();
1844
+ }
1845
+
1846
+ // CSS.escape fallback for attribute-selector building from a block id/ref.
1847
+ function cssEsc(s) {
1848
+ return window.CSS && CSS.escape ? CSS.escape(String(s)) : String(s).replace(/["\\\]]/g, '\\$&');
1849
+ }
1850
+
1851
+ // Open a block as a modal: full-screen its viewer if it has one (chart, image,
1852
+ // diagram, table, compare, code…); otherwise scroll to it and flash. `host` is
1853
+ // the .blk[data-block-id] wrapper. Returns true if it found something to show.
1854
+ function openFullEl(host) {
1855
+ if (!host) return false;
1856
+ const viewer = host.classList.contains('blk-viewer') ? host : host.querySelector('.blk-viewer');
1857
+ // Visuals open as a full-screen modal — context-safe (an overlay; × returns
1858
+ // to the exact spot). Non-modal blocks (kpi/markdown/table-less) have no
1859
+ // viewer, so we jump + highlight AND show a "↩ Back" button so the user can
1860
+ // return after the jump — no lost context across the data↔question distance.
1861
+ if (viewer && viewer._rlyTools) { enterFull(viewer); return true; }
1862
+ const fromY = window.scrollY || window.pageYOffset || 0;
1863
+ host.scrollIntoView({ behavior: 'smooth', block: 'center' });
1864
+ host.classList.remove('rly-refflash');
1865
+ void host.offsetWidth;
1866
+ host.classList.add('rly-refflash');
1867
+ setTimeout(() => host.classList.remove('rly-refflash'), 1200);
1868
+ showRefBack(fromY);
1869
+ return true;
1870
+ }
1871
+
1872
+ // A floating "↩ Back" pill that returns to the pre-jump scroll position. Self-
1873
+ // removes once the user is back near where they started, or after a timeout.
1874
+ let refBackBtn = null;
1875
+ function showRefBack(fromY) {
1876
+ if (refBackBtn) refBackBtn.remove();
1877
+ const btn = el('button', { class: 'rly-refback', type: 'button', title: 'Back to where you were' }, '↩ Back');
1878
+ const cleanup = () => { btn.remove(); window.removeEventListener('scroll', onScroll); if (refBackBtn === btn) refBackBtn = null; };
1879
+ const onScroll = () => { if (Math.abs((window.scrollY || 0) - fromY) < 120) cleanup(); };
1880
+ btn.addEventListener('click', () => { window.scrollTo({ top: fromY, behavior: 'smooth' }); cleanup(); });
1881
+ window.addEventListener('scroll', onScroll, { passive: true });
1882
+ document.body.append(btn);
1883
+ refBackBtn = btn;
1884
+ setTimeout(() => { if (refBackBtn === btn) cleanup(); }, 12000);
1885
+ }
1886
+ function openFull(blockId) {
1887
+ return openFullEl(document.querySelector('[data-block-id="' + cssEsc(blockId) + '"]'));
1888
+ }
1889
+
1890
+ // Delegated handler for markdown reference links (data-rly-ref / data-rly-refid).
1891
+ function initRefLinks() {
1892
+ if (window.__relayRefLinksReady) return;
1893
+ window.__relayRefLinksReady = true;
1894
+ const open = (a) => {
1895
+ const host = a.dataset.rlyRefid
1896
+ ? document.querySelector('[data-block-id="' + cssEsc(a.dataset.rlyRefid) + '"]')
1897
+ : document.querySelector('[data-block-ref="' + cssEsc(a.dataset.rlyRef) + '"]');
1898
+ if (host) openFullEl(host);
1899
+ else fileToast('Referenced visual not found on this board', 'err');
1900
+ };
1901
+ document.addEventListener('click', (e) => {
1902
+ const a = e.target.closest ? e.target.closest('a.rly-reflink') : null;
1903
+ if (!a) return;
1904
+ e.preventDefault();
1905
+ open(a);
1906
+ });
1907
+ document.addEventListener('keydown', (e) => {
1908
+ if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
1909
+ const a = e.target.closest ? e.target.closest('a.rly-reflink') : null;
1910
+ if (!a) return;
1911
+ e.preventDefault();
1912
+ open(a);
1913
+ });
1914
+ }
1915
+
1528
1916
  // Drag-to-pan: grab anywhere on a scrollable visual (a tall/zoomed diagram or
1529
1917
  // oversized image) and drag to move across it, instead of hunting for the
1530
1918
  // scrollbars. Self-gates on overflow, so charts and html iframes — which are
@@ -1651,7 +2039,9 @@
1651
2039
  // (contain — enlarge a small one, shrink a big one) so it's readable at
1652
2040
  // a glance without reaching for the zoom buttons. CSS margin:auto centers
1653
2041
  // it; it stays pannable once the user zooms past this fit.
1654
- const availW = window.innerWidth - FULL_PAD_X * 2;
2042
+ // subtract the docked comment rail (≥980px) so the fit doesn't run under it
2043
+ const railInset = (document.body.classList.contains('ann-rail-open') && window.innerWidth >= 980) ? 340 : 0;
2044
+ const availW = window.innerWidth - railInset - FULL_PAD_X * 2;
1655
2045
  const availH = window.innerHeight - FULL_PAD_TOP - FULL_PAD_BOTTOM;
1656
2046
  const scale = Math.min(availW / nat.w, availH / nat.h);
1657
2047
  target.style.maxWidth = 'none';
@@ -1733,15 +2123,8 @@
1733
2123
  fullBtn.innerHTML = ICON_EXPAND;
1734
2124
  fullBtn.addEventListener('click', (e) => {
1735
2125
  e.stopPropagation();
1736
- if (fullOpen === container) { exitFull(); return; }
1737
- exitFull();
1738
- container.classList.add('blk-full');
1739
- document.body.classList.add('blk-full-open');
1740
- fullOpen = container;
1741
- fullBtn.textContent = '✕';
1742
- window.dispatchEvent(new Event('resize'));
1743
- container._rlyToolsSync();
1744
- if (container._rlyZoom) container._rlyZoom.reapply(); // lift any inline height cap
2126
+ if (fullOpen === container) exitFull();
2127
+ else enterFull(container);
1745
2128
  });
1746
2129
  tools.append(fullBtn);
1747
2130
 
@@ -1782,6 +2165,7 @@
1782
2165
  function renderBlock(block, ctx) {
1783
2166
  const blockId = block.id;
1784
2167
  const wrapper = el('div', { class: 'blk blk-' + block.type, 'data-block-id': blockId });
2168
+ if (block.ref) wrapper.setAttribute('data-block-ref', block.ref);
1785
2169
  let inner = null;
1786
2170
  switch (block.type) {
1787
2171
  case 'markdown': {
@@ -1834,6 +2218,18 @@
1834
2218
  inner = renderPalette(block, ctx, blockId);
1835
2219
  wrapper.append(inner);
1836
2220
  break;
2221
+ case 'kpi':
2222
+ inner = renderKpi(block, ctx, blockId);
2223
+ wrapper.append(inner);
2224
+ break;
2225
+ case 'typography':
2226
+ inner = renderTypography(block, ctx, blockId);
2227
+ wrapper.append(inner);
2228
+ break;
2229
+ case 'compare':
2230
+ inner = renderCompare(block, ctx, blockId);
2231
+ wrapper.append(inner);
2232
+ break;
1837
2233
  default:
1838
2234
  wrapper.append(el('div', { class: 'blk-error' }, 'Unknown block type: ' + esc(String(block.type))));
1839
2235
  }
@@ -1919,6 +2315,7 @@
1919
2315
  });
1920
2316
  }
1921
2317
  initFileLinks();
2318
+ initRefLinks();
1922
2319
 
1923
- window.RelayBlocks = { render, onThemeChange, renderMarkdown };
2320
+ window.RelayBlocks = { render, onThemeChange, renderMarkdown, openFull };
1924
2321
  })();