@khanglvm/relay 0.11.0 → 0.12.1

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/README.md CHANGED
@@ -123,9 +123,12 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
123
123
  - **Streamable HTTP transport for web/mobile/remote** — `rly mcp --http
124
124
  [--port N --host H --token SECRET --allow-origin ORIGIN]` serves the same tools
125
125
  over MCP's Streamable HTTP transport (single `/mcp` endpoint, JSON responses,
126
- CORS, Origin validation, bearer auth, `Mcp-Session-Id`). Expose it via a tunnel
127
- or small host and add it as a custom connector so a phone/web host can render
128
- boards too stdio stays the zero-setup path for local desktop.
126
+ CORS, Origin validation, bearer auth, `Mcp-Session-Id`, `$PORT`-aware). relay is
127
+ **stateless**, so one instance serves everyone deploy it once with the repo
128
+ **`Dockerfile`** on any free MCP host (mcpdeploy.dev, mcphosting.io,
129
+ Render/Railway/Fly, Glama) or publish to **Smithery**, then add the URL as a
130
+ custom connector. Only the relay CLI is required — no tunnel/tailscale. stdio
131
+ stays the zero-setup path for local desktop.
129
132
  - **Native look** — the inline board **color-blends** onto the host's SEP-1865
130
133
  style variables (surfaces, text, borders, primary button, fonts), pins
131
134
  `color-scheme` so `light-dark()` tokens resolve, and uses the host's **own
@@ -138,6 +141,9 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
138
141
  copies); mark one `featured` for a spotlight. **`color` question type** — native
139
142
  picker + hex field + optional `presets`, returns a hex string. Both work on the
140
143
  browser board and inline.
144
+ - **Element annotations inline** — comment on chart points, diagram nodes, table
145
+ cells, images and text selections in the MCP board too, returned in
146
+ `annotations` exactly like the CLI board.
141
147
 
142
148
  ### 0.10.0 — open files, richer code, diffs & video
