@khanglvm/relay 0.9.0 → 0.10.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
@@ -88,6 +88,68 @@
88
88
  }
89
89
  let themeBtn = null;
90
90
 
91
+ // ---------- localStorage draft mirror ----------
92
+ // Every autosave is ALSO written to localStorage, keyed by board id. This is
93
+ // the durability layer the server file alone can't provide: if the connection
94
+ // drops and the user keeps typing, the in-memory state is mirrored locally, so
95
+ // even a tab reload / browser restart / a freshly opened tab on the same board
96
+ // prefills the LATEST input instead of a blank board or a stale server save.
97
+ // Guards (per design): newest-of-(local,server) wins; the mirror is discarded
98
+ // if the board's spec rev changed (agent edited it); cleared on submit.
99
+ const LOCAL_DRAFT_KEY = 'relay-draft-' + (boot.boardId || 'unknown');
100
+ function writeLocalDraft(p, updatedAt) {
101
+ try {
102
+ localStorage.setItem(LOCAL_DRAFT_KEY, JSON.stringify({
103
+ v: 1,
104
+ boardId: boot.boardId,
105
+ rev: bootRev,
106
+ updatedAt: updatedAt || new Date().toISOString(),
107
+ payload: p,
108
+ }));
109
+ } catch {
110
+ // localStorage may be full or unavailable (privacy mode) — non-fatal; the
111
+ // server file remains the primary persistence path.
112
+ }
113
+ }
114
+ function clearLocalDraft() {
115
+ try { localStorage.removeItem(LOCAL_DRAFT_KEY); } catch { /* non-fatal */ }
116
+ }
117
+ // Returns the saved local mirror only if it's valid for THIS board+rev,
118
+ // otherwise null (and clears a now-stale entry). rev mismatch ⇒ the agent
119
+ // re-published the board, so old local answers may not map — discard them.
120
+ function loadLocalDraft() {
121
+ let raw;
122
+ try { raw = localStorage.getItem(LOCAL_DRAFT_KEY); } catch { return null; }
123
+ if (!raw) return null;
124
+ let obj;
125
+ try { obj = JSON.parse(raw); } catch { clearLocalDraft(); return null; }
126
+ if (!obj || obj.boardId !== boot.boardId || !obj.payload || typeof obj.payload !== 'object') {
127
+ clearLocalDraft();
128
+ return null;
129
+ }
130
+ // rev-guard: a changed spec rev means the local answers may reference a
131
+ // different question set — don't resurrect them.
132
+ if (bootRev !== null && obj.rev !== undefined && obj.rev !== null && obj.rev !== bootRev) {
133
+ clearLocalDraft();
134
+ return null;
135
+ }
136
+ return obj;
137
+ }
138
+
139
+ // Choose the prefill source: the NEWER of the server draft (boot.prefill) and
140
+ // the local mirror. The server draft shape mirrors a payload() plus updatedAt.
141
+ function chooseInitialPrefill() {
142
+ const server = boot.prefill || null;
143
+ const local = loadLocalDraft();
144
+ if (!local) return server;
145
+ if (!server) return { ...local.payload, __from: 'local' };
146
+ const sT = Date.parse(server.updatedAt || '') || 0;
147
+ const lT = Date.parse(local.updatedAt || '') || 0;
148
+ // Newest wins; ties favor local (the tab that was last typing into).
149
+ return lT >= sT ? { ...local.payload, __from: 'local' } : server;
150
+ }
151
+ const initialPrefill = chooseInitialPrefill();
152
+
91
153
  // ---------- state ----------
92
154
  // state.answers holds raw control state; state.other holds the "Other"
93
155
  // free-text per question; getValue() derives the final answer value.
@@ -97,11 +159,11 @@
97
159
  other: {},
98
160
  notes: {},
99
161
  comment: '',
100
- annotations: (boot.prefill && boot.prefill.annotations) || [],
162
+ annotations: (initialPrefill && initialPrefill.annotations) || [],
101
163
  // Editable-mermaid edits: blockId -> edited source. Seeded from the live
