@khanglvm/relay 0.10.3 → 0.12.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
@@ -88,6 +88,31 @@
88
88
  }
89
89
  let themeBtn = null;
90
90
 
91
+ // ---------- font scale (A− / A+) ----------
92
+ // A page-zoom knob: scales the root font-size (every rem) so the whole board
93
+ // grows/shrinks. Persists like the theme — server pref + localStorage mirror —
94
+ // so the choice carries across the random-port boards.
95
+ const FS_KEY = 'qb-fontscale';
96
+ const FS_MIN = 0.85, FS_MAX = 1.5, FS_STEP = 0.1;
97
+ let fontScale = Number((boot.pref && boot.pref.fontScale) || localStorage.getItem(FS_KEY));
98
+ if (!Number.isFinite(fontScale) || fontScale <= 0) fontScale = 1;
99
+ const clampFs = (n) => Math.min(FS_MAX, Math.max(FS_MIN, Math.round(n * 100) / 100));
100
+ fontScale = clampFs(fontScale);
101
+ function applyFontScale() {
102
+ document.documentElement.style.setProperty('--fs', String(fontScale));
103
+ }
104
+ function setFontScale(next) {
105
+ fontScale = clampFs(next);
106
+ applyFontScale();
107
+ try { localStorage.setItem(FS_KEY, String(fontScale)); } catch { /* private mode */ }
108
+ fetch('/api/pref', {
109
+ method: 'POST',
110
+ headers: { 'content-type': 'application/json' },
111
+ body: JSON.stringify({ fontScale }),
112
+ }).catch(() => {});
113
+ }
114
+ applyFontScale();
115
+
91
116
  // ---------- localStorage draft mirror ----------
92
117
  // Every autosave is ALSO written to localStorage, keyed by board id. This is
93
118
  // the durability layer the server file alone can't provide: if the connection
@@ -698,6 +723,38 @@
698
723
  return input;
699
724
  }
700
725
 
726
+ // Normalize any CSS color to the #rrggbb the native <input type=color> needs.
727
+ function toHex6(c) {
728
+ const s = String(c || '').trim();
729
+ let m = /^#?([0-9a-fA-F]{6})$/.exec(s);
730
+ if (m) return '#' + m[1].toLowerCase();
731
+ m = /^#?([0-9a-fA-F]{3})$/.exec(s);
732
+ if (m) return '#' + m[1].split('').map((x) => x + x).join('').toLowerCase();
733
+ return '#000000';
734
+ }
735
+ function controlColor(q) {
736
+ const wrap = el('div', { class: 'colorpick' });
737
+ const init = (typeof state.answers[q.id] === 'string' && state.answers[q.id]) || (typeof q.default === 'string' ? q.default : '');
738
+ const swatch = el('input', { type: 'color', class: 'colorswatch' });
739
+ const hex = el('input', { type: 'text', class: 'colorhex', placeholder: q.placeholder || '#rrggbb', spellcheck: 'false', autocapitalize: 'off' });
740
+ swatch.value = toHex6(init || '#888888');
741
+ if (init) { hex.value = init; state.answers[q.id] = init; }
742
+ const set = (val) => { state.answers[q.id] = val; clearErr(q.id); scheduleSave(); };
743
+ swatch.addEventListener('input', () => { hex.value = swatch.value; set(swatch.value); });
744
+ 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); });
745
+ wrap.append(el('div', { class: 'colorrow' }, swatch, hex));
746
+ if (Array.isArray(q.presets) && q.presets.length) {
747
+ const presets = el('div', { class: 'colorpresets' });
748
+ for (const c of q.presets) {
749
+ const b = el('button', { type: 'button', class: 'colorpreset', style: 'background:' + c, title: c });
750
+ b.addEventListener('click', () => { swatch.value = toHex6(c); hex.value = c; set(c); });
751
+ presets.append(b);
752
+ }
753
+ wrap.append(presets);
754
+ }
755
+ return wrap;
756
+ }
757
+
701
758
  // ---------- render ----------
702
759
  themeBtn = el('button', { class: 'theme-btn', type: 'button' }, '');
