@khanglvm/relay 0.12.0 → 0.12.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/docs/AGENT.md CHANGED
@@ -154,7 +154,7 @@ rly show --html-file prototype.html --title "Dashboard concept" --height 600
154
154
  { "value": "a", "label": "Approach A", "description": "fast, less flexible" },
155
155
  "Approach B" // plain strings work too
156
156
  ],
157
- "other": true }, // adds a free-text "Other" option
157
+ "other": false }, // single: "Other" (free-text textarea) is ON by default — set false to remove it
158
158
  { "id": "scope", "type": "multi", "label": "Include which parts?", "options": ["api", "ui", "docs"],
159
159
  "note": true }, // optional free-text under the question
160
160
  // → returned as result.notes.scope
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/relay",
3
- "version": "0.12.0",
3
+ "version": "0.12.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",
@@ -159,6 +159,25 @@
159
159
  }
160
160
  }
161
161
 
162
+ // Platform awareness: reflect the host's surface so the board (and CSS) can
163
+ // adapt — a data-platform hint, a touch flag, and the host's safe-area insets
164
+ // applied as padding so content clears notches / home indicators on mobile.
165
+ function applyHostEnv() {
166
+ const root = document.documentElement;
167
+ if (host.platform === 'web' || host.platform === 'desktop' || host.platform === 'mobile') {
168
+ root.dataset.platform = host.platform;
169
+ }
170
+ root.classList.toggle('mcp-touch', Boolean(host.deviceCapabilities && host.deviceCapabilities.touch));
171
+ const sai = host.safeAreaInsets;
172
+ if (sai && typeof sai === 'object') {
173
+ const px = (n) => (Number.isFinite(n) ? n : 0) + 'px';
174
+ root.style.setProperty('--mcp-safe-top', px(sai.top));
175
+ root.style.setProperty('--mcp-safe-right', px(sai.right));
176
+ root.style.setProperty('--mcp-safe-bottom', px(sai.bottom));
177
+ root.style.setProperty('--mcp-safe-left', px(sai.left));
178
+ }
179
+ }
180
+
162
181
  let sizeTimer = null;
163
182
  function measureHeight() {
164
183
  return Math.max(document.documentElement.scrollHeight, document.body ? document.body.scrollHeight : 0);
@@ -230,7 +249,7 @@
230
249
  }
231
250
  return { value: String(o), label: String(o) };
232
251
  });
233
- q.other = rq.other === true;
252
+ q.other = type === 'single' ? rq.other !== false : rq.other === true;
234
253
  }
235
254
  if (type === 'scale') {
236
255
  q.min = Number.isFinite(rq.min) ? rq.min : 1;
@@ -496,7 +515,7 @@
496
515
  }
497
516
  if (q.other) {
498
517
  otherRadio = el('input', { type: 'radio', name: q.id });
499
- const text = el('input', { type: 'text', placeholder: 'your own answer…' });
518
+ const text = el('textarea', { class: 'otherinput', rows: '2', placeholder: 'your own answer…' });
500
519
  text.value = (state.other[q.id] && state.other[q.id].text) || '';
501
520
  otherRadio.checked = otherOn();
502
521
  const ensureOther = () => state.other[q.id] || (state.other[q.id] = { on: false, text: text.value });
@@ -529,7 +548,7 @@
529
548
  if (q.other) {
530
549
  const oth = state.other[q.id];
531
550
  const box = el('input', { type: 'checkbox' });
532
- const text = el('input', { type: 'text', placeholder: 'your own answer…' });
551
+ const text = el('textarea', { class: 'otherinput', rows: '2', placeholder: 'your own answer…' });
533
552
  box.checked = Boolean(oth && oth.on);
534
553
  text.value = (oth && oth.text) || '';
535
554
  const sync = () => { state.other[q.id] = { on: box.checked, text: text.value }; syncOptSel(group); clearErr(q.id); };
@@ -841,11 +860,15 @@
841
860
  if (p && typeof p === 'object') {
842
861
  if ('theme' in p) host.theme = p.theme;
843
862
  if ('styles' in p) host.styles = p.styles;
863
+ if ('platform' in p) host.platform = p.platform;
864
+ if ('safeAreaInsets' in p) host.safeAreaInsets = p.safeAreaInsets;
865
+ if ('deviceCapabilities' in p) host.deviceCapabilities = p.deviceCapabilities;
844
866
  if (p.displayMode === 'inline' || p.displayMode === 'fullscreen' || p.displayMode === 'pip') {
845
867
  displayMode = p.displayMode;
846
868
  applyDisplayMode();
847
869
  }
848
870
  adoptHostStyles();
871
+ applyHostEnv();
849
872
  applyTheme();
850
873
  }
851
874
  });