102
164
  // draft so a reload/reopen restores the user's edited diagram. Mutated via
103
165
  // the blocks ctx.onBlockEdit callback below; returned in payload().
104
- blockEdits: (boot.prefill && boot.prefill.blockEdits) || {},
166
+ blockEdits: (initialPrefill && initialPrefill.blockEdits) || {},
105
167
  };
106
168
  let submitted = false;
107
169
 
@@ -125,8 +187,11 @@
125
187
  }
126
188
  }
127
189
  }
128
- if (boot.prefill) seedFromPrefill(boot.prefill);
190
+ if (initialPrefill) seedFromPrefill(initialPrefill);
129
191
  else for (const q of QS) if (q.default !== undefined) state.answers[q.id] = q.default;
192
+ // If the local mirror was newer than the server (or the server had nothing),
193
+ // the in-memory state now holds input the server hasn't seen — flush it once
194
+ // the rest of the app is wired (see the post-init flush near the heartbeat).
130
195
 
131
196
  function getValue(q) {
132
197
  const v = state.answers[q.id];
@@ -176,27 +241,149 @@
176
241
  };
177
242
  }
178
243
 
244
+ // ---------- persistence-lost block ----------
245
+ // The dead-end this guards against: the client can lose its connection to the
246
+ // board's local HTTP server (server gone, port taken over, socket dropped,
247
+ // machine slept). Autosaves then fail silently and the user keeps typing
248
+ // answers/comments that are never persisted, then Submit fails too — all of it
249
+ // thrown away. When persistence is CONFIRMED lost we hard-block: disable every
250
+ // control and overlay an unmissable scrim with a Retry. Local `state` is never
251
+ // touched, so the moment the connection recovers we flush it and unblock —
252
+ // nothing the user typed during the outage is lost.
253
+ let persistenceLost = false;
254
+ let probing = false;
255
+ let lostOverlay = null;
256
+ let lostRetryBtn = null;
257
+
258
+ // A direct, side-effect-free reachability probe. Resolves true when the local
259
+ // server answers /api/status, false on any network/HTTP failure. Used to
260
+ // CONFIRM loss before blocking (so a single dropped request never blocks) and
261
+ // to detect recovery from the Retry button / heartbeat.
262
+ async function probeServer() {
263
+ try {
264
+ const r = await fetch('/api/status', { cache: 'no-store' });
265
+ return r.ok;
266
+ } catch {
267
+ return false;
268
+ }
269
+ }
270
+
271
+ function buildLostOverlay() {
272
+ if (lostOverlay) return lostOverlay;
273
+ lostRetryBtn = el('button', { class: 'lost-retry', type: 'button' }, 'Retry connection');
274
+ lostRetryBtn.addEventListener('click', retryConnection);
275
+ lostOverlay = el('div', { class: 'lost-overlay', role: 'alertdialog', 'aria-modal': 'true', 'aria-label': 'Connection lost' },
276
+ el('div', { class: 'lost-card' },
277
+ el('div', { class: 'lost-mark' }, '⚠'),
278
+ el('h2', {}, 'Connection lost — input isn’t being saved'),
279
+ 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.'),
280
+ 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.'),
281
+ lostRetryBtn
282
+ )
283
+ );
284
+ return lostOverlay;
285
+ }
286
+
287
+ // Enter the blocked state: disable controls, mount the overlay. Idempotent.
288
+ function blockForLostPersistence() {
289
+ if (persistenceLost || submitted) return;
290
+ persistenceLost = true;
291
+ document.documentElement.classList.add('relay-blocked');
292
+ // Disable every interactive control inside the form (inputs the user could
293
+ // otherwise keep typing into) plus Submit.
294
+ for (const node of app.querySelectorAll('input, textarea, button, select')) {
295
+ node.disabled = true;
296
+ }
297
+ document.body.append(buildLostOverlay());
298
+ if (saveEl) saveEl.textContent = 'connection lost — not saving';
299
+ }
300
+
301
+ // Leave the blocked state: re-enable controls, remove the overlay, and flush
302
+ // whatever the user typed during the outage so it's persisted right away.
303
+ function unblockAfterRecovery() {
304
+ if (!persistenceLost) return;
305
+ persistenceLost = false;
306
+ document.documentElement.classList.remove('relay-blocked');
307
+ for (const node of app.querySelectorAll('input, textarea, button, select')) {
308
+ node.disabled = false;
309
+ }
310
+ if (lostOverlay) lostOverlay.remove();
311
+ // Re-arm the heartbeat (it stops itself when it confirms loss) and persist
312
+ // everything typed during the outage. saveDraft() updates the save label.
313
+ startHeartbeat();
314
+ misses = 0;
315
+ saveFailures = 0;
316
+ saveDraft();
317
+ }
318
+
319
+ // Retry button: probe once; recover on success, otherwise tell the user it's
320
+ // still down (without un-blocking).
321
+ async function retryConnection() {
322
+ if (probing) return;
323
+ probing = true;
324
+ if (lostRetryBtn) { lostRetryBtn.disabled = true; lostRetryBtn.textContent = 'Checking…'; }
325
+ const ok = await probeServer();
326
+ probing = false;
327
+ if (lostRetryBtn) { lostRetryBtn.disabled = false; lostRetryBtn.textContent = 'Retry connection'; }
328
+ if (ok) unblockAfterRecovery();
329
+ else if (lostRetryBtn) {
330
+ lostRetryBtn.textContent = 'Still unreachable — try again';
331
+ setTimeout(() => { if (lostRetryBtn && persistenceLost) lostRetryBtn.textContent = 'Retry connection'; }, 2500);
332
+ }
333
+ }
334
+
335
+ // Called when a save/heartbeat fails. Confirms loss with a probe (so one
336
+ // dropped request never blocks) before hard-blocking. `force` skips the probe
337
+ // for callers (the heartbeat) that already represent repeated failures.
338
+ async function considerPersistenceLost(force) {
339
+ if (persistenceLost || submitted || probing) return;
340
+ if (!force) {
341
+ probing = true;
342
+ const ok = await probeServer();
343
+ probing = false;
344
+ if (ok || persistenceLost || submitted) return; // recovered or already handled
345
+ }
346
+ blockForLostPersistence();
347
+ }
348
+
179
349
  // ---------- real-time autosave ----------
