@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 +9 -3
- package/docs/AGENT.md +28 -14
- package/package.json +1 -1
- package/skills/relay/SKILL.md +5 -2
- package/skills/relay/examples/palette-and-color.json +42 -0
- package/src/cli.js +6 -3
- package/src/mcp-ui/board.js +29 -6
- package/src/mcp-ui/index.html +2 -0
- package/src/mcp.js +5 -2
- package/src/server.js +3 -0
- package/src/ui/annotate.js +8 -1
- package/src/ui/app.js +60 -1
- package/src/ui/blocks.css +15 -0
- package/src/ui/blocks.js +23 -5
- package/src/ui/style.css +17 -2
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
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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.
|
|
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",
|
package/skills/relay/SKILL.md
CHANGED
|
@@ -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
|
|
108
|
-
|
|
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) : (
|
|
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
|
package/src/mcp-ui/board.js
CHANGED
|
@@ -350,7 +350,9 @@
|
|
|
350
350
|
theme: effectiveTheme,
|
|
351
351
|
htmlSrc: (blockId) => htmlBlobSrc(blockId),
|
|
352
352
|
questionId: questionId == null ? null : questionId,
|
|
353
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|
package/src/mcp-ui/index.html
CHANGED
|
@@ -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`
|
|
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
|
-
|
|
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
|
package/src/ui/annotate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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);
|