@khanglvm/relay 0.14.0 → 0.14.2

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
@@ -80,10 +80,11 @@ unchanged; pick whichever surface fits.
80
80
  | "Type *done* when finished reviewing" | A Submit button; answers, notes, and inline comments returned as JSON |
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
- Everything autosaves in real time (drafts survive timeouts), multiple boards
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.
83
+ Everything autosaves in real time, detached board links keep serving after
84
+ agent timeouts, multiple boards run at once, and the package has **zero npm
85
+ runtime dependencies** — plain Node ≥ 18. Browser-side Chart.js, Mermaid, and
86
+ Viz.js assets are vendored in the package and lazy-loaded offline when a board
87
+ actually needs them.
87
88
 
88
89
  ## Learn more
89
90
 
@@ -171,9 +172,12 @@ npm test # smoke tests with no external services (spawns real servers, fake-
171
172
  embedded in the payload.
172
173
  - **`pdf` block** — render local `.pdf` files or PDF URLs inline. Local PDFs
173
174
  stream from the board server and are never embedded in the page payload.
174
- - **Durable drafts / rescue** — every autosave mirrors to `localStorage`; a
175
- board whose connection drops blocks further input instead of losing it, and
176
- `rly rescue <id>` re-serves on the same port so an open tab reconnects.
175
+ - **Durable boards / rescue** — detached boards keep serving after timeout
176
+ until Submit or `rly stop`; every autosave mirrors to `localStorage`; a board
177
+ whose connection drops blocks further input instead of losing it, and
178
+ `rly rescue <id>` re-serves on the same port so an open tab reconnects. Active
179
+ same-Wi-Fi share links stay tied to the board and survive a same-port re-serve
180
+ until revoked.
177
181
  - Still **zero npm runtime dependencies**, offline, and cross-platform. Vendored
178
182
  browser libraries are loaded only for boards that need them.
179
183
 
@@ -201,8 +205,8 @@ npm test # smoke tests with no external services (spawns real servers, fake-
201
205
  - **Markdown blocks render GFM tables**; element comments moved to an
202
206
  Outline-style right sidebar with inline highlights on commented text.
203
207
  - **Seamless timeouts** — a detached board that runs past its deadline keeps
204
- serving so you can still submit (it lands as `submitted`); the page shows a
205
- calm note instead of disconnecting.
208
+ serving until Submit or explicit stop, so you can still submit (it lands as
209
+ `submitted`); the page shows a calm note instead of disconnecting.
206
210
  - **`rly upgrade`** — install the latest CLI and refresh the skill in one step.
207
211
  - Per-question notes are multi-line textboxes.
208
212
 
package/docs/AGENT.md CHANGED
@@ -75,8 +75,7 @@ results), so partial input is never lost.
75
75
 
76
76
  Browser boards are local-owner only by default. A same-Wi-Fi phone/tablet/other
77
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:
78
+ click the topbar **Share** icon, or you can manage the same links for the user:
80
79
 