703
760
  themeBtn.addEventListener('click', () => {
@@ -712,6 +769,12 @@
712
769
  });
713
770
  applyTheme();
714
771
 
772
+ const fsDown = el('button', { class: 'fs-btn', type: 'button', title: 'Smaller text', 'aria-label': 'Decrease text size' }, 'A−');
773
+ const fsUp = el('button', { class: 'fs-btn', type: 'button', title: 'Larger text', 'aria-label': 'Increase text size' }, 'A+');
774
+ fsDown.addEventListener('click', () => setFontScale(fontScale - FS_STEP));
775
+ fsUp.addEventListener('click', () => setFontScale(fontScale + FS_STEP));
776
+ const fsCtrl = el('div', { class: 'fs-ctrl' }, fsDown, fsUp);
777
+
715
778
  // Just reloaded after a live `rly update`? Flag set before reload below.
716
779
  try {
717
780
  if (sessionStorage.getItem('relay-updated') === '1') {
@@ -723,7 +786,7 @@
723
786
  }
724
787
 
725
788
  const titleEl = el('h1', {}, spec.title);
726
- app.append(el('header', { class: 'qb-header' }, titleEl, themeBtn));
789
+ app.append(el('header', { class: 'qb-header' }, titleEl, el('div', { class: 'qb-controls' }, fsCtrl, themeBtn)));
727
790
  // The board title is commentable too: hovering it shows the pin, like the
728
791
  // intro and every other content element. (themeBtn stays out of it.)
729
792
  if (spec.title) {
@@ -757,6 +820,7 @@
757
820
  else if (q.type === 'multi') control.append(controlMulti(q));
758
821
  else if (q.type === 'yesno') control.append(segButtons(q, ['yes', 'no'], ['Yes', 'No']));
759
822
  else if (q.type === 'scale') control.append(controlScale(q));
823
+ else if (q.type === 'color') control.append(controlColor(q));
760
824
  else control.append(controlText(q, q.type === 'textarea'));
761
825
  card.append(control);
762
826
  if (q.note) {
@@ -988,6 +1052,24 @@
988
1052
  }
989
1053
 
990
1054
  // ---------- heartbeat ----------
1055
+ // True when the user is actively typing — an open annotation comment popover,
1056
+ // or focus in any text-entry field (comment box, notes, text/color answers,
1057
+ // "Other" inputs). Used to defer the live-update reload so in-progress
1058
+ // comments aren't destroyed mid-keystroke.
1059
+ function isEditable(node) {
1060
+ if (!node) return false;
1061
+ if (node.tagName === 'TEXTAREA') return true;
1062
+ if (node.tagName === 'INPUT') {
1063
+ const t = (node.type || 'text').toLowerCase();
1064
+ return ['text', 'search', 'email', 'url', 'tel', 'number', 'color'].includes(t);
1065
+ }
1066
+ return node.isContentEditable === true;
1067
+ }
1068
+ function userIsComposing() {
1069
+ if (window.RelayAnnotate && typeof RelayAnnotate.isComposing === 'function' && RelayAnnotate.isComposing()) return true;
1070
+ return isEditable(document.activeElement);
1071
+ }
1072
+
991
1073
  let misses = 0;
992
1074
  let reloading = false;
993
1075
  let hb = null;
@@ -1021,6 +1103,16 @@
1021
1103
  );
1022
1104
  }
1023
1105
  if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
1106
+ // Don't yank the board out from under someone mid-comment: an open
1107
+ // annotation popover (its add-comment box + reply inputs) holds text
1108
+ // that isn't in the draft until Save, so a reload would discard it —
1109
+ // and reloading while any field is focused drops the user's cursor and
1110
+ // last keystrokes. Defer until they're done; the heartbeat re-checks
1111
+ // every tick, so the update applies the moment they close/blur.
1112
+ if (userIsComposing()) {
1113
+ showNotice('The agent updated this board — it’ll refresh as soon as you finish your comment.', 'info');
1114
+ return;
1115
+ }
1024
1116
  reloading = true;
1025
1117
  stopHeartbeat();