180
350
  let saveTimer = null;
181
351
  let saveSeq = 0;
182
352
  let saveEl = null;
353
+ // Consecutive failed /api/draft saves. Two in a row triggers a confirming
354
+ // probe → block. Any success resets it.
355
+ let saveFailures = 0;
183
356
  function scheduleSave() {
184
357
  if (submitted) return;
358
+ // Mirror to localStorage SYNCHRONOUSLY on every edit, before (and regardless
359
+ // of) the network save. This is what survives a tab reload / crash / a new
360
+ // tab during a connection outage — it must happen even while blocked.
361
+ writeLocalDraft(payload());
362
+ if (persistenceLost) return; // network save is futile while disconnected
185
363
  if (saveEl) saveEl.textContent = 'saving…';
186
364
  clearTimeout(saveTimer);
187
365
  saveTimer = setTimeout(saveDraft, 450);
188
366
  }
189
367
  async function saveDraft() {
190
368
  const seq = ++saveSeq;
369
+ // Keep the local mirror current on every flush too (covers programmatic
370
+ // saveDraft() calls that don't go through scheduleSave, e.g. recovery flush).
371
+ writeLocalDraft(payload());
191
372
  try {
192
- await fetch('/api/draft', {
373
+ const r = await fetch('/api/draft', {
193
374
  method: 'POST',
194
375
  headers: { 'content-type': 'application/json' },
195
376
  body: JSON.stringify(payload()),
196
377
  });
197
- if (seq === saveSeq && saveEl && !submitted) saveEl.textContent = 'draft saved ✓';
378
+ if (!r.ok) throw new Error('draft rejected');
379
+ saveFailures = 0;
380
+ if (seq === saveSeq && saveEl && !submitted && !persistenceLost) saveEl.textContent = 'draft saved ✓';
198
381
  } catch {
199
- if (seq === saveSeq && saveEl && !submitted) saveEl.textContent = 'draft save failed';
382
+ if (seq === saveSeq && saveEl && !submitted && !persistenceLost) saveEl.textContent = 'draft save failed';
383
+ // A save couldn't be persisted to the SERVER — the core data-loss signal.
384
+ // (The local mirror above still captured it.) After two in a row, confirm
385
+ // with a probe and block so the user stops typing into the void.
386
+ if (++saveFailures >= 2) considerPersistenceLost(false);
200
387
  }
201
388
  }
202
389
 
@@ -543,7 +730,13 @@
543
730
  Annotate?.register(titleEl, { blockId: null, questionId: null, target: { kind: 'html-element', label: spec.title } });
544
731
  }
545
732
  if (spec.intro) {
546
- const intro = el('p', { class: 'intro' }, spec.intro);
733
+ // Render the intro as markdown (bold/italic/code/links/lists) agents write
734
+ // markdown here by default. Falls back to plain text if blocks.js is absent.
735
+ // The .blk-markdown class scopes the shared markdown typography to it.
736
+ const md = typeof window.RelayBlocks !== 'undefined' && window.RelayBlocks.renderMarkdown;
737
+ const intro = md
738
+ ? el('div', { class: 'intro blk-markdown' }, window.RelayBlocks.renderMarkdown(spec.intro))
739
+ : el('p', { class: 'intro' }, spec.intro);
547
740
  app.append(intro);
548
741
  Annotate?.enableTextSelection(intro, { blockId: null, questionId: null });
549
742
  }
@@ -650,14 +843,26 @@
650
843
  submitBtn.disabled = true;
651
844
  submitBtn.textContent = 'Submitting…';
652
845
  clearTimeout(saveTimer);
846
+ let reached = true;
653
847
  try {
654
- const res = await fetch('/api/submit', {
655
- method: 'POST',
656
- headers: { 'content-type': 'application/json' },
657
- body: JSON.stringify(payload()),
658
- });
848
+ let res;
849
+ try {
850
+ res = await fetch('/api/submit', {
851
+ method: 'POST',
852
+ headers: { 'content-type': 'application/json' },
853
+ body: JSON.stringify(payload()),
854
+ });
855
+ } catch (netErr) {
856
+ // A thrown fetch (vs. an HTTP error response) means we never reached the
857
+ // server — the connection is gone, so this submit was never delivered.
858
+ reached = false;
859
+ throw netErr;
860
+ }
659
861
  if (!res.ok) throw new Error('submit rejected');
660
862
  submitted = true;
863
+ // Submitted successfully → the local mirror is no longer needed and would
864
+ // otherwise resurrect stale answers on a future reopen. Clear it.
865
+ clearLocalDraft();
661
866
  // Don't auto-close when the agent had stopped waiting — the user needs to
662
867
  // read the "send your agent a message" note and act on it.
663
868
  const autoClose = spec.autoClose && !handedBack;
@@ -674,22 +879,33 @@
674
879
  }, 700);
