@khanglvm/relay 0.13.9 → 0.14.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
@@ -43,7 +43,7 @@ step, leaving any boards you have open untouched.
43
43
  relay also runs as an **MCP App** ([SEP-1865](https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp))
44
44
  — so the same board renders **inline, right in the conversation**, on Claude
45
45
  desktop **and mobile** and in Codex, no browser tab. `rly mcp` is a
46
- zero-dependency stdio MCP server; register it once and the agent gets two tools,
46
+ stdio MCP server with no npm runtime dependencies; register it once and the agent gets two tools,
47
47
  `relay_ask` (collect decisions/feedback with real form controls) and
48
48
  `relay_show` (present a plan, diagram, diff, table, or prototype):
49
49
 
@@ -81,8 +81,9 @@ unchanged; pick whichever surface fits.
81
81
  | Feedback = another wall of text | Click any chart point, diagram node, table cell, or sentence to comment — the agent replies and the thread grows on the board |
82
82
 
83
83
  Everything autosaves in real time (drafts survive timeouts), multiple boards
84
- run at once, and the package has **zero runtime dependencies** — plain
85
- Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
84
+ run at once, and the package has **zero npm runtime dependencies** — plain
85
+ Node ≥ 18. Browser-side Chart.js, Mermaid, and Viz.js assets are vendored in
86
+ the package and lazy-loaded offline when a board actually needs them.
86
87
 
87
88
  ## Learn more
88
89
 
@@ -91,6 +92,7 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
91
92
  | `rly help` | every command at a glance |
92
93
  | `rly git pick` / `rly git cherry-pick` | choose commit actions and rank the order directly on a board; add `--code` to cherry-pick with split code review and per-hunk Apply/Skip/Hold |
93
94
  | `rly git conflict [files…]` | auto-detect unmerged conflict files, or open specific local paths, and return resolved content in `result.blockEdits` |
95
+ | `rly share <board-id>` | activate/list/revoke same-Wi-Fi reviewer or collaborator links for a running browser board |
94
96
  | `rly view <file.md> …` | open a quick read-only board that renders local markdown file(s), data files, or PDFs — library-free; great for plans, READMEs, reports, quotes |
95
97
  | `rly install --target <agent>` | write relay's rules into an agent's instruction file — `claude` `codex` `cursor` `copilot` `kiro` `windsurf` `cline` `gemini` `opencode` `droid` `agents`; `--all`, `--scope`, `--print`, `--list` |
96
98
  | `rly upgrade` | update the CLI **and** refresh the skill in one step (safe around open boards; `--dry-run`, `--cli-only`, `--skill-only`) |
@@ -102,14 +104,14 @@ Node ≥ 18; Chart.js / Mermaid / Graphviz are vendored and lazy-loaded offline.
102
104
  ## Development
103
105
 
104
106
  ```sh
105
- npm test # zero-dep smoke tests (spawns real servers, fake-submits)
107
+ npm test # smoke tests with no external services (spawns real servers, fake-submits)
106
108
  ```
107
109
 
108
110
  ## Changelog
109
111
 
110
112
  ### 0.11.0 — render inline inside the Claude & Codex apps (MCP App)
111
113
  - **`rly mcp` — relay as an MCP App** ([SEP-1865](https://modelcontextprotocol.io/seps/1865-mcp-apps-interactive-user-interfaces-for-mcp),
112
- extension `io.modelcontextprotocol/ui`). A zero-dependency stdio MCP server
114
+ extension `io.modelcontextprotocol/ui`). A stdio MCP server with no npm runtime dependencies
113
115
  that declares a `ui://relay/board` resource (`text/html;profile=mcp-app`) and
114
116
  two tools, **`relay_ask`** and **`relay_show`**, linked to it via
115
117
  `_meta.ui.resourceUri` (plus `openai/outputTemplate` for ChatGPT/Codex). The
@@ -172,7 +174,8 @@ npm test # zero-dep smoke tests (spawns real servers, fake-submits)
172
174
  - **Durable drafts / rescue** — every autosave mirrors to `localStorage`; a
173
175
  board whose connection drops blocks further input instead of losing it, and
174
176
  `rly rescue <id>` re-serves on the same port so an open tab reconnects.
175
- - Still **zero runtime dependencies**, offline, and cross-platform.
177
+ - Still **zero npm runtime dependencies**, offline, and cross-platform. Vendored
178
+ browser libraries are loaded only for boards that need them.
176
179
 
177
180
  ### 0.9.1
178
181
  - The board **intro renders as markdown** (bold/italic/code/links/lists).
package/docs/AGENT.md CHANGED
@@ -71,6 +71,28 @@ Answers **autosave in real time** as the user fills the board — a page reload
71
71
  restores them, and a draft survives timeouts/cancellation (included in those
72
72
  results), so partial input is never lost.
73
73
 
74
+ ## Same-Wi-Fi sharing
75
+
76
+ Browser boards are local-owner only by default. A same-Wi-Fi phone/tablet/other
77
+ laptop gets a locked page until sharing is explicitly activated. The owner can
78
+ click **Share** at the bottom of the board, or you can manage the same links for
79
+ the user:
80
+
81
+ ```sh
82
+ rly share b-xxxxx # list active share roles
83
+ rly share b-xxxxx --role review # reviewer: add comments only
84
+ rly share b-xxxxx --role collab # collaborator: edit/comment/submit
85
+ rly share b-xxxxx --role review --revoke # revoke one role
86
+ rly share b-xxxxx --revoke --all # revoke all active links
87
+ ```
88
+
89
+ Use `review` unless the user explicitly wants the other device to submit as the
90
+ owner. Reviewers cannot change answers, submit, edit comments, or delete
91
+ comments; their autosaves merge new comments into the existing draft.
92
+ Collaborators can edit answers, comment, open allowed local file links, and
93
+ submit. Shared viewers refresh from the live draft when another viewer saves,
94
+ deferred while someone is typing/commenting.
95
+
74
96
  ## Inline mode — relay as an MCP App (Claude & Codex apps)
75
97
 