81
80
  ```sh
82
81
  rly share b-xxxxx # list active share roles
@@ -91,7 +90,8 @@ owner. Reviewers cannot change answers, submit, edit comments, or delete
91
90
  comments; their autosaves merge new comments into the existing draft.
92
91
  Collaborators can edit answers, comment, open allowed local file links, and
93
92
  submit. Shared viewers refresh from the live draft when another viewer saves,
94
- deferred while someone is typing/commenting.
93
+ deferred while someone is typing/commenting. Active share links keep the same
94
+ token across a same-port `rly reopen`/`rly rescue` until revoked.
95
95
 
96
96
  ## Inline mode — relay as an MCP App (Claude & Codex apps)
97
97
 
@@ -672,12 +672,13 @@ raising `--timeout`.
672
672
  For a **detached** board, the `timeout` deadline is *soft*: it hands you a
673
673
  `timeout` result (with the autosaved draft) so you regain control, but the
674
674
  server stays up and the board stays fully usable — the user can keep
675
- commenting and still hit Submit. The page tells them you stopped waiting and
676
- to prompt you afterward. So if you got a `timeout` and the user might still be
677
- working: re-check later with `rly result <id>` (its status flips to
678
- `submitted` once they finish), or pass `--on-result` so a late submit
679
- push-wakes you. A blocking `rly ask` (no `--detach`) still ends hard on
680
- timeout, since there's no separate waiter to hand back to.
675
+ commenting and still hit Submit. It stays on the same port until Submit or an
676
+ explicit `rly stop`, so don't reopen just because your wait timed out. The page
677
+ tells them you stopped waiting and to prompt you afterward. So if you got a
678
+ `timeout` and the user might still be working: re-check later with `rly result
679
+ <id>` (its status flips to `submitted` once they finish), or pass `--on-result`
680
+ so a late submit push-wakes you. A blocking `rly ask` (no `--detach`) still ends
681
+ hard on timeout, since there's no separate waiter to hand back to.
681
682
 
682
683
  ## Push-wake — get notified instead of polling
683
684
 
@@ -745,7 +746,9 @@ rly rm <id> | --all # delete saved board(s)
745
746
  ```
746
747
 
747
748
  Multiple boards can run at once (each gets its own port on 127.0.0.1).
748
- Storage lives in `~/.relay` (override with `RLY_HOME`).
749
+ Detached boards are durable: they keep serving past timeout, `reopen`/`rescue`
750
+ prefer the last port, and active same-Wi-Fi share links keep the same token until
751
+ revoked. Storage lives in `~/.relay` (override with `RLY_HOME`).
749
752
 
750
753
  ## Live board mutation — `rly update`
751
754
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.14.0",
3
+ "version": "0.14.2",
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",
@@ -135,6 +135,10 @@ rly wait b-xxxxx --timeout 550 # blocks until submit, prints result JSON
135
135
  rly result b-xxxxx # non-blocking peek (includes live draft)
136
136
  ```
137
137
 
138
+ Detached boards are durable: their own timeout hands the agent a `timeout`
139
+ result but keeps the same URL/port serving until Submit or `rly stop`. Do not
140
+ create a new board just because a wait timed out.
141
+
138
142
  For long waits prefer presence-aware waiting over a huge --timeout:
139
143
 
140
144
  ```sh
@@ -178,8 +182,8 @@ answers and any annotations written so far.
178
182
 
179
183
  Browser boards are local-owner only by default. A phone/tablet/other laptop on
180
184
  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`:
185
+ owner can click the topbar **Share** icon, or an agent can manage the same links
186
+ with `rly share`:
183
187
 
184
188
  ```sh
185
189
  rly share b-xxxxx # list active share roles
@@ -195,6 +199,8 @@ new comments into the existing draft. Use `collab` only when the user explicitly
195
199
  wants that device to act as the owner: collaborators can edit answers, comment,
196
200
  open allowed local file links, and submit. Shared viewers refresh from the live
197
201
  draft when another viewer saves, deferred while someone is typing/commenting.
202
+ Active share links are durable too: they keep the same token across a same-port
203
+ `rly reopen`/`rly rescue` until `rly share --revoke` disables them.
198
204
 
199
205
  ## Minimal spec
200
206
 
@@ -534,7 +540,8 @@ one yesno "Does this match your mental model?" + a textarea for notes.
534
540
  answers prefilled) · `rly reopen <id> --replies file.json` (add agent replies) ·
535
541
  `rly rescue <id>` (re-serve a dropped board on its ORIGINAL port) ·
536
542
  `rly list` / `rly open` / `rly stop <id>` · `rly rm <id>`.
537
- Multiple boards can run concurrently.
543
+ Multiple boards can run concurrently. Detached boards keep serving past timeout;
544
+ use `rly stop` when you actually want to close one.
538
545
 
539
546
  ### Continue a board — reconnect, NEVER recreate it
540
547
 
