@khanglvm/relay 0.13.8 → 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/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/blocks.css CHANGED
@@ -260,6 +260,68 @@
260
260
  }
261
261
  .diff-file-chip:hover { color: var(--accent); border-color: var(--accent); }
262
262
 
263
+ /* diff review: cherry-pick/apply hunk choices on top of the diff renderer */
264
+ .diff-review-head {
265
+ align-items: flex-start;
266
+ }
267
+ .diff-review-topactions {
268
+ display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 6px;
269
+ }
270
+ .diff-review-status {
271
+ flex: none; border: 1px solid rgba(185, 145, 63, 0.34);
272
+ background: rgba(185, 145, 63, 0.12); color: var(--warn);
273
+ border-radius: 999px; padding: 3px 8px; font-size: 0.72rem; font-weight: 760;
274
+ }
275
+ .diff-review-status.is-complete {
276
+ border-color: rgba(77, 138, 102, 0.42);
277
+ background: rgba(77, 138, 102, 0.14); color: var(--ok);
278
+ }
279
+ .diff-review-btn {
280
+ flex: none; cursor: pointer; font: inherit; font-size: 0.72rem; font-weight: 700;
281
+ color: var(--fg-2); background: var(--card);
282
+ border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px;
283
+ transition: color 150ms var(--ease), border-color 150ms var(--ease), background 150ms var(--ease);
284
+ }
285
+ .diff-review-btn:hover {
286
+ color: var(--accent); border-color: var(--accent);
287
+ }
288
+ .diff-review-choice.sel {
289
+ color: var(--accent); border-color: var(--accent); background: var(--accent-soft);
290
+ }
291
+ .diff-review-list {
292
+ border: 1px solid var(--border); border-radius: 0 0 10px 10px;
293
+ overflow: hidden; background: var(--bg-sunken);
294
+ }
295
+ .diff-review-hunk + .diff-review-hunk {
296
+ border-top: 1px solid var(--border);
297
+ }
298
+ .diff-review-hunkbar {
299
+ display: flex; justify-content: space-between; align-items: center; gap: 10px;
300
+ padding: 6px 8px 6px 12px; background: var(--card); border-bottom: 1px solid var(--border);
301
+ }
302
+ .diff-review-hunkmeta {
303
+ min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
304
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
305
+ }
306
+ .diff-review-file {
307
+ max-width: 360px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
308
+ color: var(--fg-2); font-size: 0.76rem; font-weight: 760;
309
+ }
310
+ .diff-review-header {
311
+ color: var(--muted); font-size: 0.72rem;
312
+ }
313
+ .diff-review-actions {
314
+ flex: none; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px;
315
+ }
316
+ .diff-review-hunk .blk-difftable {
317
+ min-width: 760px;
318
+ }
319
+ @media (max-width: 720px) {
320
+ .diff-review-head, .diff-review-hunkbar { flex-direction: column; align-items: flex-start; }
321
+ .diff-review-topactions, .diff-review-actions { justify-content: flex-start; }
322
+ .diff-review-file { max-width: 100%; }
323
+ }
324
+
263
325
  /* ---------- git conflict resolver ---------- */