76
98
  Everything above is the **CLI** surface (you run `rly` in a terminal and read
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.13.9",
3
+ "version": "0.14.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",
@@ -174,6 +174,28 @@ Exit codes: 0 submitted · 2 timeout · 3 cancelled · 5 not found. On
174
174
  timeout/cancel the result still contains the autosaved `draft` of partial
175
175
  answers and any annotations written so far.
176
176
 
177
+ ## Same-Wi-Fi sharing
178
+
179
+ Browser boards are local-owner only by default. A phone/tablet/other laptop on
180
+ the same Wi-Fi gets a locked page until sharing is explicitly activated. The
181
+ owner can click **Share** at the bottom of the board, or an agent can manage the
182
+ same links with `rly share`:
183
+
184
+ ```sh
185
+ rly share b-xxxxx # list active share roles
186
+ rly share b-xxxxx --role review # activate a reviewer link
187
+ rly share b-xxxxx --role collab # activate a collaborator link
188
+ rly share b-xxxxx --role review --revoke # revoke one role
189
+ rly share b-xxxxx --revoke --all # revoke all active share links
190
+ ```
191
+
192
+ Use `review` when the other device should add comments only. Reviewers cannot
193
+ change answers, submit, edit comments, or delete comments; their autosaves merge
194
+ new comments into the existing draft. Use `collab` only when the user explicitly
195
+ wants that device to act as the owner: collaborators can edit answers, comment,
196
+ open allowed local file links, and submit. Shared viewers refresh from the live
197
+ draft when another viewer saves, deferred while someone is typing/commenting.
198
+
177
199
  ## Minimal spec
178
200
 
179
201
  ```json
@@ -347,8 +369,9 @@ the option (and stays annotatable); the label row selects. Use per-option
347
369
  blocks whenever a question's choices have visual/example context; skip them
348
370
  for plainly textual options.
349
371
 
350
- Chart.js, Mermaid, and Graphviz are **vendored and lazy-loaded** — the base board
351
- stays dependency-free. PlantUML uses the public plantuml.com server by default;
372
+ Chart.js, Mermaid, and Graphviz are **vendored and lazy-loaded** — the package
373
+ has no npm runtime dependencies, but these browser assets are bundled for rich
374
+ blocks. PlantUML uses the public plantuml.com server by default;
352
375
  pass `"server"` for a self-hosted instance. Legacy `"html"` / `"htmlFile"` /
353
376
  `"htmlHeight"` on root or questions are still accepted and normalised automatically.
354
377
 
package/src/cli.js CHANGED
@@ -33,7 +33,7 @@ const VALUED_FLAGS = new Set([
33
33
  'file', 'html', 'html-file', 'title', 'intro', 'timeout', 'port',
34
34
  'submit-label', 'height', 'limit', 'target', 'id', 'replies',
35
35
  'on-result', 'notify-cmd', 'idle-grace', 'scope', 'range',
36
- 'host', 'token', 'allow-origin',
36
+ 'host', 'token', 'allow-origin', 'role',
37
37
  ]);
38
38
 
39
39
  function camel(key) {
@@ -779,6 +779,45 @@ async function cmdResult(args) {
779
779
  return 5;
780
780
  }
781
781
 
782
+ async function cmdShare(args) {
783
+ const record = mustLoad(args._[0]);
784
+ const running = loadRunning(record.id);
785
+ if (!running || !isAlive(running.pid)) {
786
+ throw new CliError(`board "${record.id}" is not running. Use \`rly reopen ${record.id} --detach\` first.`, 5);
787
+ }
788
+ const role = args.all ? 'all' : args.role;
789
+ if (role !== undefined && !['collab', 'review', 'all'].includes(role)) {
790
+ throw new CliError('--role must be collab or review.', 4);
791
+ }
792
+ const shareUrl = new URL('/api/share', running.url);
793
+ let res;
794
+ try {
795
+ if (args.revoke) {
796
+ res = await fetch(shareUrl, {
797
+ method: 'DELETE',
798
+ headers: { 'content-type': 'application/json' },
799
+ body: JSON.stringify({ role: role || 'all' }),
800
+ });
801
+ } else if (role && role !== 'all') {
802
+ res = await fetch(shareUrl, {
803
+ method: 'POST',
804
+ headers: { 'content-type': 'application/json' },
805
+ body: JSON.stringify({ role }),
806
+ });
807
+ } else {
808
+ res = await fetch(shareUrl, { method: 'GET' });
809
+ }
810
+ } catch (err) {
811
+ throw new CliError(`could not reach board "${record.id}" at ${running.url}: ${String((err && err.message) || err)}`, 5);
812
+ }
813
+ const body = await res.json().catch(() => null);
814
+ if (!res.ok) {
815
+ throw new CliError((body && body.error) || `share request failed with HTTP ${res.status}`, res.status === 400 ? 4 : 5);
816
+ }
817
+ printJson({ status: args.revoke ? 'revoked' : role && role !== 'all' ? 'active' : 'shares', boardId: record.id, ...body });
818
+ return 0;
819
+ }
820
+
782
821
  function cmdList(args) {
783
822
  const running = listRunning();
784
823
  if (args.json) {
@@ -1056,6 +1095,10 @@ the user should view — put it in relay instead of printing it.**
1056
1095
  - Point the user at a file with a clickable local path in a markdown block; embed a
1057
1096
  screen recording with a \`video\` block; when answer choices are visual, give each
1058
1097
  option its own visual (\`options[].blocks\`) so the user picks by looking.
1098
+ - Same-Wi-Fi sharing is locked by default. When the user wants another device to
1099
+ review or co-fill a running board, manage explicit links with \`rly share <id>\`:
1100
+ \`--role review\` creates a comments-only reviewer link, \`--role collab\` creates
1101
+ an edit/comment/submit link, and \`--revoke\` disables active links.
1059
1102
  - **There's a purpose-built component for most content — use the MOST SPECIFIC one,
1060
1103
  never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
1061
1104
  load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
@@ -1561,6 +1604,11 @@ USAGE
1561
1604
  NB: also writes the FULL result to "resultFile" (first field) —
1562
1605
  read that file if your shell truncates stdout; never pipe to head/tail
1563
1606
  rly result <id> result/status now (includes live autosaved draft + presence while open)
1607
+ rly share <id> list active same-Wi-Fi share links for a running board
1608
+ rly share <id> --role review activate a reviewer link (comments only)
1609
+ rly share <id> --role collab activate a collaborator link (edit/comment/submit)
1610
+ rly share <id> --role review --revoke
1611
+ revoke one share role; use --revoke --all to disable all roles
1564
1612
  rly list [--json] running boards
1565
1613
  rly open [id] re-open the browser tab of a running board
1566
1614
  rly reopen <id> [--replies f.json] serve a saved board again, prefilled with saved answers
@@ -1644,6 +1692,8 @@ export async function main(argv) {
1644
1692
  return await cmdWait(parseArgs(rest));
1645
1693
  case 'result':
1646
1694
  return await cmdResult(parseArgs(rest));
1695
+ case 'share':
1696
+ return await cmdShare(parseArgs(rest));
1647
1697
  case 'list':
1648
1698
  return cmdList(parseArgs(rest));
1649
1699
  case 'open':
package/src/mcp.js CHANGED
@@ -1,8 +1,9 @@
1
1
  // mcp.js — relay as an MCP App (SEP-1865, extension "io.modelcontextprotocol/ui").
2
2
  //
3
- // `rly mcp` starts a zero-dependency MCP server over stdio. Register it with a
4
- // host that supports MCP Apps (Claude desktop/mobile, Codex, …) and relay's
5
- // boards render INLINE in the conversation instead of opening a browser tab:
3
+ // `rly mcp` starts an MCP server over stdio with no npm runtime dependencies.
4
+ // Register it with a host that supports MCP Apps (Claude desktop/mobile, Codex,
5
+ // …) and relay's boards render INLINE in the conversation instead of opening a
6
+ // browser tab:
6
7
  //
7
8
  // • the server declares a UI resource ui://relay/board (text/html;profile=mcp-app)
8
9
  // • the tools `relay_ask` / `relay_show` link to it via _meta.ui.resourceUri
package/src/server.js CHANGED
@@ -96,7 +96,46 @@ function vendorPresent(file) {
96
96
  }
97
97
  }
98
98
 
99
- function buildPage(record, rev) {
99
+ function cleanShareHost(raw) {
100
+ const s = String(raw || '').trim();
101
+ if (!s) return null;
102
+ let host = s;
103
+ try {
104
+ host = s.includes('://') ? new URL(s).hostname : s;
105
+ } catch {
106
+ host = s;
107
+ }
108
+ host = host.replace(/^https?:\/\//, '').split('/')[0].split(':')[0].trim();
109
+ if (!host || host === '127.0.0.1' || host === 'localhost') return null;
110
+ return host;
111
+ }
112
+
113
+ function isPrivateIpv4(address) {
114
+ if (!/^(\d{1,3}\.){3}\d{1,3}$/.test(address)) return false;
115
+ const parts = address.split('.').map((n) => Number(n));
116
+ if (parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
117
+ return (
118
+ parts[0] === 10 ||
119
+ (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) ||
120
+ (parts[0] === 192 && parts[1] === 168)
121
+ );
122
+ }
123
+
124
+ function shareHost() {
125
+ const forced = cleanShareHost(process.env.RLY_SHARE_HOST);
126
+ if (forced) return forced;
127
+ const candidates = [];
128
+ const nets = os.networkInterfaces();
129
+ for (const list of Object.values(nets)) {
130
+ for (const net of list || []) {
131
+ if (!net || net.internal || net.family !== 'IPv4') continue;
132
+ candidates.push(net.address);
133
+ }
134
+ }
135
+ return candidates.find(isPrivateIpv4) || candidates[0] || null;
136
+ }
137
+
138
+ function buildPage(record, rev, { access = null, draftRev = 0 } = {}) {
100
139
  const html = fs.readFileSync(path.join(UI_DIR, 'index.html'), 'utf8');
101
140
  const css = readUi('style.css');
102
141
  const blocksCss = readUi('blocks.css');
@@ -136,7 +175,7 @@ function buildPage(record, rev) {
136
175
  updatedAt: record.draft.updatedAt || null,
137
176
  }
138
177
  : null;
139
- const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev };
178
+ const boot = { boardId: record.id, spec: clientSpec, prefill, pref: loadPref(), vendor, rev, draftRev, access };
140
179
  const json = JSON.stringify(boot).replace(/</g, '\\u003c');
141
180
  return html
142
181
  .split('__TITLE__').join(escapeHtml(spec.title))
@@ -149,6 +188,14 @@ function buildPage(record, rev) {
149
188
  .split('__BOOT_JSON__').join(json);
150
189
  }
151
190
 
191
+ function buildLockedPage(title = 'Relay board') {
192
+ return '<!doctype html><html><head><meta charset="utf-8">' +
193
+ '<meta name="viewport" content="width=device-width, initial-scale=1">' +
194
+ '<title>' + escapeHtml(title) + '</title>' +
195
+ '<style>body{margin:0;font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;background:#fcfbf9;color:#1c1b19;display:grid;min-height:100vh;place-items:center}.box{max-width:420px;padding:28px;text-align:center}.title{font-size:22px;font-weight:650;margin-bottom:8px}.sub{color:#57534e}</style>' +
196
+ '</head><body><div class="box"><div class="title">Share link not active</div><div class="sub">Ask the board owner to use Share and choose a permission before opening this board from another device.</div></div></body></html>';
197
+ }
198
+
152
199
  // Validates + sanitizes one annotation's threaded replies. Keeps only
153
200
  // well-formed {author, text, createdAt} entries; caps at 50; coerces author.
154
201
  function sanitizeReplies(value) {
@@ -184,6 +231,50 @@ function sanitizeAnnotations(value) {
184
231
  return out;
185
232
  }
186
233
 
234
+ function nextAnnotationId(existing, used) {
235
+ let max = 0;
236
+ for (const a of existing) {
237
+ const m = /^a(\d+)$/.exec(String(a && a.id || ''));
238
+ if (m) max = Math.max(max, Number.parseInt(m[1], 10) || 0);
239
+ }
240
+ let id;
241
+ do {
242
+ id = 'a' + (++max);
243
+ } while (used.has(id));
244
+ used.add(id);
245
+ return id;
246
+ }
247
+
248
+ function sameReply(a, b) {
249
+ return a && b && a.author === b.author && a.text === b.text && a.createdAt === b.createdAt;
250
+ }
251
+
252
+ function mergeReviewAnnotations(existingValue, incomingValue) {
253
+ const existing = sanitizeAnnotations(existingValue);
254
+ const incoming = sanitizeAnnotations(incomingValue);
255
+ const out = existing.map((a) => ({ ...a, replies: Array.isArray(a.replies) ? [...a.replies] : [] }));
256
+ const byId = new Map(out.map((a) => [String(a.id || ''), a]));
257
+ const used = new Set(out.map((a) => String(a.id || '')).filter(Boolean));
258
+ for (const ann of incoming) {
259
+ const id = String(ann.id || '');
260
+ const current = byId.get(id);
261
+ if (!current) {
262
+ const next = { ...ann, id: id && !used.has(id) ? id : nextAnnotationId(out, used) };
263
+ next.replies = Array.isArray(next.replies) ? next.replies : [];
264
+ out.push(next);
265
+ byId.set(next.id, next);
266
+ used.add(next.id);
267
+ continue;
268
+ }
269
+ const replies = Array.isArray(current.replies) ? current.replies : (current.replies = []);
270
+ for (const r of Array.isArray(ann.replies) ? ann.replies : []) {
271
+ if (replies.length >= 50) break;
272
+ if (!replies.some((x) => sameReply(x, r))) replies.push(r);
273
+ }
274
+ }
275
+ return out.slice(0, 500);
276
+ }
277
+
187
278
  function limitString(v, max) {
188
279
  return typeof v === 'string' ? v.slice(0, max) : '';
189
280
  }
@@ -390,12 +481,13 @@ function buildOpenAllowlist(spec, baseCwd) {
390
481
  // True when an Origin header (if present) belongs to this board's own server.
391
482
  // Same-origin fetches send no Origin or our own; a foreign Origin is a
392
483
  // cross-site POST and must not be allowed to open a local file.
393
- function sameOrigin(req, port) {
484
+ function sameOrigin(req, port, extraHosts = []) {
394
485
  const origin = req.headers.origin;
395
486
  if (!origin) return true;
396
487
  try {
397
- const h = new URL(origin).host;
398
- return h === `127.0.0.1:${port}` || h === `localhost:${port}`;
488
+ const u = new URL(origin);
489
+ if (u.port !== String(port)) return false;
490
+ return new Set(['127.0.0.1', 'localhost', ...extraHosts.filter(Boolean)]).has(u.hostname);
399
491
  } catch {
400
492
  return false;
401
493
  }
@@ -606,7 +698,13 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
606
698
  // Mutation token: only `rly update` (which reads the running-file record)
607
699
  // can authenticate to POST /api/update. Never embedded in the page/boot.
608
700
  const token = crypto.randomBytes(16).toString('hex');
701
+ const shareTokens = {
702
+ collab: crypto.randomBytes(16).toString('hex'),
703
+ review: crypto.randomBytes(16).toString('hex'),
704
+ };
705
+ const activeShares = { collab: false, review: false };
609
706
  let rev = 1;
707
+ let draftRev = Number.isFinite(record.draftRev) ? record.draftRev : 0;
610
708
  let status = 'open';
611
709
  let finished = false;
612
710
  // Soft timeout: the board's time is up and a `timeout` result was handed back
@@ -616,6 +714,58 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
616
714
  let softTimedOut = false;
617
715
  // Latest client presence ping (null until the first ping arrives).
618
716
  let presence = null;
717
+ let actualPort = 0;
718
+ let url = '';
719
+ const advertisedHost = shareHost();
720
+ const accessFor = (req, reqUrl = null) => {
721
+ const host = String(req.headers.host || '').split(':')[0];
722
+ if (host === '127.0.0.1' || host === 'localhost') {
723
+ return {
724
+ role: 'owner',
725
+ canShare: true,
726
+ canSubmit: true,
727
+ canEditAnswers: true,
728
+ canComment: true,
729
+ canEditComments: true,
730
+ canDeleteComments: true,
731
+ };
732
+ }
733
+ const tokenValue = String(req.headers['x-relay-share-token'] || (reqUrl && reqUrl.searchParams.get('token')) || '');
734
+ if (activeShares.collab && tokenValue === shareTokens.collab) {
735
+ return {
736
+ role: 'collab',
737
+ token: shareTokens.collab,
738
+ canShare: false,
739
+ canSubmit: true,
740
+ canEditAnswers: true,
741
+ canComment: true,
742
+ canEditComments: true,
743
+ canDeleteComments: true,
744
+ };
745
+ }
746
+ if (activeShares.review && tokenValue === shareTokens.review) {
747
+ return {
748
+ role: 'review',
749
+ token: shareTokens.review,
750
+ canShare: false,
751
+ canSubmit: false,
752
+ canEditAnswers: false,
753
+ canComment: true,
754
+ canEditComments: false,
755
+ canDeleteComments: false,
756
+ };
757
+ }
758
+ return {
759
+ role: 'locked',
760
+ canShare: false,
761
+ canSubmit: false,
762
+ canEditAnswers: false,
763
+ canComment: false,
764
+ canEditComments: false,
765
+ canDeleteComments: false,
766
+ };
767
+ };
768
+ const shareUrlFor = (role) => advertisedHost ? `http://${advertisedHost}:${actualPort}/?share=${role}&token=${shareTokens[role]}` : null;
619
769
  let resolveDone;
620
770
  const done = new Promise((r) => {
621
771
  resolveDone = r;
@@ -627,11 +777,20 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
627
777
  const theme = reqUrl.searchParams.get('theme') === 'dark' ? 'dark' : 'light';
628
778
  try {
629
779
  if (req.method === 'GET' && pathname === '/') {
630
- sendHtml(res, buildPage(record, rev));
780
+ const access = accessFor(req, reqUrl);
781
+ if (access.role === 'locked') {
782
+ sendHtml(res, buildLockedPage(record.spec.title));
783
+ } else {
784
+ sendHtml(res, buildPage(record, rev, { access, draftRev }));
785
+ }
631
786
  } else if (req.method === 'GET' && pathname === '/api/board') {
787
+ if (accessFor(req, reqUrl).role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
632
788
  sendJson(res, 200, { id: record.id, spec: record.spec, draft: record.draft, result: record.result });
633
789
  } else if (req.method === 'GET' && pathname === '/api/status') {
634
- sendJson(res, 200, { status, rev, softTimedOut });
790
+ sendJson(res, 200, { status, rev, draftRev, softTimedOut });
791
+ } else if (req.method === 'GET' && pathname === '/api/draft') {
792
+ if (accessFor(req, reqUrl).role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
793
+ sendJson(res, 200, { draft: record.draft || null, draftRev });
635
794
  } else if (req.method === 'POST' && pathname === '/api/ping') {
636
795
  const body = JSON.parse((await readBody(req)) || '{}');
637
796
  // Validate body shape: visible/focused booleans, idleMs finite >= 0.
@@ -728,7 +887,9 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
728
887
  } else if (req.method === 'POST' && pathname === '/api/open') {
729
888
  // Open a board-referenced local file in the OS default app. Guarded by
730
889
  // a same-origin check + an allowlist of paths the board actually links.
731
- if (!sameOrigin(req, actualPort)) return sendJson(res, 403, { error: 'cross-origin requests cannot open files' });
890
+ if (!sameOrigin(req, actualPort, [advertisedHost])) return sendJson(res, 403, { error: 'cross-origin requests cannot open files' });
891
+ const access = accessFor(req, reqUrl);
892
+ if (access.role !== 'owner' && access.role !== 'collab') return sendJson(res, 403, { error: 'this share cannot open local files' });
732
893
  const body = JSON.parse((await readBody(req)) || '{}');
733
894
  const raw = typeof body.path === 'string' ? body.path : '';
734
895
  if (!raw.trim()) return sendJson(res, 400, { error: 'missing "path"' });
@@ -748,18 +909,36 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
748
909
  if (!openUrl(target)) return sendJson(res, 500, { error: 'could not open the file' });
749
910
  sendJson(res, 200, { ok: true, path: target, name: path.basename(target) });
750
911
  } else if (req.method === 'POST' && pathname === '/api/draft') {
912
+ const access = accessFor(req, reqUrl);
913
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
751
914
  const body = JSON.parse((await readBody(req)) || '{}');
752
- record.draft = {
753
- answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
754
- comment: typeof body.comment === 'string' ? body.comment : '',
755
- notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
756
- annotations: sanitizeAnnotations(body.annotations),
757
- blockEdits: sanitizeBlockEdits(body.blockEdits),
758
- updatedAt: new Date().toISOString(),
759
- };
915
+ if (access.role === 'review') {
916
+ const current = record.draft && typeof record.draft === 'object' ? record.draft : {};
917
+ record.draft = {
918
+ answers: current.answers && typeof current.answers === 'object' ? current.answers : {},
919
+ comment: typeof current.comment === 'string' ? current.comment : '',
920
+ notes: current.notes && typeof current.notes === 'object' ? current.notes : {},
921
+ annotations: mergeReviewAnnotations(current.annotations, body.annotations),
922
+ blockEdits: current.blockEdits && typeof current.blockEdits === 'object' ? current.blockEdits : {},
923
+ updatedAt: new Date().toISOString(),
924
+ };
925
+ } else {
926
+ record.draft = {
927
+ answers: body.answers && typeof body.answers === 'object' ? body.answers : {},
928
+ comment: typeof body.comment === 'string' ? body.comment : '',
929
+ notes: body.notes && typeof body.notes === 'object' ? body.notes : {},
930
+ annotations: sanitizeAnnotations(body.annotations),
931
+ blockEdits: sanitizeBlockEdits(body.blockEdits),
932
+ updatedAt: new Date().toISOString(),
933
+ };
934
+ }
935
+ record.draftRev = ++draftRev;
760
936
  saveBoard(record);
761
- sendJson(res, 200, { ok: true });
937
+ sendJson(res, 200, { ok: true, draftRev });
762
938
  } else if (req.method === 'POST' && pathname === '/api/submit') {
939
+ const access = accessFor(req, reqUrl);
940
+ if (access.role === 'locked') return sendJson(res, 403, { error: 'share link is not active' });
941
+ if (!access.canSubmit) return sendJson(res, 403, { error: 'this share can comment only' });
763
942
  if (status !== 'open') return sendJson(res, 409, { error: 'board already finished' });
764
943
  const body = JSON.parse((await readBody(req)) || '{}');
765
944
  const answers = body.answers && typeof body.answers === 'object' ? body.answers : {};
@@ -774,6 +953,36 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
774
953
  annotations: sanitizeAnnotations(body.annotations),
775
954
  blockEdits: sanitizeBlockEdits(body.blockEdits),
776
955
  });
956
+ } else if (req.method === 'GET' && pathname === '/api/share') {
957
+ if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
958
+ const roles = {};
959
+ for (const role of ['collab', 'review']) {
960
+ roles[role] = {
961
+ active: activeShares[role] === true,
962
+ url: activeShares[role] === true ? shareUrlFor(role) : null,
963
+ };
964
+ }
965
+ sendJson(res, 200, { ok: true, roles });
966
+ } else if (req.method === 'POST' && pathname === '/api/share') {
967
+ if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can activate sharing' });
968
+ if (!advertisedHost) return sendJson(res, 400, { error: 'no LAN IPv4 address found for sharing' });
969
+ const body = JSON.parse((await readBody(req)) || '{}');
970
+ const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : null;
971
+ if (!role) return sendJson(res, 400, { error: 'role must be "collab" or "review"' });
972
+ activeShares[role] = true;
973
+ sendJson(res, 200, { ok: true, role, url: shareUrlFor(role) });
974
+ } else if (req.method === 'DELETE' && pathname === '/api/share') {
975
+ if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
976
+ const body = JSON.parse((await readBody(req)) || '{}');
977
+ const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : body.role === 'all' ? 'all' : null;
978
+ if (!role) return sendJson(res, 400, { error: 'role must be "collab", "review", or "all"' });
979
+ if (role === 'all') {
980
+ activeShares.collab = false;
981
+ activeShares.review = false;
982
+ } else {
983
+ activeShares[role] = false;
984
+ }
985
+ sendJson(res, 200, { ok: true, role, active: false });
777
986
  } else {
778
987
  sendJson(res, 404, { error: 'not found' });
779
988
  }
@@ -804,15 +1013,15 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
804
1013
  }
805
1014
  };
806
1015
  server.once('error', onErr);
807
- server.listen(p, '127.0.0.1', () => {
1016
+ server.listen(p, '0.0.0.0', () => {
808
1017
  server.removeListener('error', onErr);
809
1018
  resolve();
810
1019
  });
811
1020
  };
812
1021
  bind(port, true);
813
1022
  });
814
- const actualPort = server.address().port;
815
- const url = `http://127.0.0.1:${actualPort}/`;
1023
+ actualPort = server.address().port;
1024
+ url = `http://127.0.0.1:${actualPort}/`;
816
1025
  // Remember the port this board last bound, so `rly rescue <id>` can re-serve
817
1026
  // on the SAME port — letting a still-open (but disconnected) browser tab
818
1027
  // reconnect to its relative /api/* URLs without the user touching anything.
@@ -114,6 +114,7 @@
114
114
  // Frozen by the host (e.g. relay's connection-lost block): suppress every way
115
115
  // to START a comment, so the user can't type feedback that won't be saved.
116
116
  let disabled = false;
117
+ let permissions = { add: true, edit: true, delete: true, reply: true };
117
118
  let badgeTimer = 0;
118
119
  let railOpen = false;
119
120
 
@@ -513,6 +514,7 @@
513
514
  try { delConfirmSkip = localStorage.getItem(DEL_SKIP_KEY) === '1'; } catch { /* sandbox: no storage */ }
514
515
 
515
516
  function requestDelete(id, after) {
517
+ if (!permissions.delete) return;
516
518
  const done = () => { removeAnnotation(id); if (after) after(); };
517
519
  if (delConfirmSkip) { done(); return; }
518
520
  showDeleteConfirm(done);
@@ -655,6 +657,7 @@
655
657
 
656
658
  // ---------- mutations ----------
657
659
  function addAnnotation(info, text) {
660
+ if (!permissions.add) return;
658
661
  annotations.push({
659
662
  id: 'a' + (++idCounter),
660
663
  questionId: info.questionId !== undefined ? info.questionId : null,
@@ -687,6 +690,7 @@
687
690
  // Append a user reply to a top-level annotation's thread (cap 50, like the
688
691
  // server). Empty text is ignored by callers.
689
692
  function addReply(id, text) {
693
+ if (!permissions.reply) return;
690
694
  const a = annotations.find((x) => x.id === id);
691
695
  if (!a) return;
692
696
  if (!Array.isArray(a.replies)) a.replies = [];
@@ -701,6 +705,7 @@
701
705
 
702
706
  // Edit a top-level comment's text in place (author edits their own comment).
703
707
  function editAnnotation(id, text) {
708
+ if (!permissions.edit) return;
704
709
  const a = annotations.find((x) => x.id === id);
705
710
  if (!a) return;
706
711
  const t = String(text).slice(0, 5000).trim();
@@ -710,6 +715,7 @@
710
715
  }
711
716
 
712
717
  function removeAnnotation(id) {
718
+ if (!permissions.delete) return;
713
719
  const i = annotations.findIndex((a) => a.id === id);
714
720
  if (i < 0) return;
715
721
  const removed = annotations[i];
@@ -844,11 +850,9 @@
844
850
  }
845
851
 
846
852
  // ---------- public API ----------
847
- function init(opts = {}) {
848
- ensureDom();
849
- // Normalize threads: missing author -> 'user', missing replies -> [].
850
- annotations = Array.isArray(opts.initial)
851
- ? opts.initial.map((a) => ({
853
+ function normalizeList(value) {
854
+ return Array.isArray(value)
855
+ ? value.map((a) => ({
852
856
  ...a,
853
857
  author: a.author === 'agent' ? 'agent' : 'user',
854
858
  replies: Array.isArray(a.replies)
@@ -862,12 +866,28 @@
862
866
  : [],
863
867
  }))
864
868
  : [];
865
- onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
869
+ }
870
+
871
+ function resetCounter() {
866
872
  idCounter = 0;
867
873
  for (const a of annotations) {
868
874
  const m = /^a(\d+)$/.exec(String(a.id || ''));
869
875
  if (m) idCounter = Math.max(idCounter, parseInt(m[1], 10));
870
876
  }
877
+ }
878
+
879
+ function init(opts = {}) {
880
+ ensureDom();
881
+ // Normalize threads: missing author -> 'user', missing replies -> [].
882
+ annotations = normalizeList(opts.initial);
883
+ onChange = typeof opts.onChange === 'function' ? opts.onChange : null;
884
+ permissions = {
885
+ add: !(opts.permissions && opts.permissions.add === false),
886
+ edit: !(opts.permissions && opts.permissions.edit === false),
887
+ delete: !(opts.permissions && opts.permissions.delete === false),
888
+ reply: !(opts.permissions && opts.permissions.reply === false),
889
+ };
890
+ resetCounter();
871
891
  scheduleBadgeRefresh();
872
892
  renderSummaries();
873
893
  refreshRailChrome();
@@ -938,6 +958,14 @@
938
958
  return annotations.slice();
939
959
  }
940
960
 
961
+ function setList(next) {
962
+ annotations = normalizeList(next);
963
+ resetCounter();
964
+ scheduleBadgeRefresh();
965
+ renderSummaries();
966
+ refreshRailChrome();
967
+ }
968
+
941
969
  // True while the user is mid-comment: the popover is open, which hosts both
942
970
  // the "Add a comment" box and every thread's reply input. That text lives
943
971
  // only in the DOM until Save, so the board must not reload it away.
@@ -968,5 +996,5 @@
968
996
  renderSummaryInto(target);
969
997
  }
970
998
 
971
- window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, onBadgeRefresh, teardown, isComposing, flushOpen, setDisabled };
999
+ window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, setList, renderSummary, onBadgeRefresh, teardown, isComposing, flushOpen, setDisabled };
972
1000
  })();
package/src/ui/app.js CHANGED
@@ -3,6 +3,16 @@
3
3
 
4
4
  const boot = JSON.parse(document.getElementById('boot').textContent);
5
5
  const spec = boot.spec;
6
+ const access = boot.access || {
7
+ role: 'owner',
8
+ canShare: true,
9
+ canSubmit: true,
10
+ canEditAnswers: true,
11
+ canComment: true,
12
+ canEditComments: true,
13
+ canDeleteComments: true,
14
+ };
15
+ if (access.role) document.documentElement.dataset.accessRole = access.role;
6
16
  const QS = spec.questions || [];
7
17
  const app = document.getElementById('app');
8
18
  const banner = document.getElementById('banner');
@@ -10,6 +20,11 @@
10
20
  // compares /api/status.rev to this and reloads the board when it advances
11
21
  // (an agent ran `rly update`).
12
22
  const bootRev = typeof boot.rev === 'number' ? boot.rev : null;
23
+ let seenDraftRev = typeof boot.draftRev === 'number' ? boot.draftRev : 0;
24
+
25
+ function authHeaders(extra = {}) {
26
+ return access.token ? { ...extra, 'x-relay-share-token': access.token } : extra;
27
+ }
13
28
 
14
29
  // ---------- helpers ----------
15
30
  function el(tag, attrs = {}, ...children) {
@@ -453,10 +468,12 @@
453
468
  try {
454
469
  const r = await fetch('/api/draft', {
455
470
  method: 'POST',
456
- headers: { 'content-type': 'application/json' },
471
+ headers: authHeaders({ 'content-type': 'application/json' }),
457
472
  body: JSON.stringify(payload()),
458
473
  });
459
474
  if (!r.ok) throw new Error('draft rejected');
475
+ const body = await r.json().catch(() => null);
476
+ if (body && typeof body.draftRev === 'number') seenDraftRev = body.draftRev;
460
477
  saveFailures = 0;
461
478
  if (seq === saveSeq && saveEl && !submitted && !persistenceLost) saveEl.textContent = 'draft saved ✓';
462
479
  } catch {
@@ -513,6 +530,12 @@
513
530
  if (Annotate) {
514
531
  Annotate.init({
515
532
  initial: state.annotations,
533
+ permissions: {
534
+ add: access.canComment !== false,
535
+ edit: access.canEditComments !== false,
536
+ delete: access.canDeleteComments !== false,
537
+ reply: access.canComment !== false,
538
+ },
516
539
  onChange: (list) => {
517
540
  state.annotations = list;
518
541
  scheduleSave();
@@ -1019,6 +1042,10 @@
1019
1042
  sessionStorage.removeItem('relay-updated');
1020
1043
  showToast('Board updated by the agent');
1021
1044
  }
1045
+ if (sessionStorage.getItem('relay-draft-updated') === '1') {
1046
+ sessionStorage.removeItem('relay-draft-updated');
1047
+ showToast('Board refreshed with shared changes');
1048
+ }
1022
1049
  } catch {
1023
1050
  // sessionStorage may be unavailable (privacy mode) — non-fatal
1024
1051
  }
@@ -1099,8 +1126,112 @@
1099
1126
  saveEl = el('span', { class: 'savestate' }, '');
1100
1127
  const hint = el('span', { class: 'hint' },
1101
1128
  QS.length && spec.allowPartial ? 'Unanswered questions are returned as skipped.' : '');
1102
- app.append(el('div', { class: 'submitbar' }, submitBtn, hint, saveEl));
1103
- app.append(el('footer', { class: 'qb-footer' }, `relay · ${boot.boardId}`));
1129
+ const submitbar = el('div', { class: 'submitbar' }, submitBtn, hint, saveEl);
1130
+ app.append(submitbar);
1131
+
1132
+ function copyText(text, btn) {
1133
+ const done = () => {
1134
+ if (!btn) return;
1135
+ const old = btn.textContent;
1136
+ btn.textContent = 'Copied';
1137
+ setTimeout(() => { btn.textContent = old; }, 1800);
1138
+ };
1139
+ if (navigator.clipboard && navigator.clipboard.writeText) {
1140
+ navigator.clipboard.writeText(text).then(done).catch(() => {});
1141
+ return;
1142
+ }
1143
+ const ta = document.createElement('textarea');
1144
+ ta.value = text;
1145
+ ta.style.position = 'fixed';
1146
+ ta.style.opacity = '0';
1147
+ document.body.append(ta);
1148
+ ta.select();
1149
+ try { document.execCommand('copy'); done(); } catch {}
1150
+ ta.remove();
1151
+ }
1152
+
1153
+ function openSharePanel(host) {
1154
+ const panel = el('div', { class: 'share-panel' },
1155
+ el('div', { class: 'share-title' }, 'Share this board'),
1156
+ el('div', { class: 'share-desc' }, 'Choose a permission. The link starts working only after you confirm.'),
1157
+ el('button', { class: 'share-choice', type: 'button', 'data-role': 'collab' },
1158
+ el('span', { class: 'share-choice-title' }, 'Collaborator'),
1159
+ el('span', { class: 'share-choice-sub' }, 'Can edit answers, comment, and submit as owner.')
1160
+ ),
1161
+ el('button', { class: 'share-choice', type: 'button', 'data-role': 'review' },
1162
+ el('span', { class: 'share-choice-title' }, 'Reviewer'),
1163
+ el('span', { class: 'share-choice-sub' }, 'Can add comments only. No submit, answer edits, edit, or delete.')
1164
+ ),
1165
+ el('div', { class: 'share-result' })
1166
+ );
1167
+ const close = () => panel.remove();
1168
+ const closeBtn = el('button', { class: 'share-close', type: 'button', 'aria-label': 'Close share panel' }, '×');
1169
+ closeBtn.addEventListener('click', close);
1170
+ panel.prepend(closeBtn);
1171
+ const result = panel.querySelector('.share-result');
1172
+ for (const btn of panel.querySelectorAll('.share-choice')) {
1173
+ btn.addEventListener('click', async () => {
1174
+ const role = btn.getAttribute('data-role');
1175
+ const label = role === 'collab' ? 'collaborator' : 'reviewer';
1176
+ if (!window.confirm(`Activate a ${label} link for same-Wi-Fi devices?`)) return;
1177
+ btn.disabled = true;
1178
+ result.textContent = 'Activating...';
1179
+ try {
1180
+ const res = await fetch('/api/share', {
1181
+ method: 'POST',
1182
+ headers: { 'content-type': 'application/json' },
1183
+ body: JSON.stringify({ role }),
1184
+ });
1185
+ const body = await res.json().catch(() => null);
1186
+ if (!res.ok || !body || !body.url) throw new Error(body && body.error ? body.error : 'share failed');
1187
+ const copy = el('button', { class: 'share-copy', type: 'button' }, 'Copy link');
1188
+ copy.addEventListener('click', () => copyText(body.url, copy));
1189
+ result.replaceChildren(
1190
+ el('div', { class: 'share-ready' }, role === 'collab' ? 'Collaborator link active' : 'Reviewer link active'),
1191
+ el('a', { class: 'share-url', href: body.url, target: '_blank', rel: 'noreferrer' }, body.url),
1192
+ copy
1193
+ );
1194
+ } catch (err) {
1195
+ result.textContent = err && err.message ? err.message : 'Could not activate sharing.';
1196
+ } finally {
1197
+ btn.disabled = false;
1198
+ }
1199
+ });
1200
+ }
1201
+ host.append(panel);
1202
+ }
1203
+
1204
+ function buildFooter() {
1205
+ const footer = el('footer', { class: 'qb-footer' }, el('span', {}, `relay · ${boot.boardId}`));
1206
+ if (access.canShare) {
1207
+ const wrap = el('span', { class: 'share-wrap' });
1208
+ const shareBtn = el('button', { class: 'share-btn', type: 'button' }, 'Share');
1209
+ shareBtn.addEventListener('click', () => {
1210
+ const existing = wrap.querySelector('.share-panel');
1211
+ if (existing) existing.remove();
1212
+ else openSharePanel(wrap);
1213
+ });
1214
+ wrap.append(shareBtn);
1215
+ footer.append(wrap);
1216
+ } else if (access.role === 'review') {
1217
+ footer.append(el('span', { class: 'share-mode' }, 'reviewer · comments only'));
1218
+ } else if (access.role === 'collab') {
1219
+ footer.append(el('span', { class: 'share-mode' }, 'collaborator'));
1220
+ }
1221
+ return footer;
1222
+ }
1223
+
1224
+ if (!access.canSubmit) {
1225
+ submitbar.style.display = 'none';
1226
+ app.append(el('div', { class: 'access-note' }, 'Reviewer mode: comments are saved live. Answers and submission are disabled.'));
1227
+ }
1228
+ if (!access.canEditAnswers) {
1229
+ document.documentElement.classList.add('relay-review');
1230
+ for (const node of app.querySelectorAll('.card input, .card textarea, .card button, .card select')) {
1231
+ node.disabled = true;
1232
+ }
1233
+ }
1234
+ app.append(buildFooter());
1104
1235
 
1105
1236
  // ---------- validation & submit ----------
1106
1237
  function validate() {
@@ -1154,7 +1285,7 @@
1154
1285
  try {
1155
1286
  res = await fetch('/api/submit', {
1156
1287
  method: 'POST',
1157
- headers: { 'content-type': 'application/json' },
1288
+ headers: authHeaders({ 'content-type': 'application/json' }),
1158
1289
  body: JSON.stringify(payload()),
1159
1290
  });
1160
1291
  } catch (netErr) {
@@ -1368,6 +1499,23 @@
1368
1499
  // sessionStorage may be unavailable — the reload still applies the update
1369
1500
  }
1370
1501
  location.reload();
1502
+ return;
1503
+ }
1504
+ if (body && typeof body.draftRev === 'number' && body.draftRev > seenDraftRev && !submitted && !reloading) {
1505
+ if (userIsComposing()) {
1506
+ showNotice('Another viewer updated this board — it’ll refresh as soon as you finish typing.', 'info');
1507
+ return;
1508
+ }
1509
+ reloading = true;
1510
+ stopHeartbeat();
1511
+ clearTimeout(saveTimer);
1512
+ clearLocalDraft();
1513
+ try {
1514
+ sessionStorage.setItem('relay-draft-updated', '1');
1515
+ } catch {
1516
+ // sessionStorage may be unavailable — the reload still applies the update
1517
+ }
1518
+ location.reload();
1371
1519
  }
1372
1520
  } catch {
1373
1521
  // Lost the live connection (the session ended, or the machine slept). Two
package/src/ui/style.css CHANGED
@@ -341,7 +341,58 @@ textarea { min-height: 90px; resize: vertical; }
341
341
  .done h2 { font-family: var(--serif); font-weight: 500; letter-spacing: -0.015em; margin: 0 0 8px; }
342
342
  .done p { color: var(--muted); margin: 0; }
343
343
 
344
- .qb-footer { color: var(--muted); font-size: 0.75rem; text-align: center; margin-top: 28px; }
344
+ .qb-footer {
345
+ color: var(--muted); font-size: 0.75rem; text-align: center; margin-top: 28px;
346
+ display: flex; justify-content: center; align-items: center; gap: 10px; flex-wrap: wrap;
347
+ }
348
+ .share-wrap { position: relative; display: inline-flex; }
349
+ .share-btn, .share-copy, .share-choice, .share-close {
350
+ font: inherit;
351
+ }
352
+ .share-btn {
353
+ border: 1px solid var(--border-strong); border-radius: 999px;
354
+ background: var(--card); color: var(--fg-2);
355
+ padding: 5px 12px; cursor: pointer;
356
+ }
357
+ .share-btn:hover { border-color: var(--accent); color: var(--accent); }
358
+ .share-panel {
359
+ position: absolute; left: 50%; bottom: calc(100% + 10px); transform: translateX(-50%);
360
+ width: min(360px, calc(100vw - 28px));
361
+ background: var(--card); color: var(--fg);
362
+ border: 1px solid var(--border-strong); border-radius: 12px;
363
+ box-shadow: var(--shadow-lift);
364
+ padding: 16px; text-align: left; z-index: 45;
365
+ }
366
+ .share-close {
367
+ position: absolute; top: 8px; right: 8px;
368
+ border: 0; background: transparent; color: var(--muted);
369
+ cursor: pointer; font-size: 1.1rem; line-height: 1;
370
+ }
371
+ .share-title { font-weight: 650; font-size: 0.95rem; margin-right: 24px; }
372
+ .share-desc { color: var(--muted); font-size: 0.78rem; margin: 4px 20px 12px 0; }
373
+ .share-choice {
374
+ width: 100%; display: flex; flex-direction: column; gap: 2px;
375
+ border: 1px solid var(--border); border-radius: 10px;
376
+ background: var(--bg-sunken); color: var(--fg);
377
+ padding: 10px 12px; margin-top: 8px; cursor: pointer; text-align: left;
378
+ }
379
+ .share-choice:hover { border-color: var(--accent); }
380
+ .share-choice-title { font-weight: 650; }
381
+ .share-choice-sub { color: var(--muted); font-size: 0.76rem; }
382
+ .share-result { margin-top: 12px; color: var(--fg-2); font-size: 0.78rem; overflow-wrap: anywhere; }
383
+ .share-ready { color: var(--ok); font-weight: 650; margin-bottom: 4px; }
384
+ .share-url { color: var(--accent); display: block; margin-bottom: 8px; }
385
+ .share-copy {
386
+ border: 0; border-radius: 8px; background: var(--accent); color: var(--accent-fg);
387
+ padding: 7px 12px; cursor: pointer; font-weight: 650;
388
+ }
389
+ .share-mode { color: var(--fg-2); }
390
+ .access-note {
391
+ color: var(--fg-2); background: var(--accent-soft);
392
+ border-radius: 10px; padding: 10px 14px; font-size: 0.86rem;
393
+ }
394
+ .relay-review .ann-del,
395
+ .relay-review .ann-edit { display: none !important; }
345
396
 
346
397
  @media (max-width: 480px) {
347
398
  .wrap { padding: 18px 12px 18px; }