143
149
  - **Clickable local file-links.** Write a path in any markdown (`~/clip.mp4`,
package/docs/AGENT.md CHANGED
@@ -75,13 +75,25 @@ browser tab. `rly mcp` speaks the MCP **stdio** transport, so it pairs with a
75
75
  - **`relay_show`** — present a plan, diagram, diff, table, or prototype.
76
76
 
77
77
  **Web / mobile / remote hosts** can't reach a stdio subprocess, so for those run
78
- the **Streamable HTTP** transport instead: `rly mcp --http [--port N]` serves the
79
- same tools at `http://127.0.0.1:<port>/mcp`. Expose that URL (e.g. a
80
- cloudflared/ngrok tunnel, or a small always-on host) and register it as a custom
81
- connector; add `--token <secret>` + `--allow-origin <host>` when it's public.
82
- `rly mcp config` prints the recipe. Note: an HTTP server only sees local files if
83
- it runs on the machine those files live on (tunnel from your dev box to keep
84
- local `codeFile`/image blocks working).
78
+ the **Streamable HTTP** transport: `rly mcp --http [--port N]` serves the same
79
+ tools at `/mcp`. relay needs **only its own CLI** for this — no tunnel tool, no
80
+ extra infra. Because relay is **stateless** (answers go iframe→host→model, never
81
+ back to the server), **one deployed instance serves everyone**, so deploy it once
82
+ on any address the app can reach and register that URL as a custom connector:
83
+
84
+ - **Free MCP hosts** — the repo's `Dockerfile` runs relay with zero config on
85
+ **mcpdeploy.dev**, **mcphosting.io**, **Render / Railway / Fly**, or **Glama**;
86
+ the platform's `$PORT` is honored automatically. Set `RLY_MCP_TOKEN` for a
87
+ bearer-protected endpoint.
88
+ - **Smithery** (largest MCP marketplace) — publish the deployed URL with
89
+ `smithery mcp publish <url> -n @you/relay` for discovery + an OAuth gateway, or
90
+ distribute relay as a stdio bundle clients run locally.
91
+ - **Tunnel (optional, dev only)** — `cloudflared`/`tailscale` is *only* for
92
+ exposing a NAT'd laptop; it's never required and not a relay dependency.
93
+
94
+ A hosted instance can't see your local files (so `codeFile`/local-image blocks
95
+ won't resolve there — pass URLs or inline content); run it on your own machine if
96
+ you need local-file access.
85
97
 
86
98
  Progressive rendering: if the host streams the tool call as you write it, the
87
99
  board renders valid blocks/questions incrementally (a "Composing…" preview) and
@@ -97,13 +109,15 @@ board stays live until the user submits and the host delivers the result.
97
109
  **Near-full parity with the browser board.** Local `codeFile` / `htmlFile` /
98
110
  `diffFile` and local **image** files work inline too — the server inlines them
99
111
  (images as data URIs) while normalizing your spec, so the sandboxed board needs
100
- no file access. The board also adopts the host's **theme, fonts and colors** and
101
- offers a **full-screen** toggle. The only inline-mode gaps vs. the browser board:
102
- **local video files** (use a YouTube/Vimeo/`https` URL instead those play) and
103
- **element-level annotations** (comment-on-any-element) per-question notes and
104
- the overall comment still carry feedback back to you. Everything else —
105
- questions plus markdown/code/diff/table/chart/mermaid/graphviz/plantuml/image/html
106
- renders identically.
112
+ no file access. **Element-level annotations work inline** the user can comment
113
+ on chart points, diagram nodes, table cells, images and text selections, returned
114
+ in `annotations` just like the CLI. The board also adopts the host's **theme,
115
+ fonts and colors** and uses the host's **full-screen** control. The only
116
+ inline-mode gaps vs. the browser board: **local video files** (use a
117
+ YouTube/Vimeo/`https` URL instead — those play) and commenting on elements
118
+ *inside* a custom-HTML mockup (every other annotation target works). Everything
119
+ else — questions plus markdown/code/diff/table/chart/mermaid/graphviz/plantuml/
120
+ image/palette/html — renders identically.
107
121
 
108
122
  ## Creating boards
109
123
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.11.0",
3
+ "version": "0.12.1",
4
4
  "description": "Question boards with rich blocks (markdown, charts, mermaid, tables, code, diffs, video, sandboxed HTML), clickable local file-links, and element-level annotations for AI coding agents (Claude Code, Codex, …): ask users structured questions, present interactive visuals, collect inline comments, read answers as JSON — in a local browser board OR rendered INLINE inside the Claude & Codex apps as an MCP App (SEP-1865).",
5
5
  "keywords": [
6
6
  "ai-agents",
@@ -104,8 +104,9 @@ answers and any annotations written so far.
104
104
  }
105
105
  ```
106
106
 
107
- Types: `single`, `multi`, `yesno`, `text`, `textarea`, `scale`. Users may
108
- submit with unanswered questions (returned in `skipped`) unless
107
+ Types: `single`, `multi`, `yesno`, `text`, `textarea`, `scale`, `color`
108
+ (native picker + hex; optional `"presets":["#…"]`, returns a hex string). Users
109
+ may submit with unanswered questions (returned in `skipped`) unless
109
110
  `"allowPartial": false` or per-question `"required": true`.
110
111
 
111
112
  Set `"note": true` on a question to add a small optional free-text field under
@@ -155,6 +156,8 @@ single/multi question.
155
156
  // screen + zoom (⌘/Ctrl+wheel or −/+, up to 8× native) + drag-to-pan viewer,
156
157
  // so detail is always reachable regardless of height. Local images embed up
157
158
  // to 8 MB; for a huge/high-detail image pass an http(s) URL (streamed, no cap).
159
+ { "type": "palette", "palettes": [{ "name":"Brand", "colors":["#3B8EA5","#6DBAD1","#1E6278"], "featured": true }] }
160
+ // ^ color palettes as swatch cards: hover=hex, click=copy. Shorthand {"type":"palette","colors":[…]}; pair with a `color` question.
158
161
  ```
159
162
 
160
163
  ### Visual options — show each choice, don't describe it
@@ -0,0 +1,42 @@
1
+ {
2
+ "title": "Brand color direction",
3
+ "intro": "Pick a direction from the curated palettes, then choose the exact accent.",
4
+ "blocks": [
5
+ {
6
+ "type": "palette",
7
+ "title": "Candidate palettes — hover a swatch for its hex, click to copy",
8
+ "palettes": [
9
+ { "name": "Coastal Calm", "sub": "ocean, serene", "tag": "Cool", "tagTone": "cool", "featured": true,
10
+ "colors": ["#3B8EA5", "#6DBAD1", "#1E6278", "#A8DDE9", "#D6EEF5"] },
11
+ { "name": "Peach Fuzz", "mood": "warm, optimistic", "tag": "Warm", "tagTone": "warm",
12
+ "colors": ["#F2A07B", "#E8795A", "#C4543C", "#FBCFB8"] },
13
+ { "name": "Muted Sage", "mood": "earthy, wellness", "tag": "Nature", "tagTone": "nature",
14
+ "colors": ["#8DB87E", "#5D9252", "#3A6B34", "#BDD9B3"] }
15
+ ]
16
+ },
17
+ { "type": "palette", "name": "Current brand", "colors": ["#c2674b", "#1c1b19", "#fcfbf9"] }
18
+ ],
19
+ "questions": [
20
+ {
21
+ "id": "direction",
22
+ "type": "single",
23
+ "label": "Which palette direction?",
24
+ "required": true,
25
+ "options": [
26
+ { "value": "coastal", "label": "Coastal Calm" },
27
+ { "value": "peach", "label": "Peach Fuzz" },
28
+ { "value": "sage", "label": "Muted Sage" },
29
+ { "value": "keep", "label": "Keep current brand" }
30
+ ]
31
+ },
32
+ {
33
+ "id": "accent",
34
+ "type": "color",
35
+ "label": "Exact accent color",
36
+ "description": "Use the picker or paste a hex; presets are the featured palette.",
37
+ "presets": ["#3B8EA5", "#E8795A", "#5D9252", "#c2674b"],
38
+ "default": "#3B8EA5"
39
+ }
40
+ ],
41
+ "submitLabel": "Lock the palette"
42
+ }
package/src/cli.js CHANGED
@@ -1195,11 +1195,14 @@ async function cmdMcp(rest) {
1195
1195
  }
1196
1196
  const args = parseArgs(rest);
1197
1197
  if (args.http) {
1198
+ // Port precedence: --port flag, then PaaS-injected $PORT / $RLY_MCP_PORT,
1199
+ // then the default. PaaS hosts (Render, Railway, Fly, mcpdeploy, …) set $PORT.
1200
+ const envPort = process.env.PORT || process.env.RLY_MCP_PORT;
1198
1201
  return runMcpHttp({
1199
- port: args.port ? Number(args.port) : (process.env.RLY_MCP_PORT ? Number(process.env.RLY_MCP_PORT) : undefined),
1200
- host: typeof args.host === 'string' ? args.host : '127.0.0.1',
1202
+ port: args.port ? Number(args.port) : (envPort ? Number(envPort) : undefined),
1203
+ host: typeof args.host === 'string' ? args.host : (process.env.RLY_MCP_HOST || '127.0.0.1'),
1201
1204
  token: typeof args.token === 'string' ? args.token : (process.env.RLY_MCP_TOKEN || ''),
1202
- allowOrigin: typeof args.allowOrigin === 'string' ? args.allowOrigin : '',
1205
+ allowOrigin: typeof args.allowOrigin === 'string' ? args.allowOrigin : (process.env.RLY_MCP_ALLOW_ORIGIN || ''),
1203
1206
  });
1204
1207
  }
1205
1208
  // Default: be the stdio server. main() never resolves this, so the CLI's
@@ -350,7 +350,9 @@
350
350
  theme: effectiveTheme,
351
351
  htmlSrc: (blockId) => htmlBlobSrc(blockId),
352
352
  questionId: questionId == null ? null : questionId,
353
- annotate: null,
353
+ // No annotation during the streaming preview (it re-renders); on the final
354
+ // interactive render, wire the engine so blocks register their targets.
355
+ annotate: composing ? null : Annotate,
354
356
  edits: state.blockEdits,
355
357
  onBlockEdit: (blockId, codeOrNull) => {
356
358
  if (codeOrNull === null || codeOrNull === undefined) delete state.blockEdits[blockId];
@@ -379,7 +381,12 @@
379
381
  // ======================================================================
380
382
  let spec = null;
381
383
  let QS = [];
382
- const state = { answers: {}, other: {}, notes: {}, comment: '', blockEdits: {} };
384
+ const state = { answers: {}, other: {}, notes: {}, comment: '', blockEdits: {}, annotations: [] };
385
+ // Element-level comments: the SAME self-contained annotate engine the browser
386
+ // board uses (no server — it reports the thread list via onChange). Charts,
387
+ // diagram nodes, table cells, images and text selections become commentable;
388
+ // the comments ride back in the submission alongside answers.
389
+ const Annotate = (window.RelayAnnotate && typeof window.RelayAnnotate.init === 'function') ? window.RelayAnnotate : null;
383
390
  let submitted = false;
384
391
  const cards = {};
385
392
  const app = document.getElementById('app');
@@ -434,7 +441,8 @@
434
441
  }
435
442
  const skipped = QS.filter((q) => !(q.id in answers)).map((q) => q.id);
436
443
  const blockEdits = Object.keys(state.blockEdits).length ? state.blockEdits : null;
437
- return { answers, skipped, comment: (state.comment || '').trim(), notes, blockEdits };
444
+ const annotations = Array.isArray(state.annotations) && state.annotations.length ? state.annotations : null;
445
+ return { answers, skipped, comment: (state.comment || '').trim(), notes, blockEdits, annotations };
438
446
  }