@@ -870,6 +893,7 @@
870
893
  if (host.displayMode === 'fullscreen' || host.displayMode === 'pip') displayMode = host.displayMode;
871
894
  notify('ui/notifications/initialized', {});
872
895
  adoptHostStyles();
896
+ applyHostEnv();
873
897
  applyTheme();
874
898
  setStatus('Waiting for the board…');
875
899
  reportSize();
@@ -13,7 +13,12 @@
13
13
  :root{ color-scheme: light dark; }
14
14
  html,body{ background:transparent; }
15
15
  body{ margin:0; }
16
- .wrap{ max-width:none; margin:0; padding:14px 16px 18px; }
16
+ .wrap{ max-width:none; margin:0;
17
+ padding:
18
+ calc(14px + var(--mcp-safe-top, 0px))
19
+ calc(16px + var(--mcp-safe-right, 0px))
20
+ calc(18px + var(--mcp-safe-bottom, 0px))
21
+ calc(16px + var(--mcp-safe-left, 0px)); }
17
22
  .mcp-status{ font:13px/1.5 var(--sans, -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif); color:var(--muted,#8a8580); padding:18px 16px; }
18
23
  /* post-submit: a slim one-line confirmation so the iframe collapses small */
19
24
  .mcp-done{ display:flex; align-items:center; gap:8px; padding:11px 16px; font:14px/1.4 var(--sans, -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif); color:var(--fg,#1c1b19); }
package/src/mcp.js CHANGED
@@ -214,17 +214,42 @@ function callTool(params) {
214
214
  const msg = err instanceof CliError ? err.message : String((err && err.message) || err);
215
215
  return { content: [{ type: 'text', text: 'relay: invalid board spec — ' + msg }], isError: true };
216
216
  }
217
- const nQ = spec.questions.length;
218
- const summary = nQ
219
- ? `Relay board "${spec.title}" is now displayed to the user (${nQ} question${nQ === 1 ? '' : 's'}). They will fill it in and submit; their answers will be delivered back to you. Do NOT re-ask these questions in plain text — wait for the submission.`
220
- : `Relay board "${spec.title}" is now displayed to the user. They can review it and acknowledge; any feedback will be delivered back to you.`;
221
217
  return {
222
- content: [{ type: 'text', text: summary }],
218
+ content: [{ type: 'text', text: boardText(spec) }],
223
219
  structuredContent: { spec, mode: name === 'relay_show' ? 'show' : 'ask' },
224
220
  _meta: { ui: { resourceUri: BOARD_URI }, [UI_EXT]: { resourceUri: BOARD_URI } },
225
221
  };
226
222
  }
227
223
 
224
+ // The tool-result text. Beyond announcing the board, it carries a readable
225
+ // rendering of the questions as a GRACEFUL FALLBACK: on a surface that can't show
226
+ // the interactive board (some mobile/remote-control views), the user can still
227
+ // answer in chat and the model has the questions to work from. The model should
228
+ // PREFER the rendered board (wait for the submission) and only fall back to chat
229
+ // when the board clearly didn't render for the user.
230
+ function boardText(spec) {
231
+ const nQ = spec.questions.length;
232
+ if (!nQ) {
233
+ return `Relay board "${spec.title}" is now displayed to the user. They can review it and acknowledge; any feedback will be delivered back to you.`;
234
+ }
235
+ const lines = [];
236
+ lines.push(`Relay board "${spec.title}" is now displayed to the user (${nQ} question${nQ === 1 ? '' : 's'}). Prefer the interactive board — wait for their submission; do NOT re-ask in plain text.`);
237
+ lines.push('');
238
+ lines.push('If the board does NOT render on the user\'s surface (e.g. some mobile / remote views) they can answer in chat — the questions are:');
239
+ spec.questions.forEach((q, i) => {
240
+ let line = `${i + 1}. [${q.id}] ${q.label} (${q.type}`;
241
+ if (q.required || spec.allowPartial === false) line += ', required';
242
+ line += ')';
243
+ if (Array.isArray(q.options) && q.options.length) {
244
+ line += ' — options: ' + q.options.map((o) => o.label || o.value).join(' | ') + (q.other ? ' | Other (free text)' : '');
245
+ } else if (q.type === 'scale') {
246
+ line += ` — ${q.min}…${q.max}`;
247
+ }
248
+ lines.push(line);
249
+ });
250
+ return lines.join('\n');
251
+ }
252
+
228
253
  // ---------- stdio loop ----------
229
254
  export function runMcp() {
230
255
  // The peer closing stdout (EPIPE) is the only reason to stop writing — NOT a
package/src/spec.js CHANGED
@@ -510,7 +510,10 @@ export function normalizeSpec(raw, { cwd = process.cwd() } = {}) {
510
510
  if (q.options.length < 1) {
511
511
  throw new CliError(`${where}: type "${type}" needs at least 1 option.`);
512
512
  }
513
- q.other = rq.other === true;
513
+ // Radio (single) questions include an "Other" free-text option by default so
514
+ // the user is never boxed into the listed choices; opt out with other:false.
515
+ // Multi stays opt-in (checkbox lists are usually exhaustive on purpose).
516
+ q.other = type === 'single' ? rq.other !== false : rq.other === true;
514
517
  }
515
518
 
516
519
  if (type === 'scale') {
@@ -667,7 +670,7 @@ export const SPEC_SCHEMA = {
667
670
  ],
668
671
  },
669
672
  },
670
- other: { type: 'boolean', default: false, description: 'single/multi: add a free-text "Other" option. Its text is returned verbatim as the value.' },
673
+ other: { type: 'boolean', description: 'Add a free-text "Other" option (a multi-line textarea); its text is returned verbatim as the value. Defaults ON for "single" (radio) questions so the user is never boxed in — set other:false to remove it; "multi" stays opt-in (other:true).' },
671
674
  note: { type: 'boolean', description: 'Small optional free-text field under the question (to qualify an answer). Returned separately as result.notes[questionId]. Defaults to true for "single" (radio) questions so users can comment on their pick, false for other types; set note:false to hide it on a single question.' },
672
675
  placeholder: { type: 'string', description: 'For text/textarea.' },
673
676
  default: { description: 'Pre-selected value. Shape matches the answer shape for the type.' },
@@ -274,11 +274,15 @@
274
274
  .ann-rail-list .ann-sum-row + .ann-sum-row { border-top: 1px solid var(--border); }
275
275
  .ann-rail-list .ann-sum-row:hover { border-color: var(--accent); box-shadow: var(--shadow-card); }
276
276
 
277
- /* Wide screens: dock the rail beside the content instead of overlaying it. */
278
- @media (min-width: 1180px) {
279
- .ann-rail-scrim { display: none !important; }
280
- body.ann-rail-open { padding-right: 340px; }
277
+ /* The rail always overlays the board (scrim dims, content never moves) opening
278
+ it must NOT shift layout, on any width. It's reached only via the floating
279
+ Comments button, which flashes when a new comment lands. */
280
+ @keyframes ann-toggle-pulse {
281
+ 0% { transform: scale(1); }
282
+ 35% { transform: scale(1.12); box-shadow: 0 0 0 4px var(--accent-soft); }
283
+ 100% { transform: scale(1); }
281
284
  }
285
+ .ann-rail-toggle.pulse { animation: ann-toggle-pulse 600ms var(--ease); border-color: var(--accent); color: var(--accent); }
282
286
 
283
287
  /* ---------- jump-to flash ---------- */
284
288
  .ann-flash { animation: ann-flash 1s var(--ease); }
@@ -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
 
@@ -364,6 +367,14 @@
364
367
  if (railOpen) closeRail();
365
368
  else openRail();
366
369
  }
370
+ // Briefly flash the floating Comments button (used when a comment is added so
371
+ // the user sees it landed, without auto-opening the rail / shifting layout).
372
+ function pulseToggle() {
373
+ if (!dom || !dom.railToggle) return;
374
+ dom.railToggle.classList.remove('pulse');
375
+ void dom.railToggle.offsetWidth; // reflow so the animation restarts
376
+ dom.railToggle.classList.add('pulse');
377
+ }
367
378
  // Sync the toggle badge + rail count with the live list; hide the whole rail
368
379
  // affordance when there are no comments.
369
380
  function refreshRailChrome() {
@@ -377,6 +388,7 @@
377
388
 
378
389
  // ---------- hover pin ----------
379
390
  function showPin(entry) {
391
+ if (disabled) return;
380
392
  clearTimeout(pinTimer);
381
393
  pinEntry = entry;
382
394
  const rect = entry.el.getBoundingClientRect();
@@ -483,6 +495,7 @@
483
495
  }
484
496
 
485
497
  function openPopover(info, anchorEl) {
498
+ if (disabled) return;
486
499
  ensureDom();
487
500
  closePopover();
488
501
  hidePin();
@@ -596,7 +609,9 @@
596
609
  }
597
610
  }
598
611
  changed();
599
- openRail(); // surface the new comment in the sidebar, Outline-style
612
+ // Don't auto-open the rail that shifts the board layout. Just flash the
613
+ // floating Comments button so the user notices, and opens it when they want.
614
+ pulseToggle();
600
615
  }
601
616
 
602
617
  // Append a user reply to a top-level annotation's thread (cap 50, like the
@@ -641,6 +656,7 @@
641
656
  }
642
657
 
643
658
  function maybeShowSelBtn(rootEl, baseInfo) {
659
+ if (disabled) return hideSelBtn();
644
660
  const sel = window.getSelection();
645
661
  if (!sel || sel.isCollapsed || !sel.rangeCount) return hideSelBtn();
646
662
  if (!rootEl.contains(sel.anchorNode) || !rootEl.contains(sel.focusNode)) return hideSelBtn();
@@ -851,11 +867,28 @@
851
867
  return popOpen;
852
868
  }
853
869
 
870
+ // Commit a mid-typed comment (the open popover's "Add a comment" box) as a
871
+ // saved annotation, then close it — so freezing the board doesn't throw away
872
+ // text the user was in the middle of writing. ponytail: only the main comment
873
+ // box is flushed, not an in-progress thread reply (the rare case).
874
+ function flushOpen() {
875
+ if (popOpen && popSave) { try { popSave(); } catch { closePopover(); } }
876
+ }
877
+
878
+ // Freeze/unfreeze comment creation. While disabled, no pin, selection button,
879
+ // or popover appears, so the user can't start feedback that won't be saved.
880
+ function setDisabled(v) {
881
+ disabled = Boolean(v);
882
+ // Also close the rail flushOpen() may have just opened — otherwise it covers
883
+ // the host's top-right "connection lost" notice.
884
+ if (disabled) { closePopover(); hideSelBtn(); hidePin(); closeRail(); }
885
+ }
886
+
854
887
  function renderSummary(target) {
855
888
  ensureDom();
856
889
  summaryEls.add(target);
857
890
  renderSummaryInto(target);
858
891
  }
859
892
 
860
- window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, onBadgeRefresh, teardown, isComposing };
893
+ window.RelayAnnotate = { init, register, enableTextSelection, openExternal, list, renderSummary, onBadgeRefresh, teardown, isComposing, flushOpen, setDisabled };
861
894
  })();