264
326
  .gitconf {
265
327
  border: 1px solid var(--border); border-radius: 10px; overflow: hidden;
package/src/ui/blocks.js CHANGED
@@ -582,8 +582,159 @@
582
582
  // Per-file header lines are redundant once we render a filename header bar.
583
583
  const DIFF_NOISE = /^(diff --git |index |--- |\+\+\+ )/;
584
584
 
585
+ function buildDiffReviewHunks(files, block) {
586
+ const hunks = [];
587
+ files.forEach((f) => {
588
+ const name = f.name || (files.length === 1 ? (block.filename || '') : '') || 'file';
589
+ let rows = parseDiff(f.text);
590
+ if (name) rows = rows.filter((r) => !(r.kind === 'meta' && DIFF_NOISE.test(r.text)));
591
+ let current = null;
592
+ const push = () => {
593
+ if (!current) return;
594
+ const hasChange = current.rows.some((r) => r.kind === 'add' || r.kind === 'del');
595
+ if (hasChange) hunks.push(current);
596
+ current = null;
597
+ };
598
+ rows.forEach((r) => {
599
+ if (r.kind === 'hunk') {
600
+ push();
601
+ current = { id: 'h' + (hunks.length + 1), file: name, header: r.text, rows: [r] };
602
+ } else if (current) {
603
+ current.rows.push(r);
604
+ } else if (r.kind === 'add' || r.kind === 'del' || r.kind === 'ctx') {
605
+ current = { id: 'h' + (hunks.length + 1), file: name, header: 'Change', rows: [r] };
606
+ }
607
+ });
608
+ push();
609
+ });
610
+ return hunks;
611
+ }
612
+
613
+ function renderDiffReview(block, ctx, blockId, files) {
614
+ const hunks = buildDiffReviewHunks(files, block);
615
+ let view = block.view === 'split' ? 'split' : 'unified';
616
+ const prior = ctx && ctx.edits && ctx.edits[blockId] && typeof ctx.edits[blockId] === 'object'
617
+ ? ctx.edits[blockId]
618
+ : {};
619
+ const choices = prior.hunks && typeof prior.hunks === 'object' ? { ...prior.hunks } : {};
620
+ const buttonsByHunk = new Map();
621
+ const wrap = el('div', { class: 'blk-codewrap blk-diffwrap diff-review' });
622
+ const status = el('span', { class: 'diff-review-status' }, '');
623
+ const list = el('div', { class: 'diff-review-list' });
624
+
625
+ const emit = () => {
626
+ const picked = {};
627
+ hunks.forEach((h) => {
628
+ const r = choices[h.id];
629
+ if (!r || !['apply', 'skip', 'hold'].includes(r.choice)) return;
630
+ picked[h.id] = { choice: r.choice, file: h.file, header: h.header };
631
+ });
632
+ const touched = Object.keys(picked).length;
633
+ const payload = touched
634
+ ? {
635
+ type: 'diff-review',
636
+ reviewKind: block.reviewKind || '',
637
+ commit: block.commit || '',
638
+ title: block.title || block.filename || '',
639
+ resolved: touched === hunks.length,
640
+ hunks: picked,
641
+ }
642
+ : null;
643
+ if (ctx && typeof ctx.onBlockEdit === 'function') ctx.onBlockEdit(blockId, payload);
644
+ wrap.dispatchEvent(new CustomEvent('relay:block-edit', {
645
+ bubbles: true,
646
+ detail: { blockId, value: payload },
647
+ }));
648
+ status.textContent = `${touched}/${hunks.length} reviewed`;
649
+ status.classList.toggle('is-complete', hunks.length > 0 && touched === hunks.length);
650
+ for (const h of hunks) {
651
+ const set = buttonsByHunk.get(h.id) || [];
652
+ const chosen = choices[h.id] && choices[h.id].choice;
653
+ set.forEach((b) => b.el.classList.toggle('sel', b.choice === chosen));
654
+ }
655
+ };
656
+
657
+ const setChoice = (h, choice) => {
658
+ choices[h.id] = { choice, file: h.file, header: h.header };
659
+ emit();
660
+ };
661
+
662
+ const setAll = (choice) => {
663
+ hunks.forEach((h) => {
664
+ choices[h.id] = { choice, file: h.file, header: h.header };
665
+ });
666
+ emit();
667
+ };
668
+
669
+ const makeReviewButton = (label, onClick, extraClass = '') => {
670
+ const b = el('button', { type: 'button', class: 'diff-review-btn' + (extraClass ? ' ' + extraClass : '') }, label);
671
+ b.addEventListener('mousedown', (e) => e.stopPropagation());
672
+ b.addEventListener('click', (e) => {
673
+ e.preventDefault();
674
+ e.stopPropagation();
675
+ onClick();
676
+ });
677
+ return b;
678
+ };
679
+
680
+ const repaint = () => {
681
+ list.replaceChildren();
682
+ hunks.forEach((h) => {
683
+ const actionbar = el('div', { class: 'diff-review-hunkbar' },
684
+ el('div', { class: 'diff-review-hunkmeta' },
685
+ el('span', { class: 'diff-review-file' }, h.file),
686
+ el('span', { class: 'diff-review-header' }, h.header)
687
+ )
688
+ );
689
+ const rowButtons = [
690
+ { choice: 'apply', label: 'Apply hunk' },
691
+ { choice: 'skip', label: 'Skip hunk' },
692
+ { choice: 'hold', label: 'Hold' },
693
+ ].map((item) => ({
694
+ choice: item.choice,
695
+ el: makeReviewButton(item.label, () => setChoice(h, item.choice), 'diff-review-choice'),
696
+ }));
697
+ buttonsByHunk.set(h.id, rowButtons);
698
+ actionbar.append(el('div', { class: 'diff-review-actions' }, rowButtons.map((b) => b.el)));
699
+ list.append(el('section', { class: 'diff-review-hunk' },
700
+ actionbar,
701
+ view === 'split' ? buildSplitDiff(h.rows, block.lang) : buildUnifiedDiff(h.rows, block.lang)
702
+ ));
703
+ });
704
+ emit();
705
+ };
706
+
707
+ const toggle = makeReviewButton('', () => {
708
+ view = view === 'split' ? 'unified' : 'split';
709
+ wrap.classList.toggle('is-split', view === 'split');
710
+ toggle.textContent = view === 'split' ? 'Unified view' : 'Split view';
711
+ repaint();
712
+ }, 'blk-difftoggle');
713
+ toggle.title = 'Toggle side-by-side view';
714
+ toggle.textContent = view === 'split' ? 'Unified view' : 'Split view';
715
+
716
+ const title = block.title || block.filename || (files.length > 1 ? `${files.length} files changed` : 'Diff review');
717
+ wrap.append(el('div', { class: 'blk-codehead diff-review-head' },
718
+ el('span', { class: 'blk-codename' }, title),
719
+ el('div', { class: 'diff-review-topactions' },
720
+ status,
721
+ makeReviewButton('Apply all', () => setAll('apply')),
722
+ makeReviewButton('Skip all', () => setAll('skip')),
723
+ makeReviewButton('Hold all', () => setAll('hold')),
724
+ toggle
725
+ )
726
+ ));
727
+ wrap.classList.toggle('is-split', view === 'split');
728
+ wrap.append(list);
729
+ repaint();
730
+ ctx && ctx.annotate && ctx.annotate.enableTextSelection(list, { blockId, questionId: ctx.questionId });
731
+ attachViewer(wrap, { zoomEl: null, label: 'diff review', comment: wholeBlockComment(ctx, blockId, 'diff review') });
732
+ return wrap;
733
+ }
734
+
585
735
  function renderDiff(block, ctx, blockId) {
586
736
  const files = splitDiffFiles(block.diff);
737
+ if (block.review === true) return renderDiffReview(block, ctx, blockId, files);
587
738
  const named = files.filter((f) => f.name);
588
739
  const multi = named.length > 1;
589
740
  let view = block.view === 'split' ? 'split' : 'unified';
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; }