439
447
 
440
448
  function clearErr(qid) { if (cards[qid]) cards[qid].classList.remove('error'); }
@@ -605,12 +613,17 @@
605
613
  // ======================================================================
606
614
  function render() {
607
615
  app.replaceChildren();
608
- app.append(el('header', { class: 'qb-header' }, el('h1', {}, spec.title)));
616
+ const annotateOn = !composing && Annotate;
617
+ const titleEl = el('h1', {}, spec.title);
618
+ app.append(el('header', { class: 'qb-header' }, titleEl));
619
+ if (annotateOn) Annotate.register(titleEl, { blockId: null, questionId: null, target: { kind: 'html-element', label: spec.title } });
609
620
  if (spec.intro) {
610
621
  const md = window.RelayBlocks && RelayBlocks.renderMarkdown;
611
- app.append(md
622
+ const introEl = md
612
623
  ? el('div', { class: 'intro blk-markdown' }, RelayBlocks.renderMarkdown(spec.intro))
613
- : el('p', { class: 'intro' }, spec.intro));
624
+ : el('p', { class: 'intro' }, spec.intro);
625
+ app.append(introEl);
626
+ if (annotateOn) Annotate.enableTextSelection(introEl, { blockId: null, questionId: null });
614
627
  }
615
628
  renderBlocks(app, spec.blocks || [], null);
616
629
 
@@ -694,6 +707,13 @@
694
707
  }