package/src/cli.js CHANGED
@@ -1084,6 +1084,9 @@ the user should view — put it in relay instead of printing it.**
1084
1084
  if it is still open and the user may continue, run \`rly wait\` again. Do not
1085
1085
  use \`--on-result\` as the primary Codex return path; normal Codex CLI sessions
1086
1086
  do not expose a portable inbound API that wakes the current agent turn.
1087
+ Detached boards are durable: a board timeout hands the agent a \`timeout\`
1088
+ result but keeps the same URL/port serving until Submit or \`rly stop\`. Do
1089
+ not create a new board just because a wait timed out.
1087
1090
  - **A plan, structure, architecture, data, or prototype** → a relay board with
1088
1091
  diagram/chart/table/code/image/html blocks — never ASCII diagrams or walls of prose.
1089
1092
  - **"Show me the diff / git diff / these changes"** → \`rly diff\` (runs git diff →
@@ -1098,7 +1101,9 @@ the user should view — put it in relay instead of printing it.**
1098
1101
  - Same-Wi-Fi sharing is locked by default. When the user wants another device to
1099
1102
  review or co-fill a running board, manage explicit links with \`rly share <id>\`:
1100
1103
  \`--role review\` creates a comments-only reviewer link, \`--role collab\` creates
1101
- an edit/comment/submit link, and \`--revoke\` disables active links.
1104
+ an edit/comment/submit link, and \`--revoke\` disables active links. Active
1105
+ share links are durable across same-port \`rly reopen\`/\`rly rescue\` until
1106
+ revoked.
1102
1107
  - **There's a purpose-built component for most content — use the MOST SPECIFIC one,
1103
1108
  never plain prose when a block fits.** Blocks: \`table\` (sortable/filterable/CSV,
1104
1109
  load from .csv/.json), \`chart\`, \`kpi\` (stat cards), \`mermaid\`/\`graphviz\`/\`plantuml\`,
@@ -1568,8 +1573,8 @@ async function cmdServeInternal(args) {
1568
1573
  open: args.open !== false,
1569
1574
  timeoutSec: args.timeout !== undefined ? Math.max(0, Number.parseInt(args.timeout, 10) || 0) : 1800,
1570
1575
  quiet: true,
1571
- // Detached board: timeout hands back to the agent but keeps serving so the
1572
- // user can keep commenting and still submit (seamless past the deadline).
1576
+ // Detached board: timeout hands back to the agent but keeps the same
1577
+ // URL/port serving until Submit or explicit stop.
1573
1578
  keepAliveOnTimeout: true,
1574
1579
  });
1575
1580
  await done;
@@ -1609,6 +1614,7 @@ USAGE
1609
1614
  rly share <id> --role collab activate a collaborator link (edit/comment/submit)
1610
1615
  rly share <id> --role review --revoke
1611
1616
  revoke one share role; use --revoke --all to disable all roles
1617
+ active links survive same-port reopen/rescue until revoked
1612
1618
  rly list [--json] running boards
1613
1619
  rly open [id] re-open the browser tab of a running board
1614
1620
  rly reopen <id> [--replies f.json] serve a saved board again, prefilled with saved answers
@@ -1643,7 +1649,8 @@ COMMON FLAGS
1643
1649
 
1644
1650
  EXIT CODES 0 submitted/acknowledged · 2 timeout · 3 cancelled · 4 usage · 5 not found
1645
1651
 
1646
- NOTES answers & annotations autosave in real time (drafts survive timeout/cancel);
1652
+ NOTES detached boards keep serving after timeout until Submit or stop;
1653
+ answers & annotations autosave in real time (drafts survive timeout/cancel);
1647
1654
  submitting auto-closes the tab and unblocks the CLI.
1648
1655
 
1649
1656
  AI AGENTS run \`rly agent\` for the complete machine-oriented guide.
package/src/server.js CHANGED
@@ -698,11 +698,15 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
698
698
  // Mutation token: only `rly update` (which reads the running-file record)
699
699
  // can authenticate to POST /api/update. Never embedded in the page/boot.
700
700
  const token = crypto.randomBytes(16).toString('hex');
701
+ const savedShares = record.share && typeof record.share === 'object' ? record.share : {};
701
702
  const shareTokens = {
702
- collab: crypto.randomBytes(16).toString('hex'),
703
- review: crypto.randomBytes(16).toString('hex'),
703
+ collab: typeof savedShares.collab?.token === 'string' ? savedShares.collab.token : crypto.randomBytes(16).toString('hex'),
704
+ review: typeof savedShares.review?.token === 'string' ? savedShares.review.token : crypto.randomBytes(16).toString('hex'),
705
+ };
706
+ const activeShares = {
707
+ collab: savedShares.collab?.active === true,
708
+ review: savedShares.review?.active === true,
704
709
  };
705
- const activeShares = { collab: false, review: false };
706
710
  let rev = 1;
707
711
  let draftRev = Number.isFinite(record.draftRev) ? record.draftRev : 0;
708
712
  let status = 'open';
@@ -766,6 +770,14 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
766
770
  };
767
771
  };
768
772
  const shareUrlFor = (role) => advertisedHost ? `http://${advertisedHost}:${actualPort}/?share=${role}&token=${shareTokens[role]}` : null;