675
880
  }
676
881
  } catch {
677
- // The server is gone, so this submit couldn't be delivered. Keep the
678
- // button live and tell the user how to get their input to the agent —
679
- // their draft was autosaved up to the last edit.
882
+ // Restore the button so the user can retry.
680
883
  submitBtn.disabled = false;
681
884
  submitBtn.textContent = spec.submitLabel;
682
- showNotice(
683
- 'Couldn’t reach the agent to submit just now your draft is saved. Prompt the agent to reopen this board so your input isn’t lost.',
684
- 'warn'
685
- );
885
+ if (!reached) {
886
+ // The connection is gone — the submit (and any further input) can't be
887
+ // persisted. Block hard so the user stops adding feedback that would be
888
+ // lost; the block's probe loop / heartbeat lifts it on recovery, and the
889
+ // user can then submit. force=true: the failed submit already confirms
890
+ // the server is unreachable.
891
+ stopHeartbeat();
892
+ considerPersistenceLost(true);
893
+ startBlockedProbeLoop();
894
+ } else {
895
+ // The server answered with an error (e.g. 409 board already finished) —
896
+ // it's reachable, so don't show the scary block; just guide the user.
897
+ showNotice(
898
+ 'Couldn’t submit — the board may have already closed. Prompt the agent to reopen this board so your input isn’t lost.',
899
+ 'warn'
900
+ );
901
+ }
686
902
  }
