@khanglvm/relay 0.14.2 → 0.15.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
@@ -11,6 +11,8 @@
11
11
  canComment: true,
12
12
  canEditComments: true,
13
13
  canDeleteComments: true,
14
+ canEditBlocks: true,
15
+ canFinalize: true,
14
16
  };
15
17
  if (access.role) document.documentElement.dataset.accessRole = access.role;
16
18
  const QS = spec.questions || [];
@@ -23,8 +25,11 @@
23
25
  let seenDraftRev = typeof boot.draftRev === 'number' ? boot.draftRev : 0;
24
26
 
25
27
  function authHeaders(extra = {}) {
26
- return access.token ? { ...extra, 'x-relay-share-token': access.token } : extra;
28
+ const headers = access.token ? { ...extra, 'x-relay-share-token': access.token } : { ...extra };
29
+ if (access.reviewSessionId) headers['x-relay-review-session'] = access.reviewSessionId;
30
+ return headers;
27
31
  }
32
+ const canPersistFeedback = Boolean(access.canEditAnswers || access.canComment || access.canEditBlocks);
28
33
 
29
34
  // ---------- helpers ----------
30
35
  function el(tag, attrs = {}, ...children) {
@@ -133,14 +138,17 @@
133
138
  applyFontScale();
134
139
 
135
140
  // ---------- localStorage draft mirror ----------
136
- // Every autosave is ALSO written to localStorage, keyed by board id. This is
141
+ // Every autosave is ALSO written to localStorage, keyed by board id + access
142
+ // role/session. This keeps a reviewer side draft from leaking into a
143
+ // collaborator/read-only tab on the same LAN origin.
137
144
  // the durability layer the server file alone can't provide: if the connection
138
145
  // drops and the user keeps typing, the in-memory state is mirrored locally, so
139
146
  // even a tab reload / browser restart / a freshly opened tab on the same board
140
147
  // prefills the LATEST input instead of a blank board or a stale server save.
141
148
  // Guards (per design): newest-of-(local,server) wins; the mirror is discarded
142
149
  // if the board's spec rev changed (agent edited it); cleared on submit.
143
- const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown');
150
+ const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown') + '-' +
151
+ (access.role || 'owner') + (access.reviewSessionId ? '-' + access.reviewSessionId : '');
144
152
  function writeLocalDraft(p, updatedAt) {
145
153
  try {
146
154
  localStorage.setItem(LOCAL_DRAFT_KEY, JSON.stringify({
@@ -352,7 +360,7 @@
352
360
  // to detect recovery from the Retry button / heartbeat.
353
361
  async function probeServer() {
354
362
  try {
355
- const r = await fetch('/api/status', { cache: 'no-store' });
363
+ const r = await fetch('/api/status', { cache: 'no-store', headers: authHeaders() });
356
364
  return r.ok;
357
365
  } catch {
358
366
  return false;
@@ -407,6 +415,7 @@
407
415
  for (const node of app.querySelectorAll('input, textarea, button, select')) {
408
416
  node.disabled = false;
409
417
  }
418
+ applyAccessRestrictions();
410
419
  if (lostNote) lostNote.remove();
411
420
  // Re-arm the heartbeat (it stops itself when it confirms loss) and persist
412
421
  // everything typed during the outage. saveDraft() updates the save label.
@@ -454,7 +463,7 @@
454
463
  // probe → block. Any success resets it.
455
464
  let saveFailures = 0;
456
465
  function scheduleSave() {
457
- if (submitted) return;
466
+ if (submitted || !canPersistFeedback) return;
458
467
  // Mirror to localStorage SYNCHRONOUSLY on every edit, before (and regardless
459
468
  // of) the network save. This is what survives a tab reload / crash / a new
460
469
  // tab during a connection outage — it must happen even while blocked.
@@ -465,6 +474,7 @@
465
474
  saveTimer = setTimeout(saveDraft, 450);
466
475
  }
467
476
  async function saveDraft() {
477
+ if (!canPersistFeedback) return;
468
478
  const seq = ++saveSeq;
469
479
  // Keep the local mirror current on every flush too (covers programmatic
470
480
  // saveDraft() calls that don't go through scheduleSave, e.g. recovery flush).
@@ -516,7 +526,7 @@
516
526
  if (submitted) return;
517
527
  fetch('/api/ping', {
518
528
  method: 'POST',
519
- headers: { 'content-type': 'application/json' },
529
+ headers: authHeaders({ 'content-type': 'application/json' }),
520
530
  body: JSON.stringify({
521
531
  visible: !document.hidden,
522
532
  focused: document.hasFocus(),
@@ -570,11 +580,17 @@
570
580
  function blockCtx(questionId) {
571
581
  return {
572
582
  theme: effectiveTheme,
573
- htmlSrc: (blockId) => '/html/b/' + encodeURIComponent(blockId) + '?theme=' + effectiveTheme(),
583
+ htmlSrc: (blockId) => {
584
+ const params = new URLSearchParams({ theme: effectiveTheme() });
585
+ if (access.token) params.set('token', access.token);
586
+ return '/html/b/' + encodeURIComponent(blockId) + '?' + params.toString();
587
+ },
574
588
  questionId: questionId == null ? null : questionId,
575
589
  annotate: Annotate,
590
+ canComment: access.canComment !== false,
576
591
  edits: state.blockEdits,
577
592
  onBlockEdit,
593
+ canEditBlocks: access.canEditBlocks !== false,
578
594
  };
579
595
  }
580
596
 
@@ -807,6 +823,7 @@
807
823
  const byVal = new Map(q.options.map((o) => [o.value, o]));
808
824
  let dragFrom = null;
809
825
  function move(from, to) {
826
+ if (!access.canEditAnswers) return;
810
827
  const arr = state.answers[q.id];
811
828
  if (!Array.isArray(arr) || to < 0 || to >= arr.length || from === to) return;
812
829
  const [x] = arr.splice(from, 1);
@@ -827,7 +844,7 @@
827
844
  down.disabled = i === order.length - 1;
828
845
  up.addEventListener('click', () => move(i, i - 1));
829
846
  down.addEventListener('click', () => move(i, i + 1));
830
- const item = el('div', { class: 'rank-item', draggable: 'true' },
847
+ const item = el('div', { class: 'rank-item', draggable: access.canEditAnswers ? 'true' : 'false' },
831
848
  el('span', { class: 'rank-badge', 'aria-hidden': 'true' }, String(i + 1)),
832
849
  el('div', { class: 'rank-body' },
833
850
  el('div', { class: 'ol' }, o.label),
@@ -835,6 +852,7 @@
835
852
  el('div', { class: 'rank-ctrls' }, up, down)
836
853
  );
837
854
  item.addEventListener('dragstart', (e) => {
855
+ if (!access.canEditAnswers) { e.preventDefault(); return; }
838
856
  dragFrom = i; item.classList.add('dragging');
839
857
  try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', String(i)); } catch (_) {}
840
858
  });
@@ -1182,7 +1200,11 @@
1182
1200
  ),
1183
1201
  el('button', { class: 'share-choice', type: 'button', 'data-role': 'review' },
1184
1202
  el('span', { class: 'share-choice-title' }, 'Reviewer'),
1185
- el('span', { class: 'share-choice-sub' }, 'Can add comments only. No submit, answer edits, edit, or delete.')
1203
+ el('span', { class: 'share-choice-sub' }, 'Can answer, comment, and submit a reference-only side review. Does not finalize the board.')
1204
+ ),
1205
+ el('button', { class: 'share-choice', type: 'button', 'data-role': 'read' },
1206
+ el('span', { class: 'share-choice-title' }, 'Read only'),
1207
+ el('span', { class: 'share-choice-sub' }, 'Can view the board and existing feedback. Cannot answer, comment, edit, or submit.')
1186
1208
  ),
1187
1209
  el('div', { class: 'share-result' })
1188
1210
  );
@@ -1207,7 +1229,7 @@
1207
1229
  const copy = el('button', { class: 'share-copy', type: 'button' }, 'Copy link');
1208
1230
  copy.addEventListener('click', () => copyText(body.url, copy));
1209
1231
  result.replaceChildren(
1210
- el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : 'Reviewer link active'),
1232
+ el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : role === 'review' ? 'Reviewer link active' : 'Read-only link active'),
1211
1233
  el('a', { class: 'share-url', href: body.url, target: '_blank', rel: 'noreferrer' }, body.url),
1212
1234
  copy
1213
1235
  );
@@ -1226,18 +1248,33 @@
1226
1248
  return footer;
1227
1249
  }
1228
1250
 
1229
- if (!access.canSubmit) {
1230
- submitbar.style.display = 'none';
1231
- showNotice('Reviewer mode: comments are saved live. Answers and submission are disabled.', 'info');
1232
- } else if (access.role === 'collab') {
1233
- showNotice('Editor mode: answers, comments, and submit are enabled for this shared board.', 'info');
1234
- }
1235
- if (!access.canEditAnswers) {
1236
- document.documentElement.classList.add('relay-review');
1237
- for (const node of app.querySelectorAll('.card input, .card textarea, .card button, .card select')) {
1238
- node.disabled = true;
1251
+ function applyAccessRestrictions() {
1252
+ if (!access.canSubmit) submitbar.style.display = 'none';
1253
+ if (!access.canEditAnswers) {
1254
+ for (const node of app.querySelectorAll('.card input, .card textarea, .card button, .card select')) {
1255
+ // Viewer controls inside question/option blocks remain useful in a
1256
+ // read-only board (zoom, fit, full-screen, filtering, diff view). Block
1257
+ // mutation controls are independently gated by canEditBlocks.
1258
+ if (node.closest('.blocks')) continue;
1259
+ node.disabled = true;
1260
+ }
1261
+ for (const item of app.querySelectorAll('.rank-item')) {
1262
+ item.draggable = false;
1263
+ item.setAttribute('aria-disabled', 'true');
1264
+ }
1239
1265
  }
1266
+ document.documentElement.classList.toggle('relay-review', access.canEditComments === false || access.canDeleteComments === false);
1267
+ }
1268
+ if (access.role === 'review') {
1269
+ submitBtn.textContent = 'Submit side review';
1270
+ hint.textContent = 'Reference only — the owner still submits the final answer.';
1271
+ showNotice('Reviewer mode: your answers and comments are saved separately as a reference-only side review. Submitting here does not finalize the board or notify the waiting agent.', 'info');
1272
+ } else if (access.role === 'read') {
1273
+ showNotice('Read-only mode: you can view the board and existing feedback, but answers, comments, edits, and submission are disabled.', 'info');
1274
+ } else if (access.role === 'collab') {
1275
+ showNotice('Editor mode: answers, comments, and final submission are enabled for this shared board.', 'info');
1240
1276
  }
1277
+ applyAccessRestrictions();
1241
1278
  app.append(buildFooter());
1242
1279
 
1243
1280
  // ---------- validation & submit ----------
@@ -1253,7 +1290,7 @@
1253
1290
  return !firstBad;
1254
1291
  }
1255
1292
 
1256
- function showDone(closing) {
1293
+ function showDone(closing, sideReview = false) {
1257
1294
  stopHeartbeat();
1258
1295
  // Annotation pins/badges/popover float on <body> with elevated z-index —
1259
1296
  // remove them so they don't leak over the submitted screen.
@@ -1267,7 +1304,9 @@
1267
1304
  // If the agent already stopped waiting (soft timeout / dropped connection),
1268
1305
  // the submission won't be picked up automatically — tell the user to nudge
1269
1306
  // the agent. Otherwise the normal hand-back copy applies.
1270
- const note = handedBack
1307
+ const note = sideReview
1308
+ ? 'Saved for reference. This did not finalize the board or notify the waiting agent; the owner still needs to submit the final answer.'
1309
+ : handedBack
1271
1310
  ? 'Saved. Your agent had stopped waiting — send it a message so it picks up your answers.'
1272
1311
  : closing
1273
1312
  ? 'Handing back to your agent — this tab will close itself…'
@@ -1275,7 +1314,7 @@
1275
1314
  app.replaceChildren(
1276
1315
  el('div', { class: 'done' },
1277
1316
  el('div', { class: 'mark' }, '✓'),
1278
- el('h2', {}, QS.length ? 'Submitted' : 'Acknowledged'),
1317
+ el('h2', {}, sideReview ? 'Side review saved' : QS.length ? 'Submitted' : 'Acknowledged'),
1279
1318
  el('p', { id: 'done-note' }, note)
1280
1319
  )
1281
1320
  );
@@ -1302,14 +1341,16 @@
1302
1341
  throw netErr;
1303
1342
  }
1304
1343
  if (!res.ok) throw new Error('submit rejected');
1344
+ const submitResult = await res.json().catch(() => null);
1345
+ const sideReview = Boolean(submitResult && submitResult.sideReview === true && submitResult.final === false);
1305
1346
  submitted = true;
1306
1347
  // Submitted successfully → the local mirror is no longer needed and would
1307
1348
  // otherwise resurrect stale answers on a future reopen. Clear it.
1308
1349
  clearLocalDraft();
1309
1350
  // Don't auto-close when the agent had stopped waiting — the user needs to
1310
1351
  // read the "send your agent a message" note and act on it.
1311
- const autoClose = spec.autoClose && !handedBack;
1312
- showDone(autoClose);
1352
+ const autoClose = !sideReview && spec.autoClose && !handedBack;
1353
+ showDone(autoClose, sideReview);
1313
1354
  if (autoClose) {
1314
1355
  setTimeout(() => {
1315
1356
  window.close();
@@ -1324,7 +1365,7 @@
1324
1365
  } catch {
1325
1366
  // Restore the button so the user can retry.
1326
1367
  submitBtn.disabled = false;
1327
- submitBtn.textContent = spec.submitLabel;
1368
+ submitBtn.textContent = access.role === 'review' ? 'Submit side review' : spec.submitLabel;
1328
1369
  if (!reached) {
1329
1370
  // The connection is gone — the submit (and any further input) can't be
1330
1371
  // persisted. Block hard so the user stops adding feedback that would be
@@ -1362,7 +1403,7 @@
1362
1403
  // the server had nothing — e.g. a freshly reopened board the user had typed
1363
1404
  // into in another tab during an outage), the server doesn't yet have this
1364
1405
  // input. Flush it once so a brand-new tab's view is also the server's truth.
1365
- if (initialPrefill && initialPrefill.__from === 'local' && !submitted) {
1406
+ if (initialPrefill && initialPrefill.__from === 'local' && !submitted && canPersistFeedback) {
1366
1407
  saveDraft();
1367
1408
  }
1368
1409
 
@@ -1456,7 +1497,7 @@
1456
1497
  // Piggyback presence on the heartbeat (best-effort; no-ops after submit).
1457
1498
  pingPresence();
1458
1499
  try {
1459
- const r = await fetch('/api/status', { cache: 'no-store' });
1500
+ const r = await fetch('/api/status', { cache: 'no-store', headers: authHeaders() });
1460
1501
  if (!r.ok) throw new Error('bad status');
1461
1502
  misses = 0;
1462
1503
  // The heartbeat reaching the server is itself proof persistence is back —
@@ -1476,10 +1517,14 @@
1476
1517
  // user knows to prompt the agent after submitting.
1477
1518
  if (body && body.softTimedOut && !submitted) {
1478
1519
  handedBack = true;
1479
- showNotice(
1480
- 'You’ve had this open a while, so the agent stopped waiting. Your changes save automatically — submit when you’re ready, then prompt the agent to pick them up.',
1481
- 'info'
1482
- );
1520
+ if (access.role === 'review') {
1521
+ showNotice('This remains a reference-only side review. Submit when ready; the owner still needs to provide the final answer.', 'info');
1522
+ } else if (access.role !== 'read') {
1523
+ showNotice(
1524
+ 'You’ve had this open a while, so the agent stopped waiting. Your changes save automatically — submit when you’re ready, then prompt the agent to pick them up.',
1525
+ 'info'
1526
+ );
1527
+ }
1483
1528
  }
1484
1529
  if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
1485
1530
  // Don't yank the board out from under someone mid-comment: an open
package/src/ui/blocks.css CHANGED
@@ -498,6 +498,7 @@
498
498
  position: relative;
499
499
  width: 100%;
500
500
  }
501
+ .blk-chart-stage { position: relative; width: 100%; height: 100%; min-width: 0; }
501
502
  .blk-chart canvas { display: block; }
502
503
  .blk-chart-badge {
503
504
  position: absolute;
@@ -736,9 +737,10 @@ body.blk-full-open { overflow: hidden; }
736
737
  border: 1px solid var(--border);
737
738
  border-radius: 10px;
738
739
  }
739
- /* keep the canvas corners inside the rounded frame (mermaid already clips via
740
- its overflow:auto) */
741
- .blk-chart.blk-viewer:not(.blk-full) { overflow: hidden; }
740
+ /* Charts now use the same zoom/pan viewer as diagrams and images. Keep the
741
+ rounded frame while allowing the responsive chart stage to overflow once
742
+ the user zooms past fit. */
743
+ .blk-chart.blk-viewer:not(.blk-full) { overflow: auto; }
742
744
 
743
745
  /* drag-to-pan affordance: grab when a visual overflows its box, grabbing while
744
746
  dragging. During an active drag the inner svg/img/canvas ignore the pointer
package/src/ui/blocks.js CHANGED
@@ -734,7 +734,7 @@
734
734
 
735
735
  function renderDiff(block, ctx, blockId) {
736
736
  const files = splitDiffFiles(block.diff);
737
- if (block.review === true) return renderDiffReview(block, ctx, blockId, files);
737
+ if (block.review === true && ctx.canEditBlocks !== false) return renderDiffReview(block, ctx, blockId, files);
738
738
  const named = files.filter((f) => f.name);
739
739
  const multi = named.length > 1;
740
740
  let view = block.view === 'split' ? 'split' : 'unified';
@@ -844,6 +844,7 @@
844
844
  }
845
845
 
846
846
  function renderGitConflict(block, ctx, blockId) {
847
+ const editable = ctx.canEditBlocks !== false;
847
848
  const conflicts = Array.isArray(block.conflicts) ? block.conflicts : [];
848
849
  const prior = ctx && ctx.edits && ctx.edits[blockId] && typeof ctx.edits[blockId] === 'object'
849
850
  ? ctx.edits[blockId]
@@ -891,6 +892,7 @@
891
892
  spellcheck: 'false',
892
893
  rows: '5',
893
894
  placeholder: 'Write the resolved content for this hunk',
895
+ disabled: editable ? null : '',
894
896
  });
895
897
  const buttons = [];
896
898
  const setChoice = (choice) => {
@@ -902,7 +904,7 @@
902
904
  emit();
903
905
  };
904
906
  const makeButton = (choice, label) => {
905
- const b = el('button', { type: 'button', class: 'gitconf-choice' }, label);
907
+ const b = el('button', { type: 'button', class: 'gitconf-choice', disabled: editable ? null : '' }, label);
906
908
  b.addEventListener('mousedown', (e) => e.stopPropagation());
907
909
  b.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); setChoice(choice); });
908
910
  buttons.push({ choice, el: b });
@@ -1312,11 +1314,14 @@
1312
1314
  const height = clampHeight(block.height, 320);
1313
1315
  const wrap = el('div', { class: 'blk-chart' });
1314
1316
  wrap.style.height = height + 'px';
1317
+ const stage = el('div', { class: 'blk-chart-stage' });
1315
1318
  const canvas = el('canvas');
1316
- wrap.append(canvas);
1319
+ stage.append(canvas);
1320
+ wrap.append(stage);
1317
1321
 
1318
1322
  let chartInst = null;
1319
1323
  let chartBadges = [];
1324
+ let naturalSize = null;
1320
1325
 
1321
1326
  const openFor = (target) => (anchor) =>
1322
1327
  ctx.annotate.openExternal({ blockId, questionId: ctx.questionId, target }, anchor);
@@ -1358,7 +1363,7 @@
1358
1363
  const badge = makeBadge(count, 'blk-chart-badge-pt', openFor(target));
1359
1364
  badge.style.left = (canvas.offsetLeft + pos.x) + 'px';
1360
1365
  badge.style.top = (canvas.offsetTop + pos.y) + 'px';
1361
- wrap.append(badge);
1366
+ stage.append(badge);
1362
1367
  chartBadges.push(badge);
1363
1368
  }
1364
1369
  }
@@ -1370,9 +1375,6 @@
1370
1375
  }
1371
1376
 
1372
1377
  if (ctx.annotate && ctx.annotate.onBadgeRefresh) ctx.annotate.onBadgeRefresh(syncChartBadges);
1373
- // full-screen + whole-chart comment only; charts redraw responsively (no pixel zoom)
1374
- attachViewer(wrap, { zoomEl: null, label: 'chart', comment: wholeBlockComment(ctx, blockId, 'chart') });
1375
-
1376
1378
  loadChart().then((Chart) => {
1377
1379
  let config = block.config
1378
1380
  ? JSON.parse(JSON.stringify(block.config))
@@ -1384,17 +1386,33 @@
1384
1386
  try {
1385
1387
  chartInst = new Chart(canvas.getContext('2d'), config);
1386
1388
  chartRegistry.push({ chart: chartInst });
1389
+ // Chart.js stays responsive inside a stage whose dimensions the shared
1390
+ // viewer controls. This gives charts the same bounded, eased wheel zoom
1391
+ // as diagrams/images while retaining sharp redraws at every scale.
1392
+ naturalSize = {
1393
+ w: Math.max(1, Math.round(stage.getBoundingClientRect().width || wrap.clientWidth || 640)),
1394
+ h: height,
1395
+ };
1396
+ attachViewer(wrap, {
1397
+ zoomEl: stage,
1398
+ natural: () => naturalSize,
1399
+ fluidFit: true,
1400
+ scaleHeight: true,
1401
+ label: 'chart',
1402
+ comment: wholeBlockComment(ctx, blockId, 'chart'),
1403
+ });
1387
1404
  } catch (err) {
1388
1405
  wrap.replaceChildren(el('div', { class: 'blk-error' }, 'Chart error: ' + (err && err.message ? err.message : String(err))));
1389
1406
  return;
1390
1407
  }
1391
1408
  // hover -> pointer cursor on a hit
1392
1409
  canvas.addEventListener('mousemove', (e) => {
1410
+ if (ctx.canComment === false) { canvas.style.cursor = 'default'; return; }
1393
1411
  const hits = chartInst.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
1394
1412
  canvas.style.cursor = hits.length ? 'pointer' : 'default';
1395
1413
  });
1396
1414
  canvas.addEventListener('click', (e) => {
1397
- if (!ctx.annotate) return;
1415
+ if (!ctx.annotate || ctx.canComment === false) return;
1398
1416
  const hits = chartInst.getElementsAtEventForMode(e, 'nearest', { intersect: true }, true);
1399
1417
  if (!hits.length) return;
1400
1418
  const { datasetIndex, index } = hits[0];
@@ -1449,7 +1467,7 @@
1449
1467
  entry.code = effectiveMermaidCode(entry);
1450
1468
  mermaidRegistry.push(entry);
1451
1469
 
1452
- if (!block.editable) {
1470
+ if (!block.editable || ctx.canEditBlocks === false) {
1453
1471
  drawMermaid(entry);
1454
1472
  return container;
1455
1473
  }
@@ -1922,10 +1940,11 @@
1922
1940
  function enableImagePins(container, img, ctx, blockId, label) {
1923
1941
  const layer = el('div', { class: 'blk-imgpins' });
1924
1942
  container.append(layer);
1925
- img.classList.add('blk-img-pinnable');
1943
+ if (ctx.canComment !== false) img.classList.add('blk-img-pinnable');
1926
1944
  let down = null; // {x,y} at pointerdown — used to reject drags (pan) as clicks
1927
1945
  img.addEventListener('pointerdown', (e) => { down = { x: e.clientX, y: e.clientY }; });
1928
1946
  img.addEventListener('click', (e) => {
1947
+ if (ctx.canComment === false) return;
1929
1948
  if (down && (Math.abs(e.clientX - down.x) > 4 || Math.abs(e.clientY - down.y) > 4)) { down = null; return; }
1930
1949
  const r = img.getBoundingClientRect();
1931
1950
  if (!r.width || !r.height) return;
@@ -2132,6 +2151,15 @@
2132
2151
  // full-screen content insets — must mirror the .blk-full padding in blocks.css
2133
2152
  // so the fit-to-screen scale clears the fixed toolbar + leaves a small margin.
2134
2153
  const FULL_PAD_X = 24, FULL_PAD_TOP = 44, FULL_PAD_BOTTOM = 24;
2154
+ // Wheel/pinch zoom is delta-proportional and animated. The old fixed 15% jump
2155
+ // ran once for every wheel event, so a high-frequency trackpad gesture could
2156
+ // compound to several hundred percent almost instantly. Cap the contribution
2157
+ // of one event (mouse wheels often report ±100px) and ease toward the gesture's
2158
+ // accumulated target at most once per animation frame.
2159
+ const WHEEL_ZOOM_SENSITIVITY = 0.003;
2160
+ const WHEEL_ZOOM_MAX_DELTA = 20;
2161
+ const WHEEL_ZOOM_EASE = 0.24;
2162
+ const WHEEL_ZOOM_EPSILON = 0.001;
2135
2163
 
2136
2164
  // Toolbar icons: full-screen (4-corner expand) and a speech-bubble for the
2137
2165
  // "comment on the whole block" button. Zoom is both cmd/ctrl+wheel AND
@@ -2146,6 +2174,7 @@
2146
2174
  function exitFull() {
2147
2175
  if (!fullOpen) return;
2148
2176
  const c = fullOpen;
2177
+ if (c._rlyStopWheelZoom) c._rlyStopWheelZoom();
2149
2178
  c.classList.remove('blk-full');
2150
2179
  document.body.classList.remove('blk-full-open');
2151
2180
  const btn = c.querySelector('.blk-tools .tool-full');
@@ -2164,6 +2193,7 @@
2164
2193
  function enterFull(container) {
2165
2194
  if (fullOpen === container) return;
2166
2195
  exitFull();
2196
+ if (container._rlyStopWheelZoom) container._rlyStopWheelZoom();
2167
2197
  container.classList.add('blk-full');
2168
2198
  document.body.classList.add('blk-full-open');
2169
2199
  fullOpen = container;
@@ -2306,7 +2336,7 @@
2306
2336
  // with a block-scoped target ("Whole chart/diagram/…") so a user can comment
2307
2337
  // on the visual as a whole. null when annotation is off (omits the button).
2308
2338
  function wholeBlockComment(ctx, blockId, label) {
2309
- if (!ctx || !ctx.annotate) return null;
2339
+ if (!ctx || !ctx.annotate || ctx.canComment === false) return null;
2310
2340
  const a = ctx.annotate;
2311
2341
  return {
2312
2342
  open: (anchorEl) =>
@@ -2352,6 +2382,10 @@
2352
2382
  // zoom level persists across re-renders; the wheel handler (bound once)
2353
2383
  // delegates through container._rlyZoom so it never holds a stale zoomEl
2354
2384
  if (container._rlyZ === undefined) container._rlyZ = null; // null = fit-to-width
2385
+ // Fluid visuals (currently Chart.js) fit the live container width. Capture
2386
+ // that rendered size only when leaving fit mode so zoom remains proportional
2387
+ // without freezing later fit-mode viewport growth to an old width.
2388
+ let fluidZoomNatural = null;
2355
2389
  const pct = el('span', { class: 'tool-pct' }, 'fit');
2356
2390
  function apply() {
2357
2391
  if (!zoomable) return;
@@ -2377,17 +2411,18 @@
2377
2411
  const scale = Math.min(availW / nat.w, availH / nat.h);
2378
2412
  target.style.maxWidth = 'none';
2379
2413
  target.style.width = Math.max(1, Math.round(nat.w * scale)) + 'px';
2380
- target.style.height = 'auto';
2414
+ target.style.height = opts.scaleHeight ? Math.max(1, Math.round(nat.h * scale)) + 'px' : 'auto';
2381
2415
  pct.textContent = 'fit';
2382
2416
  } else if (z === null || !nat || !nat.w) {
2383
2417
  target.style.width = '100%';
2384
- target.style.maxWidth = nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%';
2385
- target.style.height = 'auto';
2418
+ target.style.maxWidth = opts.fluidFit ? '100%' : (nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%');
2419
+ target.style.height = opts.scaleHeight ? '100%' : 'auto';
2386
2420
  pct.textContent = 'fit';
2387
2421
  } else {
2422
+ const zoomNat = fluidZoomNatural || nat;
2388
2423
  target.style.maxWidth = 'none';
2389
- target.style.width = Math.round(nat.w * z) + 'px';
2390
- target.style.height = 'auto';
2424
+ target.style.width = Math.round(zoomNat.w * z) + 'px';
2425
+ target.style.height = opts.scaleHeight && zoomNat && zoomNat.h ? Math.round(zoomNat.h * z) + 'px' : 'auto';
2391
2426
  pct.textContent = Math.round(z * 100) + '%';
2392
2427
  }
2393
2428
  window.dispatchEvent(new Event('resize')); // annotation badges reposition
@@ -2396,17 +2431,38 @@
2396
2431
  }
2397
2432
  function currentZ() {
2398
2433
  if (container._rlyZ !== null) return container._rlyZ;
2434
+ if (opts.fluidFit) return 1;
2399
2435
  const nat = opts.natural();
2400
2436
  if (!nat || !nat.w) return 1;
2401
2437
  const shown = opts.zoomEl.getBoundingClientRect().width;
2402
2438
  return shown > 0 ? shown / nat.w : 1;
2403
2439
  }
2404
2440
  function setZoom(next) {
2441
+ if (next === null) {
2442
+ fluidZoomNatural = null;
2443
+ } else if (opts.fluidFit && container._rlyZ === null) {
2444
+ const shown = opts.zoomEl.getBoundingClientRect();
2445
+ fluidZoomNatural = {
2446
+ w: Math.max(1, shown.width),
2447
+ h: Math.max(1, shown.height),
2448
+ };
2449
+ }
2405
2450
  container._rlyZ = next === null ? null : Math.min(8, Math.max(0.2, next));
2406
2451
  apply();
2407
2452
  }
2408
2453
  container._rlyZoom = { setZoom, currentZ, reapply: apply };
2409
2454
 
2455
+ // Stop any in-flight wheel easing before an explicit toolbar/full-screen
2456
+ // action. This prevents an older gesture target from overriding that action
2457
+ // on the next animation frame.
2458
+ if (!container._rlyStopWheelZoom) {
2459
+ container._rlyStopWheelZoom = () => {
2460
+ if (container._rlyWheelFrame) cancelAnimationFrame(container._rlyWheelFrame);
2461
+ container._rlyWheelFrame = null;
2462
+ container._rlyWheelTarget = null;
2463
+ };
2464
+ }
2465
+
2410
2466
  const tools = el('div', { class: 'blk-tools' });
2411
2467
 
2412
2468
  // comment on the whole block (leftmost) — opens the annotation popover with
@@ -2435,18 +2491,53 @@
2435
2491
  const onWheel = (e) => {
2436
2492
  if (!(e.ctrlKey || e.metaKey) || !container._rlyZoom) return;
2437
2493
  e.preventDefault();
2438
- container._rlyZoom.setZoom(container._rlyZoom.currentZ() * (e.deltaY < 0 ? 1.15 : 1 / 1.15));
2494
+ // Normalize line/page-mode devices to pixel-like units, then cap a
2495
+ // single event. Exponential scaling makes equal motion reversible and
2496
+ // lets tiny trackpad deltas stay tiny instead of becoming fixed jumps.
2497
+ const unit = e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? window.innerHeight : 1;
2498
+ const delta = Math.max(-WHEEL_ZOOM_MAX_DELTA, Math.min(WHEEL_ZOOM_MAX_DELTA, e.deltaY * unit));
2499
+ const base = Number.isFinite(container._rlyWheelTarget)
2500
+ ? container._rlyWheelTarget
2501
+ : container._rlyZoom.currentZ();
2502
+ container._rlyWheelTarget = Math.min(8, Math.max(0.2, base * Math.exp(-delta * WHEEL_ZOOM_SENSITIVITY)));
2503
+
2504
+ // Respect reduced-motion while retaining the gentler delta-based step.
2505
+ if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
2506
+ container._rlyZoom.setZoom(container._rlyWheelTarget);
2507
+ container._rlyWheelTarget = null;
2508
+ return;
2509
+ }
2510
+ if (container._rlyWheelFrame) return; // coalesce bursty input to one rAF loop
2511
+ const tick = () => {
2512
+ const zoom = container._rlyZoom;
2513
+ const target = container._rlyWheelTarget;
2514
+ if (!zoom || !Number.isFinite(target)) {
2515
+ container._rlyWheelFrame = null;
2516
+ return;
2517
+ }
2518
+ const current = zoom.currentZ();
2519
+ const gap = target - current;
2520
+ if (Math.abs(gap) <= WHEEL_ZOOM_EPSILON) {
2521
+ zoom.setZoom(target);
2522
+ container._rlyWheelFrame = null;
2523
+ container._rlyWheelTarget = null;
2524
+ return;
2525
+ }
2526
+ zoom.setZoom(current + gap * WHEEL_ZOOM_EASE);
2527
+ container._rlyWheelFrame = requestAnimationFrame(tick);
2528
+ };
2529
+ container._rlyWheelFrame = requestAnimationFrame(tick);
2439
2530
  };
2440
2531
  container.addEventListener('wheel', onWheel, { passive: false });
2441
2532
  container._rlyWheel = onWheel;
2442
2533
  }
2443
2534
  const zoomOut = el('button', { class: 'tool-zoom', type: 'button', title: 'Zoom out' }, '−');
2444
2535
  const zoomIn = el('button', { class: 'tool-zoom', type: 'button', title: 'Zoom in' }, '+');
2445
- zoomOut.addEventListener('click', (e) => { e.stopPropagation(); setZoom(currentZ() / 1.2); });
2446
- zoomIn.addEventListener('click', (e) => { e.stopPropagation(); setZoom(currentZ() * 1.2); });
2536
+ zoomOut.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(currentZ() / 1.2); });
2537
+ zoomIn.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(currentZ() * 1.2); });
2447
2538
  pct.classList.add('is-btn');
2448
2539
  pct.title = 'Reset to fit';
2449
- pct.addEventListener('click', (e) => { e.stopPropagation(); setZoom(null); });
2540
+ pct.addEventListener('click', (e) => { e.stopPropagation(); container._rlyStopWheelZoom(); setZoom(null); });
2450
2541
  tools.append(zoomOut, pct, zoomIn);
2451
2542
  }
2452
2543
 
@@ -2586,6 +2677,8 @@
2586
2677
  // editable-mermaid plumbing: edits maps blockId -> edited code; onBlockEdit
2587
2678
  // reports an accepted change (or null to clear back to the original).
2588
2679
  edits: ctx && ctx.edits ? ctx.edits : {},
2680
+ canComment: !ctx || ctx.canComment !== false,
2681
+ canEditBlocks: !ctx || ctx.canEditBlocks !== false,
2589
2682
  onBlockEdit:
2590
2683
  ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},
2591
2684
  };