1026
1118
  clearTimeout(saveTimer);
package/src/ui/blocks.css CHANGED
@@ -491,6 +491,21 @@ body.blk-full-open { overflow: hidden; }
491
491
  height: calc(100vh - 44px) !important; width: 100%;
492
492
  margin: 0; border: 0; border-radius: 0;
493
493
  }
494
+ /* Full-screen diagrams/images: apply() sizes the child to fit the viewport
495
+ (FULL_PAD_* in blocks.js mirrors this padding). margin:auto on the single
496
+ child centers it on both axes AND keeps it scrollable when zoomed past the
497
+ fit — plain flex centering would clip the top/left and trap the overflow. */
498
+ .blk-mermaid.blk-full,
499
+ .blk-graphviz.blk-full,
500
+ .blk-plantuml.blk-full,
501
+ .blk-imagewrap.blk-full {
502
+ display: flex; padding: 44px 24px 24px;
503
+ }
504
+ .blk-mermaid.blk-full > svg,
505
+ .blk-graphviz.blk-full > svg,
506
+ .blk-plantuml.blk-full > svg,
507
+ .blk-plantuml.blk-full > .blk-plantuml-img,
508
+ .blk-imagewrap.blk-full > .blk-img { margin: auto; }
494
509
 
495
510
  /* Small frame on charts + mermaid so they read as a deliberate card, matching
496
511
  the graphviz/plantuml/image blocks (already framed). Scoped to the inner
@@ -545,3 +560,28 @@ body.blk-full-open { overflow: hidden; }
545
560
  /* code blocks reuse the viewer toolbar (comment + full-screen); the pre keeps
546
561
  its own card, the wrapper only hosts the toolbar strip */
547
562
  .blk-codewrap { position: relative; }
