@khanglvm/relay 0.2.0 → 0.4.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
@@ -6,6 +6,10 @@
6
6
  const QS = spec.questions || [];
7
7
  const app = document.getElementById('app');
8
8
  const banner = document.getElementById('banner');
9
+ // Live-update baseline: the server's rev at page-build time. The heartbeat
10
+ // compares /api/status.rev to this and reloads the board when it advances
11
+ // (an agent ran `rly update`).
12
+ const bootRev = typeof boot.rev === 'number' ? boot.rev : null;
9
13
 
10
14
  // ---------- helpers ----------
11
15
  function el(tag, attrs = {}, ...children) {
@@ -22,6 +26,14 @@
22
26
  return n;
23
27
  }
24
28
 
29
+ // Small, self-dismissing toast pinned to the top-center of the viewport.
30
+ // Used to flag a live `rly update` after the reload (accent-soft bg).
31
+ function showToast(message) {
32
+ const toast = el('div', { class: 'toast' }, message);
33
+ document.body.append(toast);
34
+ setTimeout(() => toast.remove(), 4000);
35
+ }
36
+
25
37
  // ---------- theme (auto -> light -> dark) ----------
26
38
  // Server-side pref wins: boards run on random ports, so localStorage alone
27
39
  // can't carry the choice across boards. The server persists it globally.
@@ -70,6 +82,10 @@
70
82
  notes: {},
71
83
  comment: '',
72
84
  annotations: (boot.prefill && boot.prefill.annotations) || [],
85
+ // Editable-mermaid edits: blockId -> edited source. Seeded from the live
86
+ // draft so a reload/reopen restores the user's edited diagram. Mutated via
87
+ // the blocks ctx.onBlockEdit callback below; returned in payload().
88
+ blockEdits: (boot.prefill && boot.prefill.blockEdits) || {},
73
89
  };
74
90
  let submitted = false;
75
91
 
@@ -135,7 +151,13 @@
135
151
  const n = typeof state.notes[q.id] === 'string' ? state.notes[q.id].trim() : '';
136
152
  if (n) notes[q.id] = n;
137
153
  }
138
- return { answers, comment: (state.comment || '').trim(), notes, annotations: state.annotations };
154
+ return {
155
+ answers,
156
+ comment: (state.comment || '').trim(),
157
+ notes,
158
+ annotations: state.annotations,
159
+ blockEdits: state.blockEdits,
160
+ };
139
161
  }
140
162
 
141
163
  // ---------- real-time autosave ----------
@@ -162,6 +184,44 @@
162
184
  }
163
185
  }
164
186
 
187
+ // ---------- presence / awareness ----------
188
+ // Track the last time the user interacted with the page so the agent (via
189
+ // /api/presence) can tell whether someone is still actively viewing the board
190
+ // and keep waiting instead of timing out. Every existing 3s heartbeat tick
191
+ // also POSTs /api/ping {visible, focused, idleMs}; pinging stops after submit.
192
+ let lastInteractionAt = Date.now();
193
+ let lastMoveAt = 0;
194
+ function noteInteraction() {
195
+ lastInteractionAt = Date.now();
196
+ }
197
+ window.addEventListener('pointerdown', noteInteraction, { passive: true });
198
+ window.addEventListener('keydown', noteInteraction, { passive: true });
199
+ window.addEventListener('scroll', noteInteraction, { passive: true });
200
+ window.addEventListener('touchstart', noteInteraction, { passive: true });
201
+ // pointermove fires continuously — throttle to at most once per second.
202
+ window.addEventListener('pointermove', () => {
203
+ const now = Date.now();
204
+ if (now - lastMoveAt >= 1000) {
205
+ lastMoveAt = now;
206
+ lastInteractionAt = now;
207
+ }
208
+ }, { passive: true });
209
+
210
+ function pingPresence() {
211
+ if (submitted) return;
212
+ fetch('/api/ping', {
213
+ method: 'POST',
214
+ headers: { 'content-type': 'application/json' },
215
+ body: JSON.stringify({
216
+ visible: !document.hidden,
217
+ focused: document.hasFocus(),
218
+ idleMs: Date.now() - lastInteractionAt,
219
+ }),
220
+ }).catch(() => {
221
+ // presence is best-effort — a failed ping must never surface to the user
222
+ });
223
+ }
224
+
165
225
  // ---------- annotations ----------