package/src/ui/app.js CHANGED
@@ -271,13 +271,15 @@
271
271
  // board's local HTTP server (server gone, port taken over, socket dropped,
272
272
  // machine slept). Autosaves then fail silently and the user keeps typing
273
273
  // answers/comments that are never persisted, then Submit fails too — all of it
274
- // thrown away. When persistence is CONFIRMED lost we hard-block: disable every
275
- // control and overlay an unmissable scrim with a Retry. Local `state` is never
276
- // touched, so the moment the connection recovers we flush it and unblock —
277
- // nothing the user typed during the outage is lost.
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.
278
280
  let persistenceLost = false;
279
281
  let probing = false;
280
- let lostOverlay = null;
282
+ let lostNote = null;
281
283
  let lostRetryBtn = null;
282
284
 
283
285
  // A direct, side-effect-free reachability probe. Resolves true when the local
@@ -293,46 +295,55 @@
293
295
  }
294
296
  }
295
297
 
296
- function buildLostOverlay() {
297
- if (lostOverlay) return lostOverlay;
298
- lostRetryBtn = el('button', { class: 'lost-retry', type: 'button' }, 'Retry connection');
298
+ function buildLostNote() {
299
+ if (lostNote) return lostNote;
300
+ lostRetryBtn = el('button', { class: 'lost-retry', type: 'button' }, 'Reconnect');
299
301
  lostRetryBtn.addEventListener('click', retryConnection);
300
- lostOverlay = el('div', { class: 'lost-overlay', role: 'alertdialog', 'aria-modal': 'true', 'aria-label': 'Connection lost' },
301
- el('div', { class: 'lost-card' },
302
- el('div', { class: 'lost-mark' }, '⚠'),
303
- el('h2', {}, 'Connection lost — input isn’t being saved'),
304
- el('p', {}, 'This board can no longer reach your agent’s session, so anything you type now won’t be saved. Editing is paused to keep you from losing work.'),
305
- el('p', { class: 'lost-sub' }, 'Your input up to this point is kept in this tab. Click Retry once the agent’s session is back, or prompt the agent to reopen this board — your draft and unsaved edits will be restored.'),
306
- lostRetryBtn
307
- )
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
308
309
  );
309
- return lostOverlay;
310
+ return lostNote;
310
311
  }
311
312
 
312
- // Enter the blocked state: disable controls, mount the overlay. Idempotent.
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.
313
316
  function blockForLostPersistence() {
314
317
  if (persistenceLost || submitted) return;
315
318
  persistenceLost = true;
316
319
  document.documentElement.classList.add('relay-blocked');
317
- // Disable every interactive control inside the form (inputs the user could
318
- // otherwise keep typing into) plus Submit.
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.
319
329
  for (const node of app.querySelectorAll('input, textarea, button, select')) {
320
330
  node.disabled = true;
321
331
  }
322
- document.body.append(buildLostOverlay());
332
+ document.body.append(buildLostNote());
323
333
  if (saveEl) saveEl.textContent = 'connection lost — not saving';
324
334
  }
325
335
 
326
- // Leave the blocked state: re-enable controls, remove the overlay, and flush
327
- // 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.
328
338
  function unblockAfterRecovery() {
329
339
  if (!persistenceLost) return;
330
340
  persistenceLost = false;
331
341
  document.documentElement.classList.remove('relay-blocked');
342
+ if (Annotate && typeof Annotate.setDisabled === 'function') { try { Annotate.setDisabled(false); } catch { /* non-fatal */ } }
332
343
  for (const node of app.querySelectorAll('input, textarea, button, select')) {
333
344
  node.disabled = false;
334
345
  }
335
- if (lostOverlay) lostOverlay.remove();
346
+ if (lostNote) lostNote.remove();
336
347
  // Re-arm the heartbeat (it stops itself when it confirms loss) and persist
337
348
  // everything typed during the outage. saveDraft() updates the save label.
338
349
  startHeartbeat();
@@ -586,7 +597,7 @@
586
597
  }
587
598
  if (q.other) {
588
599
  otherRadio = el('input', { type: 'radio', name: q.id });
589
- const text = el('input', { type: 'text', placeholder: 'your own answer…' });
600
+ const text = el('textarea', { class: 'otherinput', rows: '2', placeholder: 'your own answer…' });
590
601
  text.value = (state.other[q.id] && state.other[q.id].text) || '';
591
602
  otherRadio.checked = otherOn();
592
603
  const ensureOther = () => state.other[q.id] || (state.other[q.id] = { on: false, text: text.value });
@@ -655,7 +666,7 @@
655
666
  if (q.other) {
656
667
  const oth = state.other[q.id];
657
668
  const box = el('input', { type: 'checkbox' });
658
- const text = el('input', { type: 'text', placeholder: 'your own answer…' });
669
+ const text = el('textarea', { class: 'otherinput', rows: '2', placeholder: 'your own answer…' });
659
670
  box.checked = Boolean(oth && oth.on);
660
671
  text.value = (oth && oth.text) || '';
661
672
  const sync = () => {
package/src/ui/style.css CHANGED
@@ -211,6 +211,8 @@ input[type="text"]:focus, textarea:focus {
211
211
  box-shadow: 0 0 0 3px var(--accent-soft);
212
212
  }
213
213
  textarea { min-height: 90px; resize: vertical; }
214
+ /* the inline "Other" free-text is a compact 2-row textarea, not a tall one */
215
+ .otherbox .otherinput { min-height: 2.6em; padding: 8px 12px; }
214
216
 
215
217
  .qnotewrap { margin-top: 10px; }
216
218
  .qnotewrap .qnote { font-size: 0.88rem; padding: 8px 12px; min-height: 52px; resize: vertical; }
@@ -282,46 +284,35 @@ textarea { min-height: 90px; resize: vertical; }
282
284
  .scale button { width: 40px; height: 40px; }
283
285
  }
284
286
 
285
- /* Persistence-lost block: when the board can no longer save the user's input
287
+ /* Persistence-lost notice: when the board can no longer save the user's input
286
288
  (server gone / port taken / machine slept) AND recovery probes have failed,
287
- we disable every control and overlay this unmissable scrim so the user is
288
- physically prevented from typing feedback that would be silently discarded.
289
- Sits above everything (full-screen overlay z 49 / popover / rail / toast). */
290
- .relay-blocked #app { filter: grayscale(0.4) blur(1px); pointer-events: none; user-select: none; }
291
- .lost-overlay {
292
- position: fixed; inset: 0; z-index: 60;
293
- display: flex; align-items: center; justify-content: center;
294
- padding: 24px;
295
- background: color-mix(in srgb, var(--bg) 78%, transparent);
296
- backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
297
- }
298
- .lost-card {
299
- max-width: 460px; width: 100%;
289
+ we disable every control (and freeze new comments) and pin this SMALL,
290
+ non-dismissable note top-right with a Reconnect button. It does NOT block the
291
+ page the user can still read and scroll; only editing is paused. Recovery
292
+ re-enables everything and removes the note. */
293
+ .lost-note {
294
+ position: fixed; top: 12px; right: 12px; z-index: 60;
295
+ display: flex; align-items: center; gap: 10px;
296
+ max-width: min(320px, calc(100vw - 24px));
300
297
  background: var(--card); color: var(--fg);
301
- border: 1px solid var(--border-strong); border-radius: 14px;
298
+ border: 1px solid var(--danger); border-radius: 11px;
302
299
  box-shadow: var(--shadow-lift);
303
- padding: 28px 26px; text-align: center;
304
- }
305
- .lost-card .lost-mark {
306
- width: 52px; height: 52px; border-radius: 50%;
307
- background: var(--danger); color: var(--danger-fg);
308
- font-size: 1.6rem; line-height: 52px; margin: 0 auto 16px;
300
+ padding: 9px 11px;
309
301
  }
310
- .lost-card h2 {
311
- font-family: var(--serif); font-weight: 500; letter-spacing: -0.015em;
312
- font-size: 1.3rem; margin: 0 0 10px;
313
- }
314
- .lost-card p { color: var(--fg-2); font-size: 0.92rem; line-height: 1.55; margin: 0 0 8px; }
315
- .lost-card .lost-sub { color: var(--muted); font-size: 0.82rem; margin: 12px 0 18px; }
316
- .lost-card .lost-retry {
302
+ .lost-note-dot { flex: none; width: 9px; height: 9px; border-radius: 50%; background: var(--danger); }
303
+ .lost-note-body { flex: 1; min-width: 0; line-height: 1.3; }
304
+ .lost-note-title { font-size: 0.82rem; font-weight: 600; }
305
+ .lost-note-sub { font-size: 0.72rem; color: var(--muted); margin-top: 1px; }
306
+ .lost-note .lost-retry {
307
+ flex: none;
317
308
  background: var(--accent); color: var(--accent-fg);
318
- border: none; border-radius: 10px;
319
- padding: 11px 26px; font: inherit; font-size: 0.95rem; font-weight: 600; cursor: pointer;
309
+ border: none; border-radius: 8px;
310
+ padding: 6px 12px; font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer;
320
311
  transition: background 150ms var(--ease);
321
312
  }
322
- .lost-card .lost-retry:hover { background: var(--accent-hover); }
323
- .lost-card .lost-retry:disabled { opacity: 0.6; cursor: default; }
324
- .lost-card .lost-retry:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
313
+ .lost-note .lost-retry:hover { background: var(--accent-hover); }
314
+ .lost-note .lost-retry:disabled { opacity: 0.6; cursor: default; }
315
+ .lost-note .lost-retry:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
325
316
 
326
317
  @media (prefers-reduced-motion: reduce) {
327
318
  * { transition: none !important; animation: none !important; }