563
+
564
+ /* ---------- palette block (swatch cards; hover reveals hex, click copies) ---------- */
565
+ .blk-palette .pal-title { font-size: 11px; font-weight: 600; letter-spacing: .1em; text-transform: uppercase; color: var(--muted); margin: 2px 0 10px; }
566
+ .blk-palette .pal-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 14px; }
567
+ .pal-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; background: var(--card); }
568
+ .pal-card.pal-spotlight { margin-bottom: 16px; }
569
+ .pal-swatches { display: flex; height: 72px; }
570
+ .pal-spotlight .pal-swatches { height: 104px; }
571
+ .pal-sw { flex: 1; position: relative; cursor: pointer; border: 0; padding: 0; transition: flex .2s var(--ease); }
572
+ .pal-sw:hover { flex: 1.6; }
573
+ .pal-hex { position: absolute; bottom: 7px; left: 50%; transform: translateX(-50%); font: 600 10px/1 var(--mono, ui-monospace, "SF Mono", monospace); background: rgba(0,0,0,.42); color: #fff; padding: 2px 6px; border-radius: 4px; white-space: nowrap; opacity: 0; transition: opacity .15s; pointer-events: none; }
574
+ .pal-sw:hover .pal-hex, .pal-sw.copied .pal-hex { opacity: 1; }
575
+ .pal-sw.copied::after { content: "Copied"; position: absolute; top: 6px; left: 50%; transform: translateX(-50%); font: 600 9px/1 var(--sans); background: var(--ok); color: #fff; padding: 2px 7px; border-radius: 4px; }
576
+ .pal-info { padding: 10px 14px; display: flex; justify-content: space-between; align-items: center; gap: 10px; }
577
+ .pal-spotlight .pal-info { padding: 12px 16px; }
578
+ .pal-name { font-size: 14px; font-weight: 600; color: var(--fg); }
579
+ .pal-spotlight .pal-name { font-size: 15px; }
580
+ .pal-sub { font-size: 12px; color: var(--muted); margin-top: 2px; }
581
+ .pal-tag { flex: none; font-size: 11px; font-weight: 600; padding: 3px 11px; border-radius: 20px; background: var(--bg-sunken); color: var(--fg-2); }
582
+ .pal-tag.tone-warm { background: #FAECE7; color: #993C1D; }
583
+ .pal-tag.tone-cool { background: #E6F1FB; color: #185FA5; }
584
+ .pal-tag.tone-neutral { background: #F1EFE8; color: #5F5E5A; }
585
+ .pal-tag.tone-nature { background: #EAF3DE; color: #3B6D11; }
586
+ .pal-tag.tone-bold { background: #FBEAF0; color: #72243E; }
587
+ .pal-tag.tone-digital { background: #EEEDFE; color: #3C3489; }
package/src/ui/blocks.js CHANGED
@@ -31,7 +31,9 @@
31
31
 
32
32
  // ---------- chart palette (from contract) ----------
33
33
  const PALETTE_LIGHT = ['#c2674b', '#4d8a66', '#5a7ca8', '#b9913f', '#8a6da3', '#57534e'];
34
- const PALETTE_DARK = ['#d98e67', '#6fbf92', '#7c9fcc', '#d4b061', '#ad8fc2', '#a29c93'];
34
+ // Brighter/more saturated than the surfaces behind them so series stay legible
35
+ // on the dark card (#282624) — the muted set washed out at a glance.
36
+ const PALETTE_DARK = ['#e8a07a', '#74cfa0', '#88b4e8', '#e3c46a', '#c4a0db', '#c2b9ad'];
35
37
 
36
38
  function cssVar(name) {
37
39
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
@@ -743,7 +745,8 @@
743
745
 
744
746
  function themeDefaults(ctx) {
745
747
  const grid = cssVar('--border') || '#ece9e4';
746
- const tick = cssVar('--muted') || '#8a8580';
748
+ // axis/legend/title text: --fg-2 reads clearly on dark; --muted was too dim
749
+ const tick = cssVar('--fg-2') || '#57534e';
747
750
  const fontFamily = cssVar('--sans') || 'sans-serif';
748
751
  return { grid, tick, fontFamily };
749
752
  }
@@ -1426,6 +1429,65 @@
1426
1429
  return container;
1427
1430
  }
1428
1431
 
1432
+ // ---------- palette ----------
1433
+ // Color palettes as swatch cards: hover a swatch to reveal its hex, click to
1434
+ // copy it. One palette may be {featured:true} → a larger spotlight row. Lets
1435
+ // an agent present a curated palette with one structured block instead of
1436
+ // hand-writing HTML. Swatches and the whole block are commentable.
1437
+ function copyColor(text, btn) {
1438
+ const done = () => {
1439
+ btn.classList.add('copied');
1440
+ setTimeout(() => btn.classList.remove('copied'), 900);
1441
+ };
1442
+ try {
1443
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1444
+ navigator.clipboard.writeText(text).then(done, () => {});
1445
+ return;
1446
+ }
1447
+ } catch { /* fall through */ }
1448
+ done(); // no clipboard (sandbox) — still flash feedback
1449
+ }
1450
+
1451
+ function paletteCard(p, big, ctx, blockId) {
1452
+ const card = el('div', { class: 'pal-card' + (big ? ' pal-spotlight' : '') });
1453
+ const row = el('div', { class: 'pal-swatches' });
1454
+ for (const c of p.colors) {
1455
+ const sw = el('button', { class: 'pal-sw', type: 'button', style: 'background:' + c, title: 'Copy ' + c });
1456
+ sw.append(el('span', { class: 'pal-hex' }, c));
1457
+ sw.addEventListener('click', () => copyColor(c, sw));
1458
+ if (ctx.annotate) {
1459
+ ctx.annotate.register(sw, {
1460
+ blockId,
1461
+ questionId: ctx.questionId,
1462
+ target: { kind: 'swatch', label: (p.name ? p.name + ' · ' : '') + c },
1463
+ });
1464
+ }
1465
+ row.append(sw);
1466
+ }
1467
+ card.append(row);
1468
+ const info = el('div', { class: 'pal-info' });
1469
+ const meta = el('div', { class: 'pal-meta' });
1470
+ if (p.name) meta.append(el('div', { class: 'pal-name' }, p.name));
1471
+ if (p.sub) meta.append(el('div', { class: 'pal-sub' }, p.sub));
1472
+ info.append(meta);
1473
+ if (p.tag) info.append(el('span', { class: 'pal-tag' + (p.tagTone ? ' tone-' + p.tagTone : '') }, p.tag));
1474
+ if (p.name || p.sub || p.tag) card.append(info);
1475
+ return card;
1476
+ }
1477
+
1478
+ function renderPalette(block, ctx, blockId) {
1479
+ const wrap = el('div', { class: 'blk-palette' });
1480
+ const palettes = Array.isArray(block.palettes) ? block.palettes : [];
1481
+ if (block.title) wrap.append(el('div', { class: 'pal-title' }, block.title));
1482
+ const grid = el('div', { class: 'pal-grid' });
1483
+ for (const p of palettes) {
1484
+ if (p.featured) wrap.append(paletteCard(p, true, ctx, blockId));
1485
+ else grid.append(paletteCard(p, false, ctx, blockId));
1486
+ }
1487
+ if (grid.childNodes.length) wrap.append(grid);
1488
+ return wrap;
1489
+ }
1490
+
1429
1491
  // ---------- viewer controls (zoom / fit / full-screen) ----------
1430
1492
  // Large diagrams get squeezed to the column width; these controls let the
1431
1493
  // user zoom (buttons or cmd/ctrl+wheel) and expand any visual block into a
@@ -1433,6 +1495,9 @@
1433
1495
  // the annotation pins/badges/popover usable while expanded (they live on
1434
1496
  // <body>, which native fullscreen would hide).
1435
1497
  let fullOpen = null; // container currently expanded
1498
+ // full-screen content insets — must mirror the .blk-full padding in blocks.css
1499
+ // so the fit-to-screen scale clears the fixed toolbar + leaves a small margin.
1500
+ const FULL_PAD_X = 24, FULL_PAD_TOP = 44, FULL_PAD_BOTTOM = 24;
1436
1501
 
1437
1502
  // Toolbar icons: full-screen (4-corner expand) and a speech-bubble for the
1438
1503
  // "comment on the whole block" button. Zoom is both cmd/ctrl+wheel AND
@@ -1574,14 +1639,26 @@
1574
1639
  const target = opts.zoomEl;
1575
1640
  const nat = opts.natural();
1576
1641
  const z = container._rlyZ;
1642
+ const full = container.classList.contains('blk-full');
1577
1643
  // A per-block height caps only the compact inline preview. The moment the
1578
1644
  // user zooms in (z !== null) or goes full-screen they want pixel detail, so
1579
1645
  // lift the cap there; restore it when back to the inline fit view.
1580
1646
  if (opts.fitMaxHeight != null) {
1581
- const fit = z === null && !container.classList.contains('blk-full');
1582
- target.style.maxHeight = fit ? opts.fitMaxHeight + 'px' : 'none';
1647
+ target.style.maxHeight = z === null && !full ? opts.fitMaxHeight + 'px' : 'none';
1583
1648
  }
1584
- if (z === null || !nat || !nat.w) {
1649
+ if (z === null && full && nat && nat.w && nat.h) {
1650
+ // Full-screen default: scale the diagram/image to fill the viewport
1651
+ // (contain — enlarge a small one, shrink a big one) so it's readable at
1652
+ // a glance without reaching for the zoom buttons. CSS margin:auto centers
1653
+ // it; it stays pannable once the user zooms past this fit.
1654
+ const availW = window.innerWidth - FULL_PAD_X * 2;
1655
+ const availH = window.innerHeight - FULL_PAD_TOP - FULL_PAD_BOTTOM;
1656
+ const scale = Math.min(availW / nat.w, availH / nat.h);
1657
+ target.style.maxWidth = 'none';
1658
+ target.style.width = Math.max(1, Math.round(nat.w * scale)) + 'px';
1659
+ target.style.height = 'auto';
1660
+ pct.textContent = 'fit';
1661
+ } else if (z === null || !nat || !nat.w) {
1585
1662
  target.style.width = '100%';
1586
1663
  target.style.maxWidth = nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%';
1587
1664
  target.style.height = 'auto';
@@ -1753,6 +1830,10 @@
1753
1830
  inner = renderImage(block, ctx, blockId);
1754
1831
  wrapper.append(inner);
1755
1832
  break;
1833
+ case 'palette':
1834
+ inner = renderPalette(block, ctx, blockId);
1835
+ wrapper.append(inner);
1836
+ break;
1756
1837
  default:
1757
1838
  wrapper.append(el('div', { class: 'blk-error' }, 'Unknown block type: ' + esc(String(block.type))));
1758
1839
  }
package/src/ui/style.css CHANGED
@@ -68,14 +68,17 @@
68
68
 
69
69
  * { box-sizing: border-box; }
70
70
  html, body { margin: 0; padding: 0; }
71
+ /* Root font size drives every rem on the page; --fs is the user's text-size
72
+ knob (A−/A+ next to the theme toggle). Base bumped 16→17px for readability. */
73
+ html { font-size: calc(17px * var(--fs, 1)); }
71
74
  body {
72
75
  background: var(--bg);
73
76
  color: var(--fg);
74
- font: 16px/1.6 var(--sans);
77
+ font: 1rem/1.6 var(--sans);
75
78
  -webkit-font-smoothing: antialiased;
76
79
  transition: background 200ms var(--ease), color 200ms var(--ease), padding 240ms var(--ease);
77
80
  }
78
- .wrap { max-width: 860px; margin: 0 auto; padding: 32px 20px 28px; }
81
+ .wrap { max-width: 1280px; margin: 0 auto; padding: 32px 20px 28px; }
79
82
 
80
83
  .qb-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 4px; }
81
84
  h1 {
@@ -101,6 +104,18 @@ h1 {
101
104
  transition: border-color 150ms var(--ease), color 150ms var(--ease);
102
105
  }
103
106
  .theme-btn:hover { border-color: var(--accent); color: var(--accent); }
107
+ /* header controls: font-size knob + theme toggle, grouped at the right */
108
+ .qb-controls { display: flex; align-items: center; gap: 8px; flex: none; }
109
+ .fs-ctrl { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 999px; overflow: hidden; }
110
+ .fs-btn {
111
+ background: transparent; color: var(--fg-2); border: 0;
112
+ padding: 6px 11px; cursor: pointer; line-height: 1; font-family: var(--sans);
113
+ transition: color 150ms var(--ease), background 150ms var(--ease);
114
+ }
115
+ .fs-btn:first-child { font-size: 0.72rem; }
116
+ .fs-btn:last-child { font-size: 0.92rem; }
117
+ .fs-btn + .fs-btn { border-left: 1px solid var(--border-strong); }
118
+ .fs-btn:hover { color: var(--accent); background: var(--accent-soft); }
104
119
 
105
120
  .card {
106
121
  background: var(--card);
@@ -311,3 +326,14 @@ textarea { min-height: 90px; resize: vertical; }
311
326
  @media (prefers-reduced-motion: reduce) {
312
327
  * { transition: none !important; animation: none !important; }
313
328
  }
329
+
330
+ /* ---------- color picker question (native input + hex + presets) ---------- */
331
+ .colorpick { display: flex; flex-direction: column; gap: 10px; }
332
+ .colorrow { display: flex; align-items: center; gap: 10px; }
333
+ .colorswatch { width: 48px; height: 38px; padding: 0; border: 1px solid var(--border-strong); border-radius: 10px; background: var(--card); cursor: pointer; }
334
+ .colorswatch::-webkit-color-swatch-wrapper { padding: 3px; }
335
+ .colorswatch::-webkit-color-swatch { border: 0; border-radius: 7px; }
336
+ .colorhex { width: 150px; font-family: var(--mono, ui-monospace, "SF Mono", monospace); text-transform: lowercase; }
337
+ .colorpresets { display: flex; flex-wrap: wrap; gap: 8px; }
338
+ .colorpreset { width: 26px; height: 26px; border-radius: 7px; border: 1px solid rgba(0,0,0,.14); cursor: pointer; padding: 0; transition: transform .12s var(--ease); }
339
+ .colorpreset:hover { transform: scale(1.12); }