687
903
  });
688
904
 
689
905
  // ---------- prefilled load: jump past what's already answered ----------
690
906
  // On reload/reopen with saved answers, scroll to the first unanswered
691
907
  // question so the user doesn't re-scan questions they already did.
692
- if (boot.prefill && QS.length) {
908
+ if (initialPrefill && QS.length) {
693
909
  const answered = QS.filter((q) => getValue(q) !== undefined).length;
694
910
  const firstOpen = QS.find((q) => getValue(q) === undefined);
695
911
  if (answered > 0 && firstOpen) {
@@ -699,6 +915,14 @@
699
915
  }
700
916
  }
701
917
 
918
+ // If the chosen prefill came from the local mirror (newer than the server, or
919
+ // the server had nothing — e.g. a freshly reopened board the user had typed
920
+ // into in another tab during an outage), the server doesn't yet have this
921
+ // input. Flush it once so a brand-new tab's view is also the server's truth.
922
+ if (initialPrefill && initialPrefill.__from === 'local' && !submitted) {
923
+ saveDraft();
924
+ }
925
+
702
926
  // ---------- iframe annotate bridge ----------
703
927
  // Custom-HTML iframes (via /kit.js relayKit.annotate, auto-injected by the
704
928
  // server) talk to the parent over postMessage:
@@ -766,13 +990,20 @@
766
990
  // ---------- heartbeat ----------
767
991
  let misses = 0;
768
992
  let reloading = false;
769
- let hb = setInterval(async () => {
993
+ let hb = null;
994
+ async function heartbeatTick() {
770
995
  // Piggyback presence on the heartbeat (best-effort; no-ops after submit).
771
996
  pingPresence();
772
997
  try {
773
998
  const r = await fetch('/api/status', { cache: 'no-store' });
774
999
  if (!r.ok) throw new Error('bad status');
775
1000
  misses = 0;
1001
+ // The heartbeat reaching the server is itself proof persistence is back —
1002
+ // if we were blocked (saves had been failing), recover now and flush.
1003
+ if (persistenceLost) {
1004
+ unblockAfterRecovery();
1005
+ return;
1006
+ }
776
1007
  // Live update: the agent ran `rly update`, advancing the server rev.
777
1008
  // Flush whatever the user has typed so far (the reload re-prefills from
778
1009
  // the live draft — answers for now-removed question ids are ignored),
@@ -806,22 +1037,55 @@
806
1037
  location.reload();
807
1038
  }
808
1039
  } catch {
809
- // Lost the live connection (the session ended, or the machine slept).
810
- // Keep the board usable do NOT disable Submit and show a calm note
811
- // rather than the old red "closed" banner. A submit attempt that can't
812
- // reach the server falls back to the same guidance below.
813
- if (++misses >= 2 && !submitted) {
1040
+ // Lost the live connection (the session ended, or the machine slept). Two
1041
+ // consecutive misses means the local server is unreachableso input can
1042
+ // no longer be persisted. Hard-block: disable editing and overlay the
1043
+ // unmissable "connection lost" scrim, so the user can't keep typing
1044
+ // feedback that would be silently discarded. The block re-arms its own
1045
+ // probe loop and the heartbeat recovers it once the server answers again.
1046
+ if (++misses >= 2 && !submitted && !persistenceLost) {
814
1047
  handedBack = true;
815
- showNotice(
816
- 'Lost the live connection to your agent’s session — your latest edits were saved. You can still try Submit; if it doesn’t go through, prompt the agent to reopen this board.',
817
- 'warn'
818
- );
819
1048
  stopHeartbeat();
1049
+ // force=true: two heartbeat misses already confirm the server is gone,
1050
+ // so block immediately without a redundant probe.
1051
+ considerPersistenceLost(true);
1052
+ // While blocked, keep probing so an automatic recovery (server back,
1053
+ // machine woke) lifts the block even if the user never clicks Retry.
1054
+ startBlockedProbeLoop();
820
1055
  }
821
1056
  }
822
- }, 3000);
1057
+ }
1058
+ function startHeartbeat() {
1059
+ if (hb || submitted) return;
1060
+ hb = setInterval(heartbeatTick, 3000);
1061
+ }
823
1062
  function stopHeartbeat() {
824
1063
  if (hb) clearInterval(hb);
825
1064
  hb = null;
826
1065
  }
1066
+
1067
+ // While blocked, the heartbeat is stopped — so run a lightweight probe loop
1068
+ // that lifts the block automatically the moment the server is reachable again
1069
+ // (no Retry click needed). Stops itself on recovery or after submit.
1070
+ let blockedProbe = null;
1071
+ function startBlockedProbeLoop() {
1072
+ if (blockedProbe) return;
1073
+ blockedProbe = setInterval(async () => {
1074
+ if (!persistenceLost || submitted) {
1075
+ clearInterval(blockedProbe);
1076
+ blockedProbe = null;
1077
+ return;
1078
+ }
1079
+ if (probing) return;
1080
+ probing = true;
1081
+ const ok = await probeServer();
1082
+ probing = false;
1083
+ if (ok) {
1084
+ clearInterval(blockedProbe);
1085
+ blockedProbe = null;
1086
+ unblockAfterRecovery();
1087
+ }
1088
+ }, 3000);
1089
+ }
1090
+ startHeartbeat();
827
1091
  })();
