@khanglvm/relay 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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.0",
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
@@ -844,11 +844,18 @@
844
844
  return annotations.slice();
845
845
  }
846
846
 
847
+ // True while the user is mid-comment: the popover is open, which hosts both
848
+ // the "Add a comment" box and every thread's reply input. That text lives
849
+ // only in the DOM until Save, so the board must not reload it away.
850
+ function isComposing() {
851
+ return popOpen;
852
+ }
853
+
847
854
  function renderSummary(target) {
848
855
  ensureDom();
849
856
  summaryEls.add(target);
850
857
  renderSummaryInto(target);
851
858
  }
852
859
 
853
- window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, onBadgeRefresh, teardown };
860
+ window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, onBadgeRefresh, teardown, isComposing };
854
861
  })();
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
@@ -744,6 +769,12 @@
744
769
  });
745
770
  applyTheme();
746
771
 
772
+ const fsDown = el('button', { class: 'fs-btn', type: 'button', title: 'Smaller text', 'aria-label': 'Decrease text size' }, 'A−');
773
+ const fsUp = el('button', { class: 'fs-btn', type: 'button', title: 'Larger text', 'aria-label': 'Increase text size' }, 'A+');
774
+ fsDown.addEventListener('click', () => setFontScale(fontScale - FS_STEP));
775
+ fsUp.addEventListener('click', () => setFontScale(fontScale + FS_STEP));
776
+ const fsCtrl = el('div', { class: 'fs-ctrl' }, fsDown, fsUp);
777
+
747
778
  // Just reloaded after a live `rly update`? Flag set before reload below.
748
779
  try {
749
780
  if (sessionStorage.getItem('relay-updated') === '1') {
@@ -755,7 +786,7 @@
755
786
  }
756
787
 
757
788
  const titleEl = el('h1', {}, spec.title);
758
- app.append(el('header', { class: 'qb-header' }, titleEl, themeBtn));
789
+ app.append(el('header', { class: 'qb-header' }, titleEl, el('div', { class: 'qb-controls' }, fsCtrl, themeBtn)));
759
790
  // The board title is commentable too: hovering it shows the pin, like the
760
791
  // intro and every other content element. (themeBtn stays out of it.)
761
792
  if (spec.title) {
@@ -1021,6 +1052,24 @@
1021
1052
  }
1022
1053
 
1023
1054
  // ---------- heartbeat ----------
1055
+ // True when the user is actively typing — an open annotation comment popover,
1056
+ // or focus in any text-entry field (comment box, notes, text/color answers,
1057
+ // "Other" inputs). Used to defer the live-update reload so in-progress
1058
+ // comments aren't destroyed mid-keystroke.
1059
+ function isEditable(node) {
1060
+ if (!node) return false;
1061
+ if (node.tagName === 'TEXTAREA') return true;
1062
+ if (node.tagName === 'INPUT') {
1063
+ const t = (node.type || 'text').toLowerCase();
1064
+ return ['text', 'search', 'email', 'url', 'tel', 'number', 'color'].includes(t);
1065
+ }
1066
+ return node.isContentEditable === true;
1067
+ }
1068
+ function userIsComposing() {
1069
+ if (window.RelayAnnotate && typeof RelayAnnotate.isComposing === 'function' && RelayAnnotate.isComposing()) return true;
1070
+ return isEditable(document.activeElement);
1071
+ }
1072
+
1024
1073
  let misses = 0;
1025
1074
  let reloading = false;
1026
1075
  let hb = null;
@@ -1054,6 +1103,16 @@
1054
1103
  );
1055
1104
  }
1056
1105
  if (body && typeof body.rev === 'number' && bootRev !== null && body.rev !== bootRev && !submitted && !reloading) {
1106
+ // Don't yank the board out from under someone mid-comment: an open
1107
+ // annotation popover (its add-comment box + reply inputs) holds text
1108
+ // that isn't in the draft until Save, so a reload would discard it —
1109
+ // and reloading while any field is focused drops the user's cursor and
1110
+ // last keystrokes. Defer until they're done; the heartbeat re-checks
1111
+ // every tick, so the update applies the moment they close/blur.
1112
+ if (userIsComposing()) {
1113
+ showNotice('The agent updated this board — it’ll refresh as soon as you finish your comment.', 'info');
1114
+ return;
1115
+ }
1057
1116
  reloading = true;
1058
1117
  stopHeartbeat();
1059
1118
  clearTimeout(saveTimer);
package/src/ui/blocks.css CHANGED
@@ -491,6 +491,21 @@ body.blk-full-open { overflow: hidden; }
491
491
  height: calc(100vh - 44px) !important; width: 100%;
492
492
  margin: 0; border: 0; border-radius: 0;
493
493
  }
494
+ /* Full-screen diagrams/images: apply() sizes the child to fit the viewport
495
+ (FULL_PAD_* in blocks.js mirrors this padding). margin:auto on the single
496
+ child centers it on both axes AND keeps it scrollable when zoomed past the
497
+ fit — plain flex centering would clip the top/left and trap the overflow. */
498
+ .blk-mermaid.blk-full,
499
+ .blk-graphviz.blk-full,
500
+ .blk-plantuml.blk-full,
501
+ .blk-imagewrap.blk-full {
502
+ display: flex; padding: 44px 24px 24px;
503
+ }
504
+ .blk-mermaid.blk-full > svg,
505
+ .blk-graphviz.blk-full > svg,
506
+ .blk-plantuml.blk-full > svg,
507
+ .blk-plantuml.blk-full > .blk-plantuml-img,
508
+ .blk-imagewrap.blk-full > .blk-img { margin: auto; }
494
509
 
495
510
  /* Small frame on charts + mermaid so they read as a deliberate card, matching
496
511
  the graphviz/plantuml/image blocks (already framed). Scoped to the inner
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);