@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 +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 +31 -1
- package/src/ui/app.js +95 -25
- package/src/ui/blocks.css +15 -0
- package/src/ui/blocks.js +23 -5
- package/src/ui/style.css +39 -35
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.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",
|
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
|
@@ -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
|
|
250
|
-
//
|
|
251
|
-
//
|
|
252
|
-
//
|
|
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
|
|
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
|
|
272
|
-
if (
|
|
273
|
-
lostRetryBtn = el('button', { class: 'lost-retry', type: 'button' }, '
|
|
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
|
-
|
|
276
|
-
el('
|
|
277
|
-
|
|
278
|
-
el('
|
|
279
|
-
el('
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
310
|
+
return lostNote;
|
|
285
311
|
}
|
|
286
312
|
|
|
287
|
-
// Enter the blocked state:
|
|
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
|
-
//
|
|
293
|
-
//
|
|
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(
|
|
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,
|
|
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 (
|
|
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
|
-
|
|
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);
|
|
@@ -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
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
.lost-
|
|
277
|
-
position: fixed;
|
|
278
|
-
display: flex; align-items: center;
|
|
279
|
-
|
|
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(--
|
|
296
|
+
border: 1px solid var(--danger); border-radius: 11px;
|
|
287
297
|
box-shadow: var(--shadow-lift);
|
|
288
|
-
padding:
|
|
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-
|
|
300
|
-
.lost-
|
|
301
|
-
.lost-
|
|
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:
|
|
304
|
-
padding:
|
|
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-
|
|
308
|
-
.lost-
|
|
309
|
-
.lost-
|
|
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; }
|