package/src/ui/blocks.css CHANGED
@@ -79,6 +79,42 @@
79
79
  .blk-markdown .md .md-tablewrap { overflow-x: auto; margin: 12px 0; }
80
80
  .blk-markdown .md .md-table { margin: 0; }
81
81
 
82
+ /* Click-to-open local file links. Rendered wherever markdown runs (intro, md
83
+ blocks, table cells). A small file glyph + accent text signals "openable",
84
+ distinct from a plain web link. The whole chip is the click target. */
85
+ .rly-filelink {
86
+ display: inline-flex; align-items: baseline; gap: 0.3em;
87
+ color: var(--accent); cursor: pointer; text-decoration: none;
88
+ border-bottom: 1px solid transparent;
89
+ border-radius: 4px;
90
+ transition: border-color 150ms var(--ease), background 150ms var(--ease);
91
+ }
92
+ .rly-filelink:hover { border-bottom-color: var(--accent); }
93
+ .rly-filelink:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
94
+ .rly-filelink .rly-filelink-ico {
95
+ flex: none; align-self: center;
96
+ width: 0.92em; height: 0.92em;
97
+ background: currentColor;
98
+ /* a document glyph drawn via mask, so it inherits the accent color */
99
+ -webkit-mask: var(--rly-file-mask) center / contain no-repeat;
100
+ mask: var(--rly-file-mask) center / contain no-repeat;
101
+ opacity: 0.85;
102
+ }
103
+ :root {
104
+ --rly-file-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M6 2h8l6 6v12a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2zm8 1.5V8h4.5L14 3.5z'/%3E%3C/svg%3E");
105
+ }
106
+ .rly-filelink.rly-filelink-code .rly-filelink-txt {
107
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
108
+ font-size: 0.86em;
109
+ background: var(--bg-sunken);
110
+ border: 1px solid var(--border);
111
+ border-radius: 5px;
112
+ padding: 1px 5px;
113
+ }
114
+ .rly-filelink.is-opening { opacity: 0.55; pointer-events: none; }
115
+ /* Error-tone toast for a failed open (success reuses the plain .toast). */
116
+ .toast.toast-err { background: var(--danger); color: var(--danger-fg); }
117
+
82
118
  /* ---------- code block ---------- */