166
226
  // RelayAnnotate owns the live annotation list; mirror it into state on every
167
227
  // change so payload()/autosave/submit carry it exactly like answers.
@@ -176,13 +236,25 @@
176
236
  });
177
237
  }
178
238
 
179
- // ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract.
239
+ // Editable-mermaid: record (or clear) the user's edit for a block, then
240
+ // autosave. A null/empty code clears the entry (block matches the original
241
+ // again — e.g. after Reset), so payload()/draft carry only real divergences.
242
+ function onBlockEdit(blockId, codeOrNull) {
243
+ if (codeOrNull === null || codeOrNull === undefined) delete state.blockEdits[blockId];
244
+ else state.blockEdits[blockId] = codeOrNull;
245
+ scheduleSave();
246
+ }
247
+
248
+ // ctx for RelayBlocks.render — theme()/htmlSrc per the shared contract, plus
249
+ // the editable-mermaid plumbing (edits map + onBlockEdit callback).
180
250
  function blockCtx(questionId) {
181
251
  return {
182
252
  theme: effectiveTheme,
183
253
  htmlSrc: (blockId) => '/html/b/' + encodeURIComponent(blockId) + '?theme=' + effectiveTheme(),
184
254
  questionId: questionId == null ? null : questionId,
185
255
  annotate: Annotate,
256
+ edits: state.blockEdits,
257
+ onBlockEdit,
186
258
  };
187
259
  }
188
260
 
@@ -413,6 +485,16 @@
413
485
  });
414
486
  applyTheme();
415
487
 
488
+ // Just reloaded after a live `rly update`? Flag set before reload below.
489
+ try {
490
+ if (sessionStorage.getItem('relay-updated') === '1') {
491
+ sessionStorage.removeItem('relay-updated');
492
+ showToast('Board updated by the agent');
493
+ }
494
+ } catch {
495
+ // sessionStorage may be unavailable (privacy mode) — non-fatal
496
+ }
497
+
416
498
  app.append(el('header', { class: 'qb-header' }, el('h1', {}, spec.title), themeBtn));