695
708
  }
696
709
  if (data.comment) lines.push('', 'Comment: ' + data.comment);
710
+ if (data.annotations && data.annotations.length) {
711
+ lines.push('', 'Inline comments (' + data.annotations.length + '):');
712
+ for (const a of data.annotations) {
713
+ const where = (a.target && (a.target.label || a.target.text || a.target.kind)) || a.blockId || 'element';
714
+ lines.push('- [' + where + ']: ' + a.text);
715
+ }
716
+ }
697
717
  if (data.blockEdits) lines.push('', 'Edited diagrams: ' + Object.keys(data.blockEdits).join(', '));
698
718
  return lines.join('\n');
699
719
  }
@@ -712,6 +732,7 @@
712
732
  notes: data.notes,
713
733
  comment: data.comment,
714
734
  blockEdits: data.blockEdits,
735
+ annotations: data.annotations,
715
736
  };
716
737
  const text = summarize(data);
717
738
  let delivered = false;
@@ -729,6 +750,7 @@
729
750
  function showDone(delivered) {
730
751
  // Collapse: leave fullscreen, drop the whole form for a one-line confirmation
731
752
  // so the host shrinks the iframe to a small footprint in the transcript.
753
+ if (Annotate) { try { Annotate.teardown(); } catch { /* nothing to tear down */ } }
732
754
  document.documentElement.classList.remove('mcp-fullscreen');
733
755
  app.replaceChildren(el('div', { class: 'mcp-done' },
734
756
  el('span', { class: 'mark' }, '✓'),
@@ -765,6 +787,7 @@
765
787
  seedDefaults();
766
788
  indexHtmlBlocks(spec);
767
789
  setStatus('Preparing…');
790
+ if (Annotate) Annotate.init({ initial: [], onChange: (a) => { state.annotations = Array.isArray(a) ? a : []; } });
768
791
  try { await preloadVendors(spec); } catch { /* render anyway; blocks degrade individually */ }
769
792
  render();
770
793
  applyDisplayMode();
@@ -7,6 +7,7 @@
7
7
  <title>__TITLE__</title>
8
8
  <style>/*__CSS__*/
9
9
  /*__BLOCKS_CSS__*/
10
+ /*__ANNOTATE_CSS__*/
10
11
  /* MCP-app inline tweaks: the host owns the outer chrome, so the board paints
11
12
  edge-to-edge with no min-height / page background of its own. */
12
13
  :root{ color-scheme: light dark; }
@@ -31,6 +32,7 @@ body{ margin:0; }
31
32
  <script id="boot" type="application/json">__BOOT_JSON__</script>
32
33
  <div id="app" class="wrap"><div class="mcp-status" id="mcp-status">Loading the relay board…</div></div>
33
34
  <script>/*__BLOCKS_JS__*/</script>
35
+ <script>/*__ANNOTATE_JS__*/</script>
34
36
  <script>/*__BOARD_JS__*/</script>
35
37
  </body>
36
38
  </html>
package/src/mcp.js CHANGED
@@ -62,7 +62,9 @@ function buildBoardPage() {
62
62
  .split('__TITLE__').join('Relay')
63
63
  .split('/*__CSS__*/').join(readAsset(UI_DIR, 'style.css'))
64
64
  .split('/*__BLOCKS_CSS__*/').join(readAsset(UI_DIR, 'blocks.css'))
65
+ .split('/*__ANNOTATE_CSS__*/').join(readAsset(UI_DIR, 'annotate.css'))
65
66
  .split('/*__BLOCKS_JS__*/').join(readAsset(UI_DIR, 'blocks.js'))
67
+ .split('/*__ANNOTATE_JS__*/').join(readAsset(UI_DIR, 'annotate.js'))
66
68
  .split('/*__BOARD_JS__*/').join(readAsset(MCP_UI_DIR, 'board.js'))
67
69
  .split('__BOOT_JSON__').join(JSON.stringify(boot).replace(/</g, '\\u003c'));
68
70
  _cache.set('__page__', page);
@@ -441,11 +443,12 @@ export function mcpConfig({ command = 'rly', httpPort = DEFAULT_HTTP_PORT } = {}
441
443
  // Codex CLI (~/.codex/config.toml).
442
444
  toml: `[mcp_servers.relay]\ncommand = "${command}"\nargs = ["mcp"]\n`,
443
445
  // Remote / web+mobile hosts (Claude web/mobile custom connector, etc.):
444
- // run `rly mcp --http` somewhere reachable and register the URL.
446
+ // run `rly mcp --http` on any reachable address and register the URL.
445
447
  http: {
446
448
  run: `${command} mcp --http --port ${httpPort}`,
447
449
  url: `http://127.0.0.1:${httpPort}${HTTP_PATH}`,
448
- note: 'For web/mobile, expose this URL (e.g. a cloudflared/ngrok tunnel) and add it as a custom connector. Use --token <secret> + --allow-origin <host> when exposing it publicly.',
450
+ deploy: 'relay only needs its CLI run `rly mcp --http` wherever the app can reach it. The repo Dockerfile deploys to any free MCP host (mcpdeploy.dev, mcphosting.io, Render/Railway/Fly, or Smithery) with zero config; $PORT is auto-honored. relay is stateless, so one instance serves everyone.',
451
+ note: 'Set RLY_MCP_TOKEN for a bearer-protected endpoint. A tunnel (cloudflared/tailscale) is ONLY for exposing a NAT\'d local machine — never required; deploying to a reachable host needs no extra tooling.',
449
452
  },
450
453
  };
451
454
  }
package/src/server.js CHANGED
@@ -627,6 +627,9 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
627
627
  } else if (req.method === 'POST' && pathname === '/api/pref') {
628
628
  const body = JSON.parse((await readBody(req)) || '{}');
629
629
  if (['auto', 'light', 'dark'].includes(body.theme)) savePref({ theme: body.theme });
630
+ if (typeof body.fontScale === 'number' && body.fontScale >= 0.5 && body.fontScale <= 2) {
631
+ savePref({ fontScale: body.fontScale });
632
+ }
630
633
  sendJson(res, 200, { ok: true });
631
634
  } else if (req.method === 'POST' && pathname === '/api/open') {
632
635
  // Open a board-referenced local file in the OS default app. Guarded by
@@ -107,6 +107,9 @@
107
107
  let popOpen = false;
108
108
  let popScrollY = 0;
109
109
  let popSave = null;
110
+ // Frozen by the host (e.g. relay's connection-lost block): suppress every way
111
+ // to START a comment, so the user can't type feedback that won't be saved.
112
+ let disabled = false;
110
113
  let badgeTimer = 0;
111
114
  let railOpen = false;
112
115
 
@@ -377,6 +380,7 @@
377
380
 
378
381
  // ---------- hover pin ----------
379
382
  function showPin(entry) {
383
+ if (disabled) return;
380
384
  clearTimeout(pinTimer);
381
385
  pinEntry = entry;
382
386
  const rect = entry.el.getBoundingClientRect();
@@ -483,6 +487,7 @@
483
487
  }
484
488
 
485
489
  function openPopover(info, anchorEl) {
490
+ if (disabled) return;
486
491
  ensureDom();
487
492
  closePopover();
488
493
  hidePin();
@@ -641,6 +646,7 @@
641
646
  }
642
647
 
643
648
  function maybeShowSelBtn(rootEl, baseInfo) {
649
+ if (disabled) return hideSelBtn();
644
650
  const sel = window.getSelection();
645
651
  if (!sel || sel.isCollapsed || !sel.rangeCount) return hideSelBtn();
646
652
  if (!rootEl.contains(sel.anchorNode) || !rootEl.contains(sel.focusNode)) return hideSelBtn();
@@ -844,11 +850,35 @@
844
850
  return annotations.slice();
845
851
  }
846
852
 
853
+ // True while the user is mid-comment: the popover is open, which hosts both
854
+ // the "Add a comment" box and every thread's reply input. That text lives
855
+ // only in the DOM until Save, so the board must not reload it away.
856
+ function isComposing() {
857
+ return popOpen;
858
+ }
859
+
860
+ // Commit a mid-typed comment (the open popover's "Add a comment" box) as a
861
+ // saved annotation, then close it — so freezing the board doesn't throw away
862
+ // text the user was in the middle of writing. ponytail: only the main comment
863
+ // box is flushed, not an in-progress thread reply (the rare case).
864
+ function flushOpen() {
865
+ if (popOpen && popSave) { try { popSave(); } catch { closePopover(); } }
866
+ }
867
+
868
+ // Freeze/unfreeze comment creation. While disabled, no pin, selection button,
869
+ // or popover appears, so the user can't start feedback that won't be saved.
870
+ function setDisabled(v) {
871
+ disabled = Boolean(v);
872
+ // Also close the rail flushOpen() may have just opened — otherwise it covers
873
+ // the host's top-right "connection lost" notice.
874
+ if (disabled) { closePopover(); hideSelBtn(); hidePin(); closeRail(); }
875
+ }
876
+
847
877
  function renderSummary(target) {
848
878
  ensureDom();
849
879
  summaryEls.add(target);
850
880
  renderSummaryInto(target);
851
881
  }
852
882
 
853
- window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, onBadgeRefresh, teardown };
883
+ window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, onBadgeRefresh, teardown, isComposing, flushOpen, setDisabled };
854
884
  })();
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
@@ -246,13 +271,15 @@
246
271
  // board's local HTTP server (server gone, port taken over, socket dropped,