83
119
  .blk-pre {
84
120
  background: var(--bg-sunken);
@@ -87,24 +123,130 @@
87
123
  padding: 14px 16px;
88
124
  overflow-x: auto;
89
125
  margin: 0;
90
- }
91
- .blk-pre code {
126
+ /* Pin font metrics on the <pre> itself: its line-box strut uses the pre's
127
+ own font-size, so leaving it inherited (≈16px) makes lines taller than the
128
+ 0.85rem gutter and the numbers drift. Match them exactly. */
92
129
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
93
130
  font-size: 0.85rem;
94
131
  line-height: 1.55;
132
+ }
133
+ .blk-pre code {
134
+ font-family: inherit;
135
+ font-size: inherit;
136
+ line-height: inherit;
95
137
  color: var(--fg);
96
138
  white-space: pre;
97
139
  }
98
140
  /* subtle tinter colors via theme vars only */
99
141
  .blk-pre .tok-kw,
142
+ .blk-difftable .tok-kw,
100
143
  .blk-markdown .md .tok-kw { color: var(--accent); }
101
144
  .blk-pre .tok-str,
145
+ .blk-difftable .tok-str,
102
146
  .blk-markdown .md .tok-str { color: var(--ok); }
103
147
  .blk-pre .tok-com,
148
+ .blk-difftable .tok-com,
104
149
  .blk-markdown .md .tok-com { color: var(--muted); font-style: italic; }
105
150
  .blk-pre .tok-num,
151
+ .blk-difftable .tok-num,
106
152
  .blk-markdown .md .tok-num { color: var(--fg-2); }
107
153
 