417
499
  if (spec.intro) {
418
500
  const intro = el('p', { class: 'intro' }, spec.intro);
@@ -495,6 +577,15 @@
495
577
 
496
578
  function showDone(closing) {
497
579
  stopHeartbeat();
580
+ // Annotation pins/badges/popover float on <body> with elevated z-index —
581
+ // remove them so they don't leak over the submitted screen.
582
+ if (window.RelayAnnotate && typeof RelayAnnotate.teardown === 'function') {
583
+ try {
584
+ RelayAnnotate.teardown();
585
+ } catch {
586
+ // best effort
587
+ }
588
+ }
498
589
  app.replaceChildren(
499
590
  el('div', { class: 'done' },
500
591
  el('div', { class: 'mark' }, '✓'),
@@ -584,11 +675,35 @@
584
675
 
585
676
  // ---------- heartbeat ----------
586
677
  let misses = 0;
678
+ let reloading = false;
587
679
  let hb = setInterval(async () => {
680
+ // Piggyback presence on the heartbeat (best-effort; no-ops after submit).
681
+ pingPresence();
588
682
  try {
589
683
  const r = await fetch('/api/status', { cache: 'no-store' });
590
684
  if (!r.ok) throw new Error('bad status');
591
685
  misses = 0;
686
+ // Live update: the agent ran `rly update`, advancing the server rev.
687
+ // Flush whatever the user has typed so far (the reload re-prefills from
688
+ // the live draft — answers for now-removed question ids are ignored),
689
+ // then reload to render the new spec. Guarded so it fires once.
690
+ const body = await r.json().catch(() => null);
691
+ if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
692
+ reloading = true;
693
+ stopHeartbeat();
694
+ clearTimeout(saveTimer);
695
+ try {
696
+ await saveDraft();
697
+ } catch {
698
+ // a failed final save shouldn't block the reload to the new spec
699
+ }
700
+ try {
701
+ sessionStorage.setItem('relay-updated', '1');
702
+ } catch {
703
+ // sessionStorage may be unavailable — the reload still applies the update
704
+ }
705
+ location.reload();
706
+ }
592
707
  } catch {
593
708
  if (++misses >= 2 && !submitted) {
594
709
  banner.textContent = 'This board is closed — the server has stopped. Answers up to your last edit were autosaved.';
package/src/ui/blocks.css CHANGED
@@ -168,3 +168,100 @@
168
168
  }
169
169
  .blk-mermaid .node,
170
170
  .blk-mermaid .edgeLabel { cursor: default; }
171
+
172
+ /* ---------- editable mermaid (diagram editor) ---------- */
173
+ .blk-mermaid-edit { margin-top: 8px; }
174
+ .blk-edit-btn {
175
+ appearance: none;
176
+ background: transparent;
177
+ border: 1px solid var(--border);
178
+ border-radius: 7px;
179
+ color: var(--muted);
180
+ font-family: var(--sans);
181
+ font-size: 0.78rem;
182
+ padding: 4px 10px;
183
+ cursor: pointer;
184
+ transition: color 150ms var(--ease), border-color 150ms var(--ease);
185
+ }
186
+ .blk-edit-btn:hover,
187
+ .blk-edit-btn.is-open { color: var(--accent); border-color: var(--accent); }
188
+ .blk-editor { margin-top: 8px; }
189
+ .blk-editor[hidden] { display: none; }
190
+ .blk-editor-ta {
191
+ display: block;
192
+ width: 100%;
193
+ box-sizing: border-box;
194
+ min-height: 120px;
195
+ resize: vertical;
196
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
197
+ font-size: 13px;
198
+ line-height: 1.5;
199
+ color: var(--fg);
200
+ background: var(--bg-sunken);
201
+ border: 1px solid var(--border);
202
+ border-radius: 8px;
203
+ padding: 10px 12px;
204
+ }
205
+ .blk-editor-ta:focus {
206
+ outline: none;
207
+ border-color: var(--accent);
208
+ }
209
+ .blk-editor-row {
210
+ display: flex;
211
+ align-items: center;
212
+ gap: 12px;
213
+ margin-top: 8px;
214
+ }
215
+ .blk-editor-reset {
216
+ appearance: none;
217
+ background: transparent;
218
+ border: 1px solid var(--border);
219
+ border-radius: 7px;
220
+ color: var(--muted);
221
+ font-family: var(--sans);
222
+ font-size: 0.78rem;
223
+ padding: 4px 10px;
224
+ cursor: pointer;
225
+ transition: color 150ms var(--ease), border-color 150ms var(--ease);
226
+ }
227
+ .blk-editor-reset:hover { color: var(--accent); border-color: var(--accent); }
228
+ .blk-editor-status {
229
+ font-family: var(--sans);
230
+ font-size: 0.78rem;
231
+ color: var(--muted);
232
+ }
233
+ .blk-editor-status.has-error { color: var(--muted); }
234
+
235
+ /* ---------- graphviz (offline, vendored Viz.js) ---------- */
236
+ .blk-graphviz {
237
+ overflow: auto;
238
+ max-height: 1200px;
239
+ text-align: center;
240
+ /* graphviz draws on its own white canvas; frame it as a deliberate card so
241
+ it reads intentional in dark mode instead of a stray white rectangle */
242
+ background: #fff;
243
+ border: 1px solid var(--border);
244
+ border-radius: 10px;
245
+ padding: 10px;
246
+ }
247
+ .blk-graphviz svg {
248
+ max-width: 100%;
249
+ height: auto;
250
+ }
251
+ .blk-graphviz g.node,
252
+ .blk-graphviz g.edge { cursor: default; }
253
+
254
+ /* ---------- plantuml (server-rendered image) ---------- */
255
+ .blk-plantuml {
256
+ overflow: auto;
257
+ max-height: 1200px;
258
+ text-align: center;
259
+ background: #fff;
260
+ border: 1px solid var(--border);
261
+ border-radius: 10px;
262
+ padding: 10px;
263
+ }
264
+ .blk-plantuml .blk-plantuml-img {
265
+ max-width: 100%;
266
+ height: auto;
267
+ }
package/src/ui/blocks.js CHANGED
@@ -371,6 +371,20 @@
371
371
  return mermaidPromise;
372
372
  }
373
373
 
374
+ let vizPromise = null;
375
+ function loadViz() {
376
+ if (window.Viz) return Promise.resolve(window.Viz);
377
+ if (vizPromise) return vizPromise;
378
+ vizPromise = new Promise((resolve, reject) => {
379
+ const s = document.createElement('script');
380
+ s.src = '/vendor/viz-standalone.js';
381
+ s.onload = () => (window.Viz ? resolve(window.Viz) : reject(new Error('Graphviz failed to load')));
382
+ s.onerror = () => reject(new Error('Graphviz failed to load'));
383
+ document.head.appendChild(s);
384
+ });
385
+ return vizPromise;
386
+ }
387
+
374
388
  // ---------- chart ----------
375
389
  function clampHeight(h, def) {
376
390
  const n = Number(h);
@@ -540,16 +554,133 @@
540
554
  const mermaidRegistry = [];
541
555
  const chartRegistry = [];
542
556
 
557
+ // Effective code = the user's edit if present, else the authored block.code.
558
+ // Used by the initial render, the editor's live preview, and the theme
559
+ // re-render registry path so an edit survives a theme toggle.
560
+ function effectiveMermaidCode(entry) {
561
+ const { block, ctx, blockId } = entry;
562
+ const edited = ctx.edits ? ctx.edits[blockId] : undefined;
563
+ if (edited !== undefined && edited !== null) return edited;
564
+ // The live editor keeps entry.code as the source of truth; if the host's
565
+ // ctx.edits hasn't been updated yet but the user has diverged from the
566
+ // original, preserve that divergence (don't snap back to block.code).
567
+ if (entry.code !== undefined && entry.code !== (block.code || '')) return entry.code;
568
+ return block.code || '';
569
+ }
570
+
543
571
  function renderMermaid(block, ctx, blockId) {
544
572
  const container = el('div', { class: 'blk-mermaid' });
545
573
  const entry = { container, block, ctx, blockId };
574
+ entry.code = effectiveMermaidCode(entry);
546
575
  mermaidRegistry.push(entry);
576
+
577
+ if (!block.editable) {
578
+ drawMermaid(entry);
579
+ return container;
580
+ }
581
+
582
+ // Editable: wrap the diagram + an editor below it. The wrapper is what the
583
+ // dispatcher appends; the registry still tracks `container` (the diagram).
584
+ const wrap = el('div', { class: 'blk-mermaid-wrap' });
585
+ wrap.append(container);
586
+ wrap.append(buildMermaidEditor(entry));
547
587
  drawMermaid(entry);
548
- return container;
588
+ return wrap;
589
+ }
590
+
591
+ // Editor: toggle button + collapsible panel (textarea + Reset + status line).
592
+ // Live re-render is debounced 600ms; errors show inline WITHOUT destroying the
593
+ // last good diagram. Each accepted change calls ctx.onBlockEdit(blockId, code)
594
+ // (null when the value matches the original block.code again).
595
+ function buildMermaidEditor(entry) {
596
+ const { block, ctx, blockId, container } = entry;
597
+ const original = block.code || '';
598
+
599
+ const toggleBtn = el('button', { type: 'button', class: 'blk-edit-btn' }, 'Edit diagram');
600
+ const panel = el('div', { class: 'blk-editor', hidden: '' });
601
+
602
+ const ta = el('textarea', { class: 'blk-editor-ta', spellcheck: 'false' });
603
+ ta.value = entry.code;
604
+
605
+ const status = el('div', { class: 'blk-editor-status' }, '');
606
+ const resetBtn = el('button', { type: 'button', class: 'blk-editor-reset' }, 'Reset');
607
+ const row = el('div', { class: 'blk-editor-row' }, resetBtn, status);
608
+ panel.append(ta, row);
609
+
610
+ let open = false;
611
+ toggleBtn.addEventListener('click', () => {
612
+ open = !open;
613
+ panel.hidden = !open;
614
+ toggleBtn.classList.toggle('is-open', open);
615
+ if (open) ta.focus();
616
+ });
617
+
618
+ let timer = null;
619
+ function reportEdit(code) {
620
+ // Only report a real divergence; equal-to-original clears the edit (null).
621
+ try {
622
+ if (code === original) ctx.onBlockEdit(blockId, null);
623
+ else ctx.onBlockEdit(blockId, code);
624
+ } catch {
625
+ // host callback failures must not break editing
626
+ }
627
+ }
628
+
629
+ // Try to render `code`; on success swap the diagram + re-register annotation
630
+ // targets (via drawMermaid) and report the edit; on failure keep the last
631
+ // good diagram and show the error inline.
632
+ function applyCode(code) {
633
+ previewMermaid(entry, code)
634
+ .then(() => {
635
+ status.textContent = '';
636
+ status.classList.remove('has-error');
637
+ entry.code = code;
638
+ reportEdit(code);
639
+ })
640
+ .catch((err) => {
641
+ status.textContent = 'diagram error: ' + (err && err.message ? err.message : String(err));
642
+ status.classList.add('has-error');
643
+ });
644
+ }
645
+
646
+ ta.addEventListener('input', () => {
647
+ if (timer) clearTimeout(timer);
648
+ const code = ta.value;
649
+ timer = setTimeout(() => applyCode(code), 600);
650
+ });
651
+
652
+ resetBtn.addEventListener('click', () => {
653
+ if (timer) { clearTimeout(timer); timer = null; }
654
+ ta.value = original;
655
+ applyCode(original);
656
+ });
657
+
658
+ return el('div', { class: 'blk-mermaid-edit' }, toggleBtn, panel);
659
+ }
660
+
661
+ // Render `code` for the editor preview WITHOUT mutating entry.code on failure.
662
+ // Resolves once the diagram is swapped into the container (annotation targets
663
+ // re-registered by drawMermaid); rejects with the render error so the caller
664
+ // can show it inline and keep the last good diagram.
665
+ function previewMermaid(entry, code) {
666
+ const prev = entry.code;
667
+ entry.code = code;
668
+ return new Promise((resolve, reject) => {
669
+ drawMermaid(entry, { onDone: resolve, onError: reject });
670
+ }).catch((err) => {
671
+ entry.code = prev;
672
+ throw err;
673
+ });
549
674
  }
550
675
 
551
- function drawMermaid(entry) {
552
- const { container, block, ctx, blockId } = entry;
676
+ function drawMermaid(entry, hooks) {
677
+ const { container, ctx, blockId } = entry;
678
+ const onError = hooks && hooks.onError ? hooks.onError : null;
679
+ const onDone = hooks && hooks.onDone ? hooks.onDone : null;
680
+ const fail = (err) => {
681
+ if (onError) onError(err);
682
+ else showMermaidErr(container, err);
683
+ };
553
684
  loadMermaid().then((mermaid) => {
554
685
  try {
555
686
  mermaid.initialize({
@@ -561,7 +692,7 @@
561
692
  // ignore re-init issues
562
693
  }
563
694
  const id = 'rly-mmd-' + (++mermaidSeq);
564
- const code = block.code || '';
695
+ const code = entry.code !== undefined ? entry.code : effectiveMermaidCode(entry);
565
696
  const onSvg = (svg) => {
566
697
  container.innerHTML = svg;
567
698
  const svgEl = container.querySelector('svg');
@@ -579,24 +710,26 @@
579
710
  svgEl.style.maxWidth = '100%';
580
711
  }
581
712
  }
582
- if (!ctx.annotate) return;
583
- const nodes = container.querySelectorAll('.node, .edgeLabel');
584
- nodes.forEach((g) => {
585
- ctx.annotate.register(g, {
586
- blockId,
587
- questionId: ctx.questionId,
588
- target: {
589
- kind: 'mermaid-node',
590
- nodeId: g.id || '',
591
- text: (g.textContent || '').trim().slice(0, 120),
592
- },
713
+ if (ctx.annotate) {
714
+ const nodes = container.querySelectorAll('.node, .edgeLabel');
715
+ nodes.forEach((g) => {
716
+ ctx.annotate.register(g, {
717
+ blockId,
718
+ questionId: ctx.questionId,
719
+ target: {
720
+ kind: 'mermaid-node',
721
+ nodeId: g.id || '',
722
+ text: (g.textContent || '').trim().slice(0, 120),
723
+ },
724
+ });
593
725
  });
594
- });
726
+ }
727
+ if (onDone) onDone();
595
728
  };
596
729
  try {
597
730
  const ret = mermaid.render(id, code);
598
731
  if (ret && typeof ret.then === 'function') {
599
- ret.then((r) => onSvg(r.svg)).catch((err) => showMermaidErr(container, err));
732
+ ret.then((r) => onSvg(r.svg)).catch((err) => fail(err));
600
733
  } else if (ret && ret.svg) {
601
734
  onSvg(ret.svg);
602
735
  } else if (typeof ret === 'string') {
@@ -606,9 +739,9 @@
606
739
  mermaid.render(id, code, (svg) => onSvg(svg));
607
740
  }
608
741
  } catch (err) {
609
- showMermaidErr(container, err);
742
+ fail(err);
610
743
  }
611
- }).catch((err) => showMermaidErr(container, err));
744
+ }).catch((err) => fail(err));
612
745
  }
613
746
 
614
747
  // Live theme toggle: re-render mermaid diagrams with the new mermaid theme
@@ -616,6 +749,8 @@
616
749
  function onThemeChange() {
617
750
  for (const entry of mermaidRegistry) {
618
751
  try {
752
+ // re-render with the effective code so an edit survives the toggle
753
+ entry.code = effectiveMermaidCode(entry);
619
754
  drawMermaid(entry);
620
755
  } catch {
621
756
  // keep the previous svg on failure
@@ -648,6 +783,120 @@
648
783
  );
649
784
  }
650
785
 
786
+ // ---------- graphviz (offline, vendored Viz.js -> SVG) ----------
787
+ // Same sizing rule as mermaid: never upscale past the diagram's natural
788
+ // width; shrink on narrow screens. Authors set their own colors so there is
789
+ // no theme re-render.
790
+ function sizeDiagramSvg(svgEl) {
791
+ if (!svgEl) return;
792
+ svgEl.removeAttribute('height');
793
+ svgEl.removeAttribute('width');
794
+ const vb = svgEl.viewBox && svgEl.viewBox.baseVal;
795
+ if (vb && vb.width > 0) {
796
+ svgEl.style.width = '100%';
797
+ svgEl.style.maxWidth = Math.ceil(vb.width) + 'px';
798
+ svgEl.style.height = 'auto';
799
+ } else {
800
+ svgEl.style.maxWidth = '100%';
801
+ }
802
+ }
803
+
804
+ function renderGraphviz(block, ctx, blockId) {
805
+ const container = el('div', { class: 'blk-graphviz' });
806
+ loadViz()
807
+ .then((Viz) => Viz.instance())
808
+ .then((viz) => {
809
+ const svgEl = viz.renderSVGElement(block.dot || '');
810
+ sizeDiagramSvg(svgEl);
811
+ container.replaceChildren(svgEl);
812
+ if (!ctx.annotate) return;
813
+ const parts = svgEl.querySelectorAll('g.node, g.edge');
814
+ parts.forEach((g) => {
815
+ const titleEl = g.querySelector('title');
816
+ const nodeId = (g.id || (titleEl && titleEl.textContent) || '').trim();
817
+ // Label text lives in <text> elements; g.textContent would also
818
+ // include the <title> child and duplicate the label.
819
+ const labels = Array.from(g.querySelectorAll('text')).map((t) => t.textContent.trim()).filter(Boolean);
820
+ const text = (labels.join(' ') || (titleEl && titleEl.textContent) || '').trim().slice(0, 120);
821
+ ctx.annotate.register(g, {
822
+ blockId,
823
+ questionId: ctx.questionId,
824
+ target: {
825
+ kind: 'graphviz-node',
826
+ nodeId,
827
+ text,
828
+ },
829
+ });
830
+ });
831
+ })
832
+ .catch((err) => {
833
+ container.replaceChildren(
834
+ el('div', { class: 'blk-error' }, 'Graphviz error: ' + (err && err.message ? err.message : String(err)))
835
+ );
836
+ });
837
+ return container;
838
+ }
839
+
840
+ // ---------- plantuml (server-rendered; client deflate-raw + base64 variant) ----------
841
+ const PLANTUML_ALPHABET = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-_';
842
+
843
+ // PlantUML's base64 variant: 3 bytes -> 4 chars using PLANTUML_ALPHABET.
844
+ function encode64(bytes) {
845
+ let out = '';
846
+ for (let i = 0; i < bytes.length; i += 3) {
847
+ const b1 = bytes[i];
848
+ const b2 = i + 1 < bytes.length ? bytes[i + 1] : 0;
849
+ const b3 = i + 2 < bytes.length ? bytes[i + 2] : 0;
850
+ out += PLANTUML_ALPHABET[b1 >> 2];
851
+ out += PLANTUML_ALPHABET[((b1 & 0x3) << 4) | (b2 >> 4)];
852
+ if (i + 1 < bytes.length) out += PLANTUML_ALPHABET[((b2 & 0xf) << 2) | (b3 >> 6)];
853
+ if (i + 2 < bytes.length) out += PLANTUML_ALPHABET[b3 & 0x3f];
854
+ }
855
+ return out;
856
+ }
857
+
858
+ async function encodePlantUml(code) {
859
+ if (typeof CompressionStream === 'undefined') {
860
+ throw new Error('CompressionStream unavailable');
861
+ }
862
+ const bytes = new TextEncoder().encode(String(code));
863
+ const blob = new Blob([bytes]);
864
+ const compressed = await new Response(
865
+ blob.stream().pipeThrough(new CompressionStream('deflate-raw'))
866
+ ).arrayBuffer();
867
+ return encode64(new Uint8Array(compressed));
868
+ }
869
+
870
+ function renderPlantuml(block, ctx, blockId) {
871
+ const container = el('div', { class: 'blk-plantuml' });
872
+ const fail = () =>
873
+ container.replaceChildren(
874
+ el('div', { class: 'blk-error' }, 'PlantUML needs network access and a modern browser')
875
+ );
876
+ encodePlantUml(block.code || '')
877
+ .then((encoded) => {
878
+ const server = block.server || 'https://www.plantuml.com/plantuml';
879
+ const img = el('img', {
880
+ class: 'blk-plantuml-img',
881
+ src: server + '/svg/' + encoded,
882
+ alt: 'PlantUML diagram',
883
+ loading: 'lazy',
884
+ });
885
+ if (block.height) img.style.height = clampHeight(block.height, 360) + 'px';
886
+ img.addEventListener('error', fail);
887
+ container.replaceChildren(img);
888
+ if (ctx.annotate) {
889
+ ctx.annotate.register(img, {
890
+ blockId,
891
+ questionId: ctx.questionId,
892
+ target: { kind: 'image', label: 'PlantUML diagram' },
893
+ });
894
+ }
895
+ })
896
+ .catch(fail);
897
+ return container;
898
+ }
899
+
651
900
  // ---------- html (sandboxed iframe) ----------
652
901
  function renderHtml(block, ctx, blockId) {
653
902
  const height = clampHeight(block.height, 360);
@@ -690,6 +939,14 @@
690
939
  inner = renderMermaid(block, ctx, blockId);
691
940
  wrapper.append(inner);
692
941
  break;
942
+ case 'graphviz':
943
+ inner = renderGraphviz(block, ctx, blockId);
944
+ wrapper.append(inner);
945
+ break;
946
+ case 'plantuml':
947
+ inner = renderPlantuml(block, ctx, blockId);
948
+ wrapper.append(inner);
949
+ break;
693
950
  case 'html':
694
951
  inner = renderHtml(block, ctx, blockId);
695
952
  wrapper.append(inner);
@@ -708,6 +965,11 @@
708
965
  htmlSrc: ctx && ctx.htmlSrc ? ctx.htmlSrc : (id) => '/html/b/' + id,
709
966
  questionId: ctx && ctx.questionId !== undefined ? ctx.questionId : null,
710
967
  annotate: ctx && ctx.annotate ? ctx.annotate : null,
968
+ // editable-mermaid plumbing: edits maps blockId -> edited code; onBlockEdit
969
+ // reports an accepted change (or null to clear back to the original).
970
+ edits: ctx && ctx.edits ? ctx.edits : {},
971
+ onBlockEdit:
972
+ ctx && typeof ctx.onBlockEdit === 'function' ? ctx.onBlockEdit : () => {},
711
973
  };
712
974
  for (const block of list) {
713
975
  if (!block || !block.type) continue;
package/src/ui/style.css CHANGED
@@ -206,6 +206,15 @@ textarea { min-height: 90px; resize: vertical; }
206
206
  z-index: 10; display: none;
207
207
  }
208
208
 
209
+ /* Live-update toast: flagged after a `rly update` reload (see app.js).
210
+ Fixed top-center, accent-soft bg, accent text; JS auto-removes it. */
211
+ .toast {
212
+ position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
213
+ background: var(--accent-soft); color: var(--accent);
214
+ padding: 10px 18px; border-radius: 10px; font-size: 0.9rem;
215
+ box-shadow: var(--shadow-lift); z-index: 50;
216
+ }
217
+
209
218
  .done { text-align: center; padding: 80px 18px; }
210
219
  .done .mark {
211
220
  width: 64px; height: 64px; border-radius: 50%;
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "chart.js": "4.5.1",
3
3
  "mermaid": "11.15.0",
4
- "updatedAt": "2026-06-11T09:20:33.586Z"
4
+ "@viz-js/viz": "3.28.0",
5
+ "updatedAt": "2026-06-11T10:09:03.988Z"
5
6
  }