773
+ const persistShareState = () => {
774
+ record.share = {
775
+ collab: { active: activeShares.collab === true, token: shareTokens.collab },
776
+ review: { active: activeShares.review === true, token: shareTokens.review },
777
+ updatedAt: new Date().toISOString(),
778
+ };
779
+ saveBoard(record);
780
+ };
769
781
  let resolveDone;
770
782
  const done = new Promise((r) => {
771
783
  resolveDone = r;
@@ -970,6 +982,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
970
982
  const role = body.role === 'review' ? 'review' : body.role === 'collab' ? 'collab' : null;
971
983
  if (!role) return sendJson(res, 400, { error: 'role must be "collab" or "review"' });
972
984
  activeShares[role] = true;
985
+ persistShareState();
973
986
  sendJson(res, 200, { ok: true, role, url: shareUrlFor(role) });
974
987
  } else if (req.method === 'DELETE' && pathname === '/api/share') {
975
988
  if (accessFor(req, reqUrl).role !== 'owner') return sendJson(res, 403, { error: 'only the board owner can manage sharing' });
@@ -982,6 +995,7 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
982
995
  } else {
983
996
  activeShares[role] = false;
984
997
  }
998
+ persistShareState();
985
999
  sendJson(res, 200, { ok: true, role, active: false });
986
1000
  } else {
987
1001
  sendJson(res, 404, { error: 'not found' });
@@ -1040,15 +1054,13 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1040
1054
  });
1041
1055
 
1042
1056
  let timer = null;
1043
- let idleTimer = null;
1044
1057
  // The detached server's timeout is SOFT (keepAliveOnTimeout): at the deadline
1045
1058
  // we hand a `timeout` result back to the waiting agent (so `rly wait` returns
1046
1059
  // with the autosaved draft) but keep the server listening, so the user can
1047
1060
  // keep working and still submit. A late submit overwrites the result with
1048
1061
  // `submitted` and re-fires the push-wake; the board only truly closes on
1049
- // submit, an explicit stop, or once the user has clearly left (idle
1050
- // watchdog). A BLOCKING `rly ask` has no separate waiter to hand back to, so
1051
- // its timeout stays hard (close + resolve, exit 2).
1062
+ // submit or an explicit stop. A BLOCKING `rly ask` has no separate waiter to
1063
+ // hand back to, so its timeout stays hard (close + resolve, exit 2).
1052
1064
  if (timeoutSec > 0) {
1053
1065
  timer = setTimeout(keepAliveOnTimeout ? softTimeout : () => finish({ status: 'timeout' }), timeoutSec * 1000);
1054
1066
  }