247
272
  // machine slept). Autosaves then fail silently and the user keeps typing
248
273
  // answers/comments that are never persisted, then Submit fails too — all of it
249
- // thrown away. When persistence is CONFIRMED lost we hard-block: disable every
250
- // control and overlay an unmissable scrim with a Retry. Local `state` is never
251
- // touched, so the moment the connection recovers we flush it and unblock —
252
- // nothing the user typed during the outage is lost.
274
+ // thrown away. When persistence is CONFIRMED lost we DISABLE every control (and
275
+ // freeze new comments) and show a small, non-dismissable top-right notice with
276
+ // a Reconnect button without blocking the page, so the user can still read
277
+ // and scroll. Local `state` is never touched, and a mid-typed comment is saved
278
+ // before the freeze, so nothing the user entered during the outage is lost; the
279
+ // moment the connection recovers we flush state and re-enable everything.
253
280
  let persistenceLost = false;
254
281
  let probing = false;
255
- let lostOverlay = null;
282
+ let lostNote = null;
256
283
  let lostRetryBtn = null;
257
284
 
258
285
  // A direct, side-effect-free reachability probe. Resolves true when the local
@@ -268,46 +295,55 @@
268
295
  }
269
296
  }
270
297
 
271
- function buildLostOverlay() {
272
- if (lostOverlay) return lostOverlay;
273
- lostRetryBtn = el('button', { class: 'lost-retry', type: 'button' }, 'Retry connection');
298
+ function buildLostNote() {
299
+ if (lostNote) return lostNote;
300
+ lostRetryBtn = el('button', { class: 'lost-retry', type: 'button' }, 'Reconnect');
274
301
  lostRetryBtn.addEventListener('click', retryConnection);
275
- lostOverlay = el('div', { class: 'lost-overlay', role: 'alertdialog', 'aria-modal': 'true', 'aria-label': 'Connection lost' },
276
- el('div', { class: 'lost-card' },
277
- el('div', { class: 'lost-mark' }, '⚠'),
278
- el('h2', {}, 'Connection lost — input isn’t being saved'),
279
- el('p', {}, 'This board can no longer reach your agent’s session, so anything you type now won’t be saved. Editing is paused to keep you from losing work.'),
280
- el('p', { class: 'lost-sub' }, 'Your input up to this point is kept in this tab. Click Retry once the agent’s session is back, or prompt the agent to reopen this board — your draft and unsaved edits will be restored.'),
281
- lostRetryBtn
282
- )
302
+ lostNote = el('div', { class: 'lost-note', role: 'status', 'aria-live': 'polite' },
303
+ el('span', { class: 'lost-note-dot', 'aria-hidden': 'true' }),
304
+ el('div', { class: 'lost-note-body' },
305
+ el('div', { class: 'lost-note-title' }, 'Connection lost'),
306
+ el('div', { class: 'lost-note-sub' }, 'Editing paused your input is kept.')
307
+ ),
308
+ lostRetryBtn
283
309
  );
284
- return lostOverlay;
310
+ return lostNote;
285
311
  }
286
312
 
287
- // Enter the blocked state: disable controls, mount the overlay. Idempotent.
313
+ // Enter the blocked state: save a mid-typed comment, freeze comments, disable
314
+ // controls, and pin the small notice. Idempotent. Does NOT block the page —
315
+ // the user can still read and scroll; only editing is paused.
288
316
  function blockForLostPersistence() {
289
317
  if (persistenceLost || submitted) return;
290
318
  persistenceLost = true;
291
319
  document.documentElement.classList.add('relay-blocked');
292
- // Disable every interactive control inside the form (inputs the user could
293
- // otherwise keep typing into) plus Submit.
320
+ // Save whatever the user was typing into the comment popover BEFORE freezing
321
+ // it, so the in-progress comment is kept (in `state` + localStorage) instead
322
+ // of discarded; then stop any new comment from being started.
323
+ if (Annotate) {
324
+ if (typeof Annotate.flushOpen === 'function') { try { Annotate.flushOpen(); } catch { /* non-fatal */ } }
325
+ if (typeof Annotate.setDisabled === 'function') { try { Annotate.setDisabled(true); } catch { /* non-fatal */ } }
326
+ }
327
+ // Disable every interactive control inside the form. Values already entered
328
+ // stay in the DOM (and in `state`), so nothing typed so far is lost.
294
329
  for (const node of app.querySelectorAll('input, textarea, button, select')) {
295
330
  node.disabled = true;
296
331
  }
297
- document.body.append(buildLostOverlay());
332
+ document.body.append(buildLostNote());
298
333
  if (saveEl) saveEl.textContent = 'connection lost — not saving';
299
334
  }
300
335
 
301
- // Leave the blocked state: re-enable controls, remove the overlay, and flush
302
- // whatever the user typed during the outage so it's persisted right away.
336
+ // Leave the blocked state: re-enable controls + comments, drop the notice, and
337
+ // flush whatever the user typed during the outage so it's persisted right away.
303
338
  function unblockAfterRecovery() {
304
339
  if (!persistenceLost) return;
305
340
  persistenceLost = false;
306
341
  document.documentElement.classList.remove('relay-blocked');
342
+ if (Annotate && typeof Annotate.setDisabled === 'function') { try { Annotate.setDisabled(false); } catch { /* non-fatal */ } }
307
343
  for (const node of app.querySelectorAll('input, textarea, button, select')) {
308
344
  node.disabled = false;
309
345
  }
310
- if (lostOverlay) lostOverlay.remove();
346
+ if (lostNote) lostNote.remove();
311
347
  // Re-arm the heartbeat (it stops itself when it confirms loss) and persist
312
348
  // everything typed during the outage. saveDraft() updates the save label.
313
349
  startHeartbeat();
@@ -744,6 +780,12 @@
744
780
  });
745
781
  applyTheme();
746
782
 
783
+ const fsDown = el('button', { class: 'fs-btn', type: 'button', title: 'Smaller text', 'aria-label': 'Decrease text size' }, 'A−');
784
+ const fsUp = el('button', { class: 'fs-btn', type: 'button', title: 'Larger text', 'aria-label': 'Increase text size' }, 'A+');
785
+ fsDown.addEventListener('click', () => setFontScale(fontScale - FS_STEP));
786
+ fsUp.addEventListener('click', () => setFontScale(fontScale + FS_STEP));
787
+ const fsCtrl = el('div', { class: 'fs-ctrl' }, fsDown, fsUp);
788
+
747
789
  // Just reloaded after a live `rly update`? Flag set before reload below.
748
790
  try {
749
791
  if (sessionStorage.getItem('relay-updated') === '1') {
@@ -755,7 +797,7 @@
755
797
  }
756
798
 
757
799
  const titleEl = el('h1', {}, spec.title);
758
- app.append(el('header', { class: 'qb-header' }, titleEl, themeBtn));
800
+ app.append(el('header', { class: 'qb-header' }, titleEl, el('div', { class: 'qb-controls' }, fsCtrl, themeBtn)));
759
801
  // The board title is commentable too: hovering it shows the pin, like the
760
802
  // intro and every other content element. (themeBtn stays out of it.)
761
803
  if (spec.title) {
@@ -1021,6 +1063,24 @@
1021
1063
  }
1022
1064
 
1023
1065
  // ---------- heartbeat ----------
1066
+ // True when the user is actively typing — an open annotation comment popover,
1067
+ // or focus in any text-entry field (comment box, notes, text/color answers,
1068
+ // "Other" inputs). Used to defer the live-update reload so in-progress
1069
+ // comments aren't destroyed mid-keystroke.
1070
+ function isEditable(node) {
1071
+ if (!node) return false;
1072
+ if (node.tagName === 'TEXTAREA') return true;
1073
+ if (node.tagName === 'INPUT') {
1074
+ const t = (node.type || 'text').toLowerCase();
1075
+ return ['text', 'search', 'email', 'url', 'tel', 'number', 'color'].includes(t);
1076
+ }
1077
+ return node.isContentEditable === true;
1078
+ }
1079
+ function userIsComposing() {
1080
+ if (window.RelayAnnotate && typeof RelayAnnotate.isComposing === 'function' && RelayAnnotate.isComposing()) return true;
1081
+ return isEditable(document.activeElement);
1082
+ }
1083
+
1024
1084
  let misses = 0;
1025
1085
  let reloading = false;
1026
1086
  let hb = null;
@@ -1054,6 +1114,16 @@
1054
1114
  );
1055
1115
  }