154
+ /* code block: file-name / language header above the card */
155
+ .blk-codehead {
156
+ display: flex; align-items: center; justify-content: space-between; gap: 12px;
157
+ padding: 6px 12px; margin-bottom: -1px;
158
+ background: var(--bg-sunken);
159
+ border: 1px solid var(--border); border-bottom: 0;
160
+ border-radius: 10px 10px 0 0;
161
+ font-size: 0.74rem; color: var(--muted);
162
+ }
163
+ .blk-codehead .blk-codename {
164
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
165
+ color: var(--fg-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
166
+ }
167
+ .blk-codehead .blk-codelang { text-transform: uppercase; letter-spacing: 0.05em; flex: none; }
168
+ .blk-codehead:empty,
169
+ .blk-codehead .blk-codename:empty,
170
+ .blk-codehead .blk-codelang:empty { display: none; }
171
+ .blk-codehead:has(.blk-codename:empty) { justify-content: flex-end; }
172
+
173
+ /* code block: line-number gutter + code column share one bordered card */
174
+ .blk-coderow {
175
+ display: flex; align-items: stretch;
176
+ background: var(--bg-sunken);
177
+ border: 1px solid var(--border);
178
+ border-radius: 10px;
179
+ overflow: hidden;
180
+ }
181
+ .blk-codehead + .blk-coderow { border-radius: 0 0 10px 10px; }
182
+ .blk-gutter {
183
+ flex: none; user-select: none; -webkit-user-select: none;
184
+ padding: 14px 10px 14px 14px; text-align: right;
185
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
186
+ font-size: 0.85rem; line-height: 1.55;
187
+ color: var(--muted); white-space: pre;
188
+ border-right: 1px solid var(--border);
189
+ }
190
+ .blk-coderow .blk-pre {
191
+ flex: 1 1 auto; border: 0; border-radius: 0; background: none;
192
+ }
193
+
194
+ /* ---------- diff (unified / git diff) ---------- */
195
+ .blk-diffscroll { overflow-x: auto; border: 1px solid var(--border); border-radius: 10px; background: var(--bg-sunken); }
196
+ .blk-codehead + .blk-diffscroll { border-radius: 0 0 10px 10px; }
197
+ .blk-difftable {
198
+ border-collapse: collapse; width: 100%;
199
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
200
+ font-size: 0.84rem; line-height: 1.5;
201
+ }
202
+ .blk-difftable td { padding: 0 8px; vertical-align: top; }
203
+ .blk-difftable .diff-no {
204
+ width: 1%; white-space: nowrap; text-align: right;
205
+ color: var(--muted); user-select: none; -webkit-user-select: none;
206
+ padding: 0 6px; border-right: 1px solid var(--border);
207
+ }
208
+ .blk-difftable .diff-sign {
209
+ width: 1ch; text-align: center; color: var(--muted);
210
+ user-select: none; -webkit-user-select: none; padding: 0 4px;
211
+ }
212
+ .blk-difftable .diff-code { width: 100%; white-space: pre; }
213
+ .blk-difftable .diff-code code { font: inherit; color: var(--fg); background: none; }
214
+ .blk-difftable .diff-add { background: rgba(77, 138, 102, 0.16); }
215
+ .blk-difftable .diff-add .diff-sign { color: var(--ok); }
216
+ .blk-difftable .diff-del { background: rgba(188, 68, 52, 0.15); }
217
+ .blk-difftable .diff-del .diff-sign { color: var(--danger); }
218
+ .blk-difftable .diff-hunk td { color: var(--accent); background: var(--accent-soft); padding-top: 2px; padding-bottom: 2px; }
219
+ .blk-difftable .diff-meta td { color: var(--muted); font-style: italic; }
220
+ .blk-difftable .diff-hunk .diff-code code,
221
+ .blk-difftable .diff-meta .diff-code code { color: inherit; }
222
+
223
+ /* diff: side-by-side (split) view — old | new. Long lines grow the table and
224
+ the wrapper scrolls horizontally; short code splits ~50/50. */
225
+ .blk-difftable-split .diff-code { width: 50%; }
226
+ .blk-difftable-split .diff-newside { border-left: 2px solid var(--border-strong); }
227
+ .blk-difftable .diff-fill {
228
+ background-color: var(--bg-sunken);
229
+ background-image: repeating-linear-gradient(45deg, transparent 0, transparent 6px, rgba(128, 128, 128, 0.07) 6px, rgba(128, 128, 128, 0.07) 12px);
230
+ }
231
+ /* Unified/Split view toggle in the diff header */
232
+ .blk-difftoggle {
233
+ flex: none; cursor: pointer; font: inherit; font-size: 0.72rem;
234
+ color: var(--fg-2); background: var(--card);
235
+ border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px;
236
+ transition: color 150ms var(--ease), border-color 150ms var(--ease);
237
+ }
238
+ .blk-difftoggle:hover { color: var(--accent); border-color: var(--accent); }
239
+
240
+ /* ---------- video (iframe embed / local stream / direct URL) ---------- */
241
+ .blk-videowrap { margin: 0; }
242
+ .blk-video, .blk-video-embed {
243
+ display: block; width: 100%; border: 0;
244
+ border-radius: 10px; background: #000;
245
+ }
246
+ .blk-video { max-height: 70vh; }
247
+ .blk-video-embed { aspect-ratio: 16 / 9; height: auto; }
248
+ .blk-videocap { margin-top: 8px; font-size: 0.84rem; color: var(--muted); text-align: center; }
249
+
108
250
  /* ---------- table ---------- */
109
251
  .blk-table {
110
252
  width: 100%;