@@ -1097,7 +1109,6 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1097
1109
  function closeServer(result) {
1098
1110
  removeRunning(record.id);
1099
1111
  if (timer) clearTimeout(timer);
1100
- if (idleTimer) clearInterval(idleTimer);
1101
1112
  process.removeListener('SIGINT', onSignal);
1102
1113
  process.removeListener('SIGTERM', onSignal);
1103
1114
  setTimeout(() => {
@@ -1131,30 +1142,6 @@ export async function runBoard({ id, port = 0, open = true, timeoutSec = 1800, q
1131
1142
  softTimedOut = true;
1132
1143
  const result = persistResult({ status: 'timeout' });
1133
1144
  runOnResult(record.onResult, result, { quiet });
1134
- startIdleWatchdog();
1135
- }
1136
-
1137
- // After a soft timeout, close the board for real once the user has clearly
1138
- // left (no presence ping for IDLE_CLOSE_MS) or a hard absolute cap is hit, so
1139
- // an abandoned board doesn't keep a server alive forever. Re-persists the
1140
- // latest draft as the final `timeout` result; no double push-wake.
1141
- function startIdleWatchdog() {
1142
- const IDLE_CLOSE_MS = 15 * 60 * 1000;
1143
- const HARD_CAP_MS = 6 * 60 * 60 * 1000;
1144
- const softAt = Date.now();
1145
- idleTimer = setInterval(() => {
1146
- if (finished) {
1147
- clearInterval(idleTimer);
1148
- return;
1149
- }
1150
- const lastSeen = presence ? presence.atMs : softAt;
1151
- if (Date.now() - lastSeen > IDLE_CLOSE_MS || Date.now() - softAt > HARD_CAP_MS) {
1152
- finished = true;
1153
- clearInterval(idleTimer);
1154
- closeServer(persistResult({ status: 'timeout' }));
1155
- }
1156
- }, 60 * 1000);
1157
- idleTimer.unref?.();
1158
1145
  }
1159
1146
 
1160
1147
  if (open) openUrl(url);
package/src/ui/app.js CHANGED
@@ -53,15 +53,19 @@
53
53
  // live connection dropped — used to tailor the post-submit message. The board
54
54
  // stays fully usable either way; we never disable Submit.
55
55
  let handedBack = false;
56
- // Calm, persistent status note (reuses the #banner bar). tone 'info' is the
56
+ // Calm, persistent status notes (the #banner stack). tone 'info' is the
57
57
  // default neutral style; 'warn' is a softer amber, NOT the old red error.
58
- // Shown at most once per message so the heartbeat can call it every tick.
58
+ // Each message is shown at most once so the heartbeat can call it every tick.
59
59
  const notesShown = new Set();
60
60
  function showNotice(message, tone) {
61
61
  if (notesShown.has(message)) return;
62
62
  notesShown.add(message);
63
- banner.textContent = message;
64
- banner.className = 'banner ' + (tone === 'warn' ? 'warn' : 'info');
63
+ if (banner.dataset.ready !== '1') {
64
+ banner.replaceChildren();
65
+ banner.dataset.ready = '1';
66
+ }
67
+ banner.className = 'banner-stack';
68
+ banner.append(el('div', { class: 'banner-item ' + (tone === 'warn' ? 'warn' : 'info') }, message));
65
69
  banner.style.display = 'block';
66
70
  }
67
71
 
@@ -1036,6 +1040,24 @@
1036
1040
  fsUp.addEventListener('click', () => setFontScale(fontScale + FS_STEP));
1037
1041
  const fsCtrl = el('div', { class: 'fs-ctrl' }, fsDown, fsUp);
1038
1042
 
1043
+ function buildShareControl() {
1044
+ if (!access.canShare) return null;
1045
+ const wrap = el('span', { class: 'share-wrap' });
1046
+ const shareBtn = el('button', { class: 'share-btn icon-btn', type: 'button', title: 'Share board', 'aria-label': 'Share board' });
1047
+ shareBtn.innerHTML =
1048
+ '<svg viewBox="0 0 16 16" width="15" height="15" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">' +
1049
+ '<path d="M6.5 9.5 9.5 11.2M9.5 4.8 6.5 6.5"/>' +
1050
+ '<circle cx="4.5" cy="8" r="2"/><circle cx="11.5" cy="4" r="2"/><circle cx="11.5" cy="12" r="2"/>' +
1051
+ '</svg>';
1052
+ shareBtn.addEventListener('click', () => {
1053
+ const existing = wrap.querySelector('.share-panel');
1054
+ if (existing) existing.remove();
1055
+ else openSharePanel(wrap);
1056
+ });
1057
+ wrap.append(shareBtn);
1058
+ return wrap;
1059
+ }
1060
+
1039
1061
  // Just reloaded after a live `rly update`? Flag set before reload below.
1040
1062
  try {
1041
1063
  if (sessionStorage.getItem('relay-updated') === '1') {
@@ -1051,7 +1073,7 @@
1051
1073
  }
1052
1074
 
1053
1075
  const titleEl = el('h1', {}, spec.title);
1054
- app.append(el('header', { class: 'qb-header' }, titleEl, el('div', { class: 'qb-controls' }, fsCtrl, themeBtn)));
1076
+ app.append(el('header', { class: 'qb-header' }, titleEl, el('div', { class: 'qb-controls' }, fsCtrl, buildShareControl(), themeBtn)));
1055
1077
  // The board title is commentable too: hovering it shows the pin, like the
1056
1078
  // intro and every other content element. (themeBtn stays out of it.)
1057
1079
  if (spec.title) {
@@ -1153,7 +1175,7 @@
1153
1175
  function openSharePanel(host) {
1154
1176
  const panel = el('div', { class: 'share-panel' },
1155
1177
  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.'),
1178
+ el('div', { class: 'share-desc' }, 'Choose a permission. The link starts working immediately.'),
1157
1179
  el('button', { class: 'share-choice', type: 'button', 'data-role': 'collab' },
1158
1180
  el('span', { class: 'share-choice-title' }, 'Collaborator'),
1159
1181
  el('span', { class: 'share-choice-sub' }, 'Can edit answers, comment, and submit as owner.')
@@ -1172,8 +1194,6 @@
1172
1194
  for (const btn of panel.querySelectorAll('.share-choice')) {
1173
1195
  btn.addEventListener('click', async () => {
1174
1196
  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
1197
  btn.disabled = true;
1178
1198
  result.textContent = 'Activating...';
1179
1199
  try {
@@ -1203,27 +1223,14 @@
1203
1223
 
1204
1224
  function buildFooter() {
1205
1225
  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
1226
  return footer;
1222
1227
  }
1223
1228
 
1224
1229
  if (!access.canSubmit) {
1225
1230
  submitbar.style.display = 'none';
1226
- app.append(el('div', { class: 'access-note' }, 'Reviewer mode: comments are saved live. Answers and submission are disabled.'));
1231
+ showNotice('Reviewer mode: comments are saved live. Answers and submission are disabled.', 'info');
1232
+ } else if (access.role === 'collab') {
1233
+ showNotice('Editor mode: answers, comments, and submit are enabled for this shared board.', 'info');
1227
1234
  }
1228
1235
  if (!access.canEditAnswers) {
1229
1236
  document.documentElement.classList.add('relay-review');
package/src/ui/style.css CHANGED
@@ -96,14 +96,21 @@ h1 {
96
96
  .intro.blk-markdown .md { color: var(--fg-2); }
97
97
  .intro.blk-markdown .md > :first-child { margin-top: 0; }
98
98
  .intro.blk-markdown .md > :last-child { margin-bottom: 0; }
99
- .theme-btn {
99
+ .theme-btn,
100
+ .icon-btn {
100
101
  background: transparent; color: var(--fg-2);
101
102
  border: 1px solid var(--border-strong); border-radius: 999px;
102
- padding: 6px 14px; cursor: pointer; font-size: 0.8rem; flex: none;
103
+ cursor: pointer; font-size: 0.8rem; flex: none;
103
104
  font-family: var(--sans);
104
105
  transition: border-color 150ms var(--ease), color 150ms var(--ease);
105
106
  }
106
- .theme-btn:hover { border-color: var(--accent); color: var(--accent); }
107
+ .theme-btn { padding: 6px 14px; }
108
+ .icon-btn {
109
+ width: 32px; height: 32px; padding: 0;
110
+ display: inline-flex; align-items: center; justify-content: center;
111
+ }
112
+ .theme-btn:hover,
113
+ .icon-btn:hover { border-color: var(--accent); color: var(--accent); }
107
114
  /* header controls: font-size knob + theme toggle, grouped at the right */
108
115
  .qb-controls { display: flex; align-items: center; gap: 8px; flex: none; }
109
116
  .fs-ctrl { display: inline-flex; border: 1px solid var(--border-strong); border-radius: 999px; overflow: hidden; }
@@ -317,10 +324,22 @@ textarea { min-height: 90px; resize: vertical; }
317
324
  z-index: 10; display: none;
318
325
  border-bottom: 1px solid transparent;
319
326
  }
327
+ .banner-stack {
328
+ position: sticky; top: 0;
329
+ display: flex; flex-direction: column; gap: 0;
330
+ z-index: 10;
331
+ }
332
+ .banner-item {
333
+ text-align: center; padding: 9px 16px; font-size: 0.9rem;
334
+ line-height: 1.45;
335
+ border-bottom: 1px solid transparent;
336
+ }
320
337
  /* Calm status notes (timeout hand-back / lost connection). Never the red error
321
338
  state — the board stays usable, so these only inform. */
322
- .banner.info { background: var(--accent-soft); color: var(--accent); }
323
- .banner.warn { background: var(--bg-sunken); color: var(--fg-2); border-bottom-color: var(--border-strong); }
339
+ .banner.info,
340
+ .banner-item.info { background: var(--accent-soft); color: var(--accent); }
341
+ .banner.warn,
342
+ .banner-item.warn { background: var(--bg-sunken); color: var(--fg-2); border-bottom-color: var(--border-strong); }
324
343
 
325
344
  /* Live-update toast: flagged after a `rly update` reload (see app.js).
326
345
  Fixed top-center, accent-soft bg, accent text; JS auto-removes it. */
@@ -349,14 +368,9 @@ textarea { min-height: 90px; resize: vertical; }
349
368
  .share-btn, .share-copy, .share-choice, .share-close {
350
369
  font: inherit;
351
370
  }
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); }
371
+ .share-btn { background: transparent; }
358
372
  .share-panel {
359
- position: absolute; left: 50%; bottom: calc(100% + 10px); transform: translateX(-50%);
373
+ position: absolute; right: 0; top: calc(100% + 10px);
360
374
  width: min(360px, calc(100vw - 28px));
361
375
  background: var(--card); color: var(--fg);
362
376
  border: 1px solid var(--border-strong); border-radius: 12px;
@@ -386,20 +400,19 @@ textarea { min-height: 90px; resize: vertical; }
386
400
  border: 0; border-radius: 8px; background: var(--accent); color: var(--accent-fg);
387
401
  padding: 7px 12px; cursor: pointer; font-weight: 650;
388
402
  }
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
403
  .relay-review .ann-del,
395
404
  .relay-review .ann-edit { display: none !important; }
396
405
 
397
406
  @media (max-width: 480px) {
398
407
  .wrap { padding: 18px 12px 18px; }
408
+ .qb-header { align-items: flex-start; }
409
+ .qb-controls { gap: 6px; }
399
410
  .card { padding: 16px; border-radius: 10px; }
400
411
  h1 { font-size: 1.35rem; }
401
412
  .seg button { padding: 10px 18px; }
402
413
  .scale button { width: 40px; height: 40px; }
414
+ .theme-btn { padding: 6px 10px; }
415
+ .share-panel { right: -46px; }
403
416
  }
404
417
 
405
418
  /* Persistence-lost notice: when the board can no longer save the user's input