1056
1116
  if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
1117
+ // Don't yank the board out from under someone mid-comment: an open
1118
+ // annotation popover (its add-comment box + reply inputs) holds text
1119
+ // that isn't in the draft until Save, so a reload would discard it —
1120
+ // and reloading while any field is focused drops the user's cursor and
1121
+ // last keystrokes. Defer until they're done; the heartbeat re-checks
1122
+ // every tick, so the update applies the moment they close/blur.
1123
+ if (userIsComposing()) {
1124
+ showNotice('The agent updated this board — it’ll refresh as soon as you finish your comment.', 'info');
1125
+ return;
1126
+ }
1057
1127
  reloading = true;
1058
1128
  stopHeartbeat();
1059
1129
  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
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
  }
@@ -1492,6 +1495,9 @@
1492
1495
  // the annotation pins/badges/popover usable while expanded (they live on
1493
1496
  // <body>, which native fullscreen would hide).
1494
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;
1495
1501
 
1496
1502
  // Toolbar icons: full-screen (4-corner expand) and a speech-bubble for the
1497
1503
  // "comment on the whole block" button. Zoom is both cmd/ctrl+wheel AND
@@ -1633,14 +1639,26 @@
1633
1639
  const target = opts.zoomEl;
1634
1640
  const nat = opts.natural();
1635
1641
  const z = container._rlyZ;
1642
+ const full = container.classList.contains('blk-full');
1636
1643
  // A per-block height caps only the compact inline preview. The moment the
1637
1644
  // user zooms in (z !== null) or goes full-screen they want pixel detail, so
1638
1645
  // lift the cap there; restore it when back to the inline fit view.
1639
1646
  if (opts.fitMaxHeight != null) {
1640
- const fit = z === null && !container.classList.contains('blk-full');
1641
- target.style.maxHeight = fit ? opts.fitMaxHeight + 'px' : 'none';
1647
+ target.style.maxHeight = z === null && !full ? opts.fitMaxHeight + 'px' : 'none';
1642
1648
  }
1643
- 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) {
1644
1662
  target.style.width = '100%';
1645
1663
  target.style.maxWidth = nat && nat.w ? Math.ceil(nat.w) + 'px' : '100%';
1646
1664
  target.style.height = 'auto';
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);
@@ -267,46 +282,35 @@ textarea { min-height: 90px; resize: vertical; }
267
282
  .scale button { width: 40px; height: 40px; }
268
283
  }
269
284
 
270
- /* Persistence-lost block: when the board can no longer save the user's input
285
+ /* Persistence-lost notice: when the board can no longer save the user's input
271
286
  (server gone / port taken / machine slept) AND recovery probes have failed,
272
- we disable every control and overlay this unmissable scrim so the user is
273
- physically prevented from typing feedback that would be silently discarded.
274
- Sits above everything (full-screen overlay z 49 / popover / rail / toast). */
275
- .relay-blocked #app { filter: grayscale(0.4) blur(1px); pointer-events: none; user-select: none; }
276
- .lost-overlay {
277
- position: fixed; inset: 0; z-index: 60;
278
- display: flex; align-items: center; justify-content: center;
279
- padding: 24px;
280
- background: color-mix(in srgb, var(--bg) 78%, transparent);
281
- backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
282
- }
283
- .lost-card {
284
- max-width: 460px; width: 100%;
287
+ we disable every control (and freeze new comments) and pin this SMALL,
288
+ non-dismissable note top-right with a Reconnect button. It does NOT block the
289
+ page the user can still read and scroll; only editing is paused. Recovery
290
+ re-enables everything and removes the note. */
291
+ .lost-note {
292
+ position: fixed; top: 12px; right: 12px; z-index: 60;
293
+ display: flex; align-items: center; gap: 10px;
294
+ max-width: min(320px, calc(100vw - 24px));
285
295
  background: var(--card); color: var(--fg);
286
- border: 1px solid var(--border-strong); border-radius: 14px;
296
+ border: 1px solid var(--danger); border-radius: 11px;
287
297
  box-shadow: var(--shadow-lift);
288
- padding: 28px 26px; text-align: center;
289
- }
290
- .lost-card .lost-mark {
291
- width: 52px; height: 52px; border-radius: 50%;
292
- background: var(--danger); color: var(--danger-fg);
293
- font-size: 1.6rem; line-height: 52px; margin: 0 auto 16px;
294
- }
295
- .lost-card h2 {
296
- font-family: var(--serif); font-weight: 500; letter-spacing: -0.015em;
297
- font-size: 1.3rem; margin: 0 0 10px;
298
+ padding: 9px 11px;
298
299
  }
299
- .lost-card p { color: var(--fg-2); font-size: 0.92rem; line-height: 1.55; margin: 0 0 8px; }
300
- .lost-card .lost-sub { color: var(--muted); font-size: 0.82rem; margin: 12px 0 18px; }
301
- .lost-card .lost-retry {
300
+ .lost-note-dot { flex: none; width: 9px; height: 9px; border-radius: 50%; background: var(--danger); }
301
+ .lost-note-body { flex: 1; min-width: 0; line-height: 1.3; }
302
+ .lost-note-title { font-size: 0.82rem; font-weight: 600; }
303
+ .lost-note-sub { font-size: 0.72rem; color: var(--muted); margin-top: 1px; }
304
+ .lost-note .lost-retry {
305
+ flex: none;
302
306
  background: var(--accent); color: var(--accent-fg);
303
- border: none; border-radius: 10px;
304
- padding: 11px 26px; font: inherit; font-size: 0.95rem; font-weight: 600; cursor: pointer;
307
+ border: none; border-radius: 8px;
308
+ padding: 6px 12px; font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer;
305
309
  transition: background 150ms var(--ease);
306
310
  }
307
- .lost-card .lost-retry:hover { background: var(--accent-hover); }
308
- .lost-card .lost-retry:disabled { opacity: 0.6; cursor: default; }
309
- .lost-card .lost-retry:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
311
+ .lost-note .lost-retry:hover { background: var(--accent-hover); }
312
+ .lost-note .lost-retry:disabled { opacity: 0.6; cursor: default; }
313
+ .lost-note .lost-retry:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
310
314
 
311
315
  @media (prefers-reduced-motion: reduce) {
312
316
  * { transition: none !important; animation: none !important; }