@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/blocks.js CHANGED
@@ -37,21 +37,80 @@
37
37
  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
38
38
  }
39
39
 
40
+ // ---------- local file paths → click-to-open links ----------
41
+ // Agents routinely write a path (~/clip.mp4, ./src/app.js, /abs/file) and the
42
+ // user expects to click it open, not copy it into a terminal. We turn such
43
+ // paths into links that POST to /api/open, where the server opens them in the
44
+ // OS default app. FILE_PATH_RE / looksLikeLocalPath MUST match the same logic
45
+ // in server.js so the page only links what the server will agree to open.
46
+ // The body class also excludes \0 (the placeholder delimiter mdInline uses
47
+ // below) so a path butted against a stashed span can't swallow it; \0 never
48
+ // appears in real text, so this doesn't change which real paths match.
49
+ const FILE_PATH_RE =
50
+ /(?<![\w@:./])(?:file:\/\/\/?[^\s)<>"'`*\0]+|~\/[^\s)<>"'`*\0]+|\.{1,2}\/[^\s)<>"'`*\0]+|\/[^\s)<>"'`*\0]+|[A-Za-z]:[\\/][^\s)<>"'`*\0]+)/g;
51
+
52
+ // Note: candidates here are HTML-escaped (esc() ran first), but escaping only
53
+ // touches & < > " ' — none of which appear in a path's structural prefix, so
54
+ // these tests are safe to run on the escaped text. Mirrors server.js.
55
+ function looksLikeLocalPath(s) {
56
+ if (typeof s !== 'string') return false;
57
+ const t = s.trim();
58
+ if (!t || /\s/.test(t)) return false;
59
+ if (/^file:\/\//i.test(t)) return true;
60
+ if (/^[A-Za-z]:[\\/]/.test(t)) return true; // windows drive
61
+ if (t === '~' || /^~\//.test(t)) return true;
62
+ if (/^\.\.?\//.test(t)) return true; // ./ or ../
63
+ if (t.startsWith('/')) return /\/[^/]+\/[^/]/.test(t) || /\.[A-Za-z0-9]{1,8}$/.test(t);
64
+ return false;
65
+ }
66
+
67
+ // Builds the <a> for a file path. `raw` and `label` are already escaped, so
68
+ // they embed safely inside the attribute and text. codeStyle keeps the
69
+ // monospace look when the path came from a `backtick` span.
70
+ function fileLinkHtml(raw, label, codeStyle) {
71
+ const cls = 'rly-filelink' + (codeStyle ? ' rly-filelink-code' : '');
72
+ return (
73
+ `<a class="${cls}" role="link" tabindex="0" data-rly-open="${raw}" ` +
74
+ `title="Open ${raw} in the default app">` +
75
+ `<span class="rly-filelink-ico" aria-hidden="true"></span>` +
76
+ `<span class="rly-filelink-txt">${label}</span></a>`
77
+ );
78
+ }
79
+
40
80
  // ---------- markdown mini renderer (NO library) ----------
41
81
  // Escape FIRST, then apply transforms on the already-escaped text. Because
42
82
  // <, >, & are gone, our generated tags are the only real tags in the output.
43
83
  function mdInline(escaped) {
84
+ // Stash code/link/file-link HTML behind \0-delimited placeholders so the
85
+ // later path-autolink and bold/italic passes can't re-tokenize inside them.
86
+ // \0 never appears in real text, so the placeholders can't collide with it.
87
+ const stash = [];
88
+ const keep = (html) => '\0' + (stash.push(html) - 1) + '\0';
89
+
44
90
  let s = escaped;
45
- // inline code first so its contents aren't treated as bold/italic/links
46
- s = s.replace(/`([^`]+)`/g, (_m, c) => `<code>${c}</code>`);
47
- // links [text](url) url is already escaped; guard javascript: schemes
91
+ // inline code first so its contents aren't treated as bold/italic/links
92
+ // a backtick span that is ITSELF a lone path opens instead of just showing.
93
+ s = s.replace(/`([^`]+)`/g, (_m, c) =>
94
+ looksLikeLocalPath(c) ? keep(fileLinkHtml(c.trim(), c, true)) : keep(`<code>${c}</code>`)
95
+ );
96
+ // links [text](url) — url is already escaped; a local-path target becomes a
97
+ // click-to-open link, otherwise a normal link (guarding odd schemes).
48
98
  s = s.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => {
99
+ if (looksLikeLocalPath(url)) return keep(fileLinkHtml(url, text, false));
49
100
  const safe = /^(https?:|mailto:|\/|#|\.)/i.test(url) ? url : '#';
50
- return `<a href="${safe}" target="_blank" rel="noopener">${text}</a>`;
101
+ return keep(`<a href="${safe}" target="_blank" rel="noopener">${text}</a>`);
51
102
  });
103
+ // bare paths in running text (the common case: the agent just typed a path)
104
+ s = s.replace(FILE_PATH_RE, (m) => (looksLikeLocalPath(m) ? keep(fileLinkHtml(m, m, false)) : m));
52
105
  // bold then italic (bold uses ** so must run before single *)
53
106
  s = s.replace(/\*\*([^*]+)\*\*/g, (_m, c) => `<strong>${c}</strong>`);
54
107
  s = s.replace(/\*([^*]+)\*/g, (_m, c) => `<em>${c}</em>`);
108
+ // restore the stashed HTML — loop so a placeholder nested inside another
109
+ // stashed fragment (e.g. a code span inside a link's text) is also resolved.
110
+ let guard = 0;
111
+ while (s.indexOf('\0') !== -1 && guard++ < 6) {
112
+ s = s.replace(/\0(\d+)\0/g, (_m, i) => (stash[Number(i)] !== undefined ? stash[Number(i)] : ''));
113
+ }
55
114
  return s;
56
115
  }
57
116
 
@@ -220,64 +279,78 @@
220
279
  return root;
221
280
  }
222
281
 
223
- // ---------- code tinter (lightweight regex highlighter) ----------
224
- const KEYWORDS = {
225
- js: 'await async break case catch class const continue default delete do else export extends false finally for from function if import in instanceof let new null of return super switch this throw true try typeof undefined var void while yield',
226
- ts: 'await async break case catch class const continue default delete do else enum export extends false finally for from function if implements import in instanceof interface let new null of private protected public readonly return super switch this throw true try type typeof undefined var void while yield',
227
- json: 'true false null',
228
- py: 'and as assert async await break class continue def del elif else except False finally for from global if import in is lambda None nonlocal not or pass raise return True try while with yield',
229
- sh: 'if then else elif fi for while do done case esac function in return export local echo cd',
230
- css: '',
231
- html: '',
282
+ // ---------- code tinter (lightweight, zero-dependency, offline) ----------
283
+ // Per language we record its comment style(s), whether it has backtick
284
+ // strings, and its keyword set. From those a single alternation regex tints
285
+ // comments / strings / numbers / keywords in one pass. Unknown languages
286
+ // render as plain escaped text; css / html / xml get bespoke patterns.
287
+ // Mirrors the wide-compat goal: no library, works in every browser offline.
288
+ const LANGS = {
289
+ js: { c: true, tmpl: true, kw: 'await async break case catch class const continue debugger default delete do else export extends false finally for from function get if import in instanceof let new null of return set static super switch this throw true try typeof undefined var void while yield' },
290
+ ts: { c: true, tmpl: true, kw: 'abstract any as asserts async await boolean break case catch class const continue declare default delete do else enum export extends false finally for from function get if implements import in infer instanceof interface is keyof let namespace never new null number object of private protected public readonly return satisfies set static string super switch this throw true try type typeof undefined unknown var void while yield' },
291
+ json: { kw: 'true false null' },
292
+ py: { hash: true, kw: 'and as assert async await break case class continue def del elif else except False finally for from global if import in is lambda match None nonlocal not or pass raise return self True try while with yield' },
293
+ sh: { hash: true, kw: 'if then else elif fi for while until do done case esac function in return export local readonly echo cd set unset source eval exec trap exit' },
294
+ go: { c: true, tmpl: true, kw: 'break case chan const continue default defer else fallthrough for func go goto if import interface map package range return select struct switch type var nil true false iota' },
295
+ rust: { c: true, kw: 'as async await break const continue crate dyn else enum extern false fn for if impl in let loop match mod move mut pub ref return self Self static struct super trait true type unsafe use where while' },
296
+ java: { c: true, kw: 'abstract assert boolean break byte case catch char class const continue default do double else enum extends final finally float for goto if implements import instanceof int interface long native new null package private protected public return short static super switch synchronized this throw throws transient true false try void volatile while var record sealed' },
297
+ c: { c: true, kw: 'auto break case char const continue default do double else enum extern float for goto if inline int long register restrict return short signed sizeof static struct switch typedef union unsigned void volatile while bool true false NULL' },
298
+ cpp: { c: true, kw: 'alignas alignof auto bool break case catch char class const constexpr continue decltype default delete do double else enum explicit export extern false float for friend goto if inline int long mutable namespace new noexcept nullptr operator private protected public register return short signed sizeof static struct switch template this throw true try typedef typename union unsigned using virtual void volatile while' },
299
+ csharp: { c: true, kw: 'abstract as base bool break byte case catch char checked class const continue decimal default delegate do double else enum event explicit extern false finally fixed float for foreach goto if implicit in int interface internal is lock long namespace new null object operator out override params private protected public readonly ref return sbyte sealed short sizeof static string struct switch this throw true try typeof uint ulong unchecked unsafe ushort using var virtual void volatile while async await yield' },
300
+ ruby: { hash: true, kw: 'alias and begin break case class def defined do else elsif end ensure false for if in module next nil not or redo rescue retry return self super then true unless until when while yield require attr_accessor' },
301
+ php: { c: true, hash: true, kw: 'abstract and array as break callable case catch class clone const continue declare default do echo else elseif empty enddeclare endfor endforeach endif endswitch endwhile enum extends false final finally fn for foreach function global goto if implements include instanceof insteadof interface isset list match namespace new null or print private protected public readonly return static switch throw trait true try unset use var while xor yield' },
302
+ swift: { c: true, kw: 'associatedtype class deinit enum extension fileprivate func import init inout internal let open operator private protocol public rethrows static struct subscript typealias var break case continue default defer do else fallthrough for guard if in repeat return switch where while as catch false is nil super self throw throws true try' },
303
+ kotlin: { c: true, kw: 'abstract actual annotation as break by catch class companion const constructor continue crossinline data delegate do dynamic else enum external false final finally for fun get if import in infix init inline inner interface internal is lateinit lazy null object open operator out override package private protected public reified return sealed set super suspend this throw true try typealias val var vararg when where while' },
304
+ yaml: { hash: true, kw: 'true false null yes no on off' },
305
+ toml: { hash: true, kw: 'true false' },
306
+ sql: { c: true, sql: true, kwi: true, kw: 'select from where insert into values update set delete create table alter drop index view join inner left right outer full on as and or not null is in like between group by order having limit offset union all distinct count sum avg min max case when then else end primary key foreign references default unique constraint cascade asc desc returning with' },
307
+ css: { special: 'css' },
308
+ html: { special: 'html' },
309
+ };
310
+ const LANG_ALIAS = {
311
+ javascript: 'js', node: 'js', mjs: 'js', cjs: 'js', jsx: 'js',
312
+ typescript: 'ts', tsx: 'ts',
313
+ shell: 'sh', bash: 'sh', zsh: 'sh', console: 'sh',
314
+ python: 'py', py3: 'py',
315
+ golang: 'go', rs: 'rust', 'c++': 'cpp', cc: 'cpp', cxx: 'cpp', hpp: 'cpp', 'c#': 'csharp', cs: 'csharp',
316
+ rb: 'ruby', kt: 'kotlin',
317
+ yml: 'yaml', xml: 'html', svg: 'html', htm: 'html',
318
+ jsonc: 'json', json5: 'json',
232
319
  };
233
320
 
234
321
  function tintCode(code, lang) {
235
- const langKey = (lang || '').toLowerCase();
236
- const alias = { javascript: 'js', typescript: 'ts', shell: 'sh', bash: 'sh', python: 'py' };
237
- const key = alias[langKey] || langKey;
238
- if (!Object.prototype.hasOwnProperty.call(KEYWORDS, key)) {
239
- return esc(code); // unknown lang -> plain
240
- }
241
- // Single-pass tokenizer over the RAW source: one alternation regex, each
242
- // match escaped + wrapped as it is emitted. No placeholders — placeholder
243
- // text gets re-tokenized by later passes and corrupts the output.
244
- const kws = (KEYWORDS[key] || '').split(' ').filter(Boolean);
245
- const kwAlt = kws.length ? kws.join('|') : 'A\\bB'; // alternation that never matches
246
- let rx;
247
- let classes;
248
- if (key === 'css') {
249
- rx = new RegExp(
250
- /(\/\*[\s\S]*?\*\/)/.source +
251
- '|' + /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/.source +
252
- '|' + /([A-Za-z-]+(?=\s*:))/.source +
253
- '|' + /(\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms|deg)?\b)/.source,
254
- 'g'
255
- );
256
- classes = ['com', 'str', 'kw', 'num'];
257
- } else if (key === 'html') {
258
- rx = new RegExp(
259
- /(<!--[\s\S]*?-->)/.source +
260
- '|' + /(<\/?[A-Za-z][\w-]*|\/?>)/.source +
261
- '|' + /("[^"]*")/.source,
262
- 'g'
263
- );
264
- classes = ['com', 'kw', 'str'];
322
+ const key0 = (lang || '').toLowerCase().trim();
323
+ const cfg = LANGS[LANG_ALIAS[key0] || key0];
324
+ if (!cfg) return esc(code); // unknown lang -> plain
325
+
326
+ // Build ONE alternation regex with a parallel classes[] so group g (1-based)
327
+ // maps to classes[g-1]. Each pushed source is wrapped in exactly one
328
+ // capturing group; the source bodies use only non-capturing groups.
329
+ const parts = [];
330
+ const classes = [];
331
+ const push = (src, cls) => { parts.push('(' + src + ')'); classes.push(cls); };
332
+ if (cfg.special === 'css') {
333
+ push(/\/\*[\s\S]*?\*\//.source, 'com');
334
+ push(/"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/.source, 'str');
335
+ push(/[A-Za-z-]+(?=\s*:)/.source, 'kw');
336
+ push(/\b\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|s|ms|deg)?\b/.source, 'num');
337
+ } else if (cfg.special === 'html') {
338
+ push(/<!--[\s\S]*?-->/.source, 'com');
339
+ push(/<\/?[A-Za-z][\w-]*|\/?>/.source, 'kw');
340
+ push(/"[^"]*"/.source, 'str');
265
341
  } else {
266
- const comment = key === 'py' || key === 'sh'
267
- ? /(#[^\n]*)/.source
268
- : /(\/\/[^\n]*|\/\*[\s\S]*?\*\/)/.source;
269
- const str = key === 'py' || key === 'sh'
270
- ? /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/.source
271
- : /("(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`)/.source;
272
- rx = new RegExp(
273
- comment +
274
- '|' + str +
275
- '|' + /(\b(?:0x[\da-fA-F]+|\d+(?:\.\d+)?)\b)/.source +
276
- '|(\\b(?:' + kwAlt + ')\\b)',
277
- 'g'
278
- );
279
- classes = ['com', 'str', 'num', 'kw'];
342
+ if (cfg.c) { push(/\/\*[\s\S]*?\*\//.source, 'com'); push(/\/\/[^\n]*/.source, 'com'); }
343
+ if (cfg.hash) push(/#[^\n]*/.source, 'com');
344
+ if (cfg.sql) push(/--[^\n]*/.source, 'com');
345
+ push((cfg.tmpl
346
+ ? /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/
347
+ : /"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/).source, 'str');
348
+ push(/\b(?:0x[\da-fA-F]+|\d+(?:\.\d+)?)\b/.source, 'num');
349
+ let kws = (cfg.kw || '').split(' ').filter(Boolean);
350
+ if (cfg.kwi) kws = kws.concat(kws.map((k) => k.toUpperCase())); // SQL: also UPPERCASE
351
+ if (kws.length) push('\\b(?:' + kws.join('|') + ')\\b', 'kw');
280
352
  }
353
+ const rx = new RegExp(parts.join('|'), 'g');
281
354
  let out = '';
282
355
  let last = 0;
283
356
  let m;
@@ -298,11 +371,34 @@
298
371
  return out;
299
372
  }
300
373
 
374
+ // A line-number gutter element for `text` (aria-hidden + non-selectable so a
375
+ // select-to-comment grabs only the code, not the numbers). `startAt` default 1.
376
+ function lineGutter(text, startAt) {
377
+ const n = Math.max(1, String(text).replace(/\n+$/, '').split('\n').length);
378
+ const g = el('div', { class: 'blk-gutter', 'aria-hidden': 'true' });
379
+ let s = '';
380
+ for (let i = 0; i < n; i++) s += (startAt || 1) + i + '\n';
381
+ g.textContent = s;
382
+ return g;
383
+ }
384
+
301
385
  function renderCode(block, ctx, blockId) {
386
+ // Drop a single trailing newline so the last source line isn't rendered as
387
+ // an empty row the gutter has no number for (files usually end in "\n").
388
+ const raw = (block.code || '').replace(/\n$/, '');
302
389
  const code = el('code');
303
- code.innerHTML = tintCode(block.code || '', block.lang);
390
+ code.innerHTML = tintCode(raw, block.lang);
304
391
  const pre = el('pre', { class: 'blk-pre', 'data-lang': block.lang || '' }, code);
305
- const wrap = el('div', { class: 'blk-codewrap' }, pre);
392
+ const row = el('div', { class: 'blk-coderow' }, lineGutter(raw), pre);
393
+ const wrap = el('div', { class: 'blk-codewrap' });
394
+ // Optional file-name + language header above the code.
395
+ if (block.filename || block.lang) {
396
+ wrap.append(el('div', { class: 'blk-codehead' },
397
+ el('span', { class: 'blk-codename' }, block.filename || ''),
398
+ el('span', { class: 'blk-codelang' }, block.lang || '')
399
+ ));
400
+ }
401
+ wrap.append(row);
306
402
  // select-to-comment on the code text (like markdown), plus a whole-block
307
403
  // comment + full-screen via the shared viewer toolbar
308
404
  ctx && ctx.annotate && ctx.annotate.enableTextSelection(pre, { blockId, questionId: ctx.questionId });
@@ -310,6 +406,189 @@
310
406
  return wrap;
311
407
  }
312
408
 
409
+ // ---------- diff (unified / git diff) ----------
410
+ // No git is involved — the agent supplies the diff text. Parsed once into
411
+ // rows, then rendered either UNIFIED (one column, +/- signs) or SPLIT (old |
412
+ // new side-by-side). A header toggle flips between the two live; the block's
413
+ // "view" sets the initial mode. Each code line is tinted by lang.
414
+
415
+ // Parse a unified diff into a flat row list: {kind, text, oldNo, newNo}
416
+ // (line numbers stored 0-based; rendered +1).
417
+ function parseDiff(text) {
418
+ const lines = String(text || '').replace(/\r\n?/g, '\n').split('\n');
419
+ const rows = [];
420
+ let oldNo = 0;
421
+ let newNo = 0;
422
+ for (const line of lines) {
423
+ if (/^@@/.test(line)) {
424
+ const m = line.match(/@@\s*-(\d+)(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s*@@/);
425
+ if (m) { oldNo = Number(m[1]) - 1; newNo = Number(m[2]) - 1; }
426
+ rows.push({ kind: 'hunk', text: line });
427
+ } else if (/^(diff |index |--- |\+\+\+ |new file|deleted file|old mode|new mode|similarity|rename |copy )/.test(line)) {
428
+ rows.push({ kind: 'meta', text: line });
429
+ } else if (line[0] === '+') {
430
+ rows.push({ kind: 'add', text: line.slice(1), newNo: newNo++ });
431
+ } else if (line[0] === '-') {
432
+ rows.push({ kind: 'del', text: line.slice(1), oldNo: oldNo++ });
433
+ } else {
434
+ rows.push({ kind: 'ctx', text: line[0] === ' ' ? line.slice(1) : line, oldNo: oldNo++, newNo: newNo++ });
435
+ }
436
+ }
437
+ return rows;
438
+ }
439
+
440
+ function diffNoTd(n, extraClass) {
441
+ const td = el('td', { class: 'diff-no' + (extraClass ? ' ' + extraClass : ''), 'aria-hidden': 'true' });
442
+ if (n !== undefined && n !== null) td.textContent = String(n + 1);
443
+ return td;
444
+ }
445
+ function diffCodeTd(html, extraClass) {
446
+ const c = el('code');
447
+ c.innerHTML = html || ' ';
448
+ return el('td', { class: 'diff-code' + (extraClass ? ' ' + extraClass : '') }, c);
449
+ }
450
+
451
+ // Unified: oldNo | newNo | sign | code
452
+ function buildUnifiedDiff(rows, lang) {
453
+ const tbody = el('tbody');
454
+ for (const r of rows) {
455
+ const full = r.kind === 'hunk' || r.kind === 'meta';
456
+ const codeHtml = full ? esc(r.text) : tintCode(r.text, lang);
457
+ const sign = r.kind === 'add' ? '+' : r.kind === 'del' ? '−' : '';
458
+ tbody.append(el('tr', { class: 'diff-row diff-' + r.kind },
459
+ diffNoTd(r.oldNo),
460
+ diffNoTd(r.newNo),
461
+ el('td', { class: 'diff-sign', 'aria-hidden': 'true' }, sign),
462
+ diffCodeTd(codeHtml)
463
+ ));
464
+ }
465
+ return el('table', { class: 'blk-difftable' }, tbody);
466
+ }
467
+
468
+ // Split: oldNo | old code || newNo | new code. A run of removed lines is
469
+ // paired row-by-row with the following run of added lines; the shorter side
470
+ // gets filler cells. Context shows identically on both sides.
471
+ function buildSplitDiff(rows, lang) {
472
+ const tbody = el('tbody');
473
+ const fullRow = (cls, text) => {
474
+ const c = el('code');
475
+ c.innerHTML = esc(text) || ' ';
476
+ tbody.append(el('tr', { class: 'diff-row diff-' + cls }, el('td', { class: 'diff-code', colspan: '4' }, c)));
477
+ };
478
+ let dels = [];
479
+ let adds = [];
480
+ const flush = () => {
481
+ const n = Math.max(dels.length, adds.length);
482
+ for (let i = 0; i < n; i++) {
483
+ const d = dels[i];
484
+ const a = adds[i];
485
+ const left = d
486
+ ? [diffNoTd(d.oldNo, 'diff-del'), diffCodeTd(tintCode(d.text, lang), 'diff-del')]
487
+ : [diffNoTd(null, 'diff-fill'), diffCodeTd(' ', 'diff-fill')];
488
+ const right = a
489
+ ? [diffNoTd(a.newNo, 'diff-newside diff-add'), diffCodeTd(tintCode(a.text, lang), 'diff-add')]
490
+ : [diffNoTd(null, 'diff-fill diff-newside'), diffCodeTd(' ', 'diff-fill')];
491
+ tbody.append(el('tr', { class: 'diff-row' }, left[0], left[1], right[0], right[1]));
492
+ }
493
+ dels = [];
494
+ adds = [];
495
+ };
496
+ for (const r of rows) {
497
+ if (r.kind === 'del') { dels.push(r); continue; }
498
+ if (r.kind === 'add') { adds.push(r); continue; }
499
+ flush();
500
+ if (r.kind === 'hunk') fullRow('hunk', r.text);
501
+ else if (r.kind === 'meta') fullRow('meta', r.text);
502
+ else {
503
+ const html = tintCode(r.text, lang);
504
+ tbody.append(el('tr', { class: 'diff-row diff-ctx' },
505
+ diffNoTd(r.oldNo), diffCodeTd(html),
506
+ diffNoTd(r.newNo, 'diff-newside'), diffCodeTd(html)
507
+ ));
508
+ }
509
+ }
510
+ flush();
511
+ return el('table', { class: 'blk-difftable blk-difftable-split' }, tbody);
512
+ }
513
+
514
+ function renderDiff(block, ctx, blockId) {
515
+ const rows = parseDiff(block.diff);
516
+ let view = block.view === 'split' ? 'split' : 'unified';
517
+ const scroll = el('div', { class: 'blk-diffscroll' });
518
+ const paint = () => {
519
+ scroll.innerHTML = '';
520
+ scroll.append(view === 'split' ? buildSplitDiff(rows, block.lang) : buildUnifiedDiff(rows, block.lang));
521
+ };
522
+ paint();
523
+
524
+ const wrap = el('div', { class: 'blk-codewrap blk-diffwrap' });
525
+ // header: file name (left) + a Unified/Split view toggle (right)
526
+ const toggle = el('button', { class: 'blk-difftoggle', type: 'button', title: 'Toggle side-by-side view' });
527
+ const syncToggle = () => { toggle.textContent = view === 'split' ? 'Unified view' : 'Split view'; };
528
+ syncToggle();
529
+ toggle.addEventListener('mousedown', (e) => e.stopPropagation());
530
+ toggle.addEventListener('click', (e) => {
531
+ e.preventDefault();
532
+ e.stopPropagation();
533
+ view = view === 'split' ? 'unified' : 'split';
534
+ wrap.classList.toggle('is-split', view === 'split');
535
+ syncToggle();
536
+ paint();
537
+ });
538
+ wrap.append(el('div', { class: 'blk-codehead' },
539
+ el('span', { class: 'blk-codename' }, block.filename || ''),
540
+ toggle
541
+ ));
542
+ wrap.classList.toggle('is-split', view === 'split');
543
+ wrap.append(scroll);
544
+ // select-to-comment stays bound to the stable scroll container across toggles
545
+ ctx && ctx.annotate && ctx.annotate.enableTextSelection(scroll, { blockId, questionId: ctx.questionId });
546
+ attachViewer(wrap, { zoomEl: null, label: 'diff', comment: wholeBlockComment(ctx, blockId, 'diff') });
547
+ return wrap;
548
+ }
549
+ // ---------- video (YouTube/Vimeo embed, local stream, or direct URL) ----------
550
+ function renderVideo(block, ctx, blockId) {
551
+ const wrap = el('div', { class: 'blk-videowrap' });
552
+ let media;
553
+ if (block.provider === 'youtube') {
554
+ const q = block.start ? '?start=' + block.start : '';
555
+ media = el('iframe', {
556
+ class: 'blk-video-embed',
557
+ src: 'https://www.youtube-nocookie.com/embed/' + encodeURIComponent(block.videoId) + q,
558
+ title: block.title || 'YouTube video',
559
+ frameborder: '0',
560
+ allow: 'accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share',
561
+ referrerpolicy: 'strict-origin-when-cross-origin',
562
+ allowfullscreen: 'true',
563
+ loading: 'lazy',
564
+ });
565
+ } else if (block.provider === 'vimeo') {
566
+ media = el('iframe', {
567
+ class: 'blk-video-embed',
568
+ src: 'https://player.vimeo.com/video/' + encodeURIComponent(block.videoId),
569
+ title: block.title || 'Vimeo video',
570
+ frameborder: '0',
571
+ allow: 'autoplay; fullscreen; picture-in-picture',
572
+ allowfullscreen: 'true',
573
+ loading: 'lazy',
574
+ });
575
+ } else {
576
+ // local file (served via /video/b/<id>) or a direct http(s) media URL
577
+ const src = block.hasFile ? '/video/b/' + encodeURIComponent(blockId) : block.src;
578
+ media = el('video', { class: 'blk-video', controls: 'true', preload: 'metadata', playsinline: 'true' });
579
+ if (block.title) media.setAttribute('title', block.title);
580
+ const source = el('source', { src });
581
+ if (block.mime) source.setAttribute('type', block.mime);
582
+ media.append(source);
583
+ media.append(document.createTextNode('Your browser cannot play this video.'));
584
+ }
585
+ wrap.append(media);
586
+ if (block.title) wrap.append(el('div', { class: 'blk-videocap' }, block.title));
587
+ // whole-block comment + the comment pin (no zoom/full-screen for media)
588
+ attachViewer(wrap, { zoomEl: null, label: 'video', comment: wholeBlockComment(ctx, blockId, 'video') });
589
+ return wrap;
590
+ }
591
+
313
592
  // ---------- table ----------
314
593
  function normalizeColumns(columns) {
315
594
  return (columns || []).map((c, idx) => {
@@ -1429,6 +1708,14 @@
1429
1708
  inner = renderCode(block, ctx, blockId);
1430
1709
  wrapper.append(inner);
1431
1710
  break;
1711
+ case 'diff':
1712
+ inner = renderDiff(block, ctx, blockId);
1713
+ wrapper.append(inner);
1714
+ break;
1715
+ case 'video':
1716
+ inner = renderVideo(block, ctx, blockId);
1717
+ wrapper.append(inner);
1718
+ break;
1432
1719
  case 'chart':
1433
1720
  inner = renderChart(block, ctx, blockId);
1434
1721
  wrapper.append(inner);
@@ -1479,5 +1766,65 @@
1479
1766
  }
1480
1767
  }
1481
1768
 
1482
- window.RelayBlocks = { render, onThemeChange };
1769
+ // ---------- file-link open behavior ----------
1770
+ // A small toast pinned top-center (reuses the .toast style from style.css).
1771
+ // tone 'err' adds .toast-err so failures read as a problem, not a success.
1772
+ function fileToast(message, tone) {
1773
+ const t = el('div', { class: 'toast' + (tone === 'err' ? ' toast-err' : '') }, message);
1774
+ document.body.append(t);
1775
+ setTimeout(() => t.remove(), 3500);
1776
+ }
1777
+
1778
+ // POST the clicked path to /api/open; the server opens it in the OS default
1779
+ // app. The link is disabled mid-flight so a double-click can't double-open.
1780
+ async function openFilePath(a) {
1781
+ const p = a.dataset.rlyOpen;
1782
+ if (!p || a.classList.contains('is-opening')) return;
1783
+ a.classList.add('is-opening');
1784
+ try {
1785
+ const r = await fetch('/api/open', {
1786
+ method: 'POST',
1787
+ headers: { 'content-type': 'application/json' },
1788
+ body: JSON.stringify({ path: p }),
1789
+ });
1790
+ let data = {};
1791
+ try {
1792
+ data = await r.json();
1793
+ } catch {
1794
+ data = {};
1795
+ }
1796
+ if (r.ok && data.ok) fileToast('Opened ' + (data.name || p));
1797
+ else fileToast((data.error || 'Could not open') + ' — ' + p, 'err');
1798
+ } catch {
1799
+ fileToast('Could not reach the server to open ' + p, 'err');
1800
+ } finally {
1801
+ a.classList.remove('is-opening');
1802
+ }
1803
+ }
1804
+
1805
+ // Delegated once on the document so it catches file-links anywhere they are
1806
+ // rendered — the intro, any markdown block, or a markdown pipe-table cell.
1807
+ // Keyboard-activatable (Enter/Space) since the links are role="link" anchors
1808
+ // with no href (the open happens via fetch, not navigation).
1809
+ function initFileLinks() {
1810
+ if (window.__relayFileLinksReady) return;
1811
+ window.__relayFileLinksReady = true;
1812
+ const hit = (e) => (e.target.closest ? e.target.closest('a.rly-filelink') : null);
1813
+ document.addEventListener('click', (e) => {
1814
+ const a = hit(e);
1815
+ if (!a) return;
1816
+ e.preventDefault();
1817
+ openFilePath(a);
1818
+ });
1819
+ document.addEventListener('keydown', (e) => {
1820
+ if (e.key !== 'Enter' && e.key !== ' ' && e.key !== 'Spacebar') return;
1821
+ const a = hit(e);
1822
+ if (!a) return;
1823
+ e.preventDefault();
1824
+ openFilePath(a);
1825
+ });
1826
+ }
1827
+ initFileLinks();
1828
+
1829
+ window.RelayBlocks = { render, onThemeChange, renderMarkdown };
1483
1830
  })();
package/src/ui/style.css CHANGED
@@ -87,7 +87,12 @@ h1 {
87
87
  line-height: 1.25;
88
88
  text-wrap: balance;
89
89
  }
90
- .intro { color: var(--fg-2); white-space: pre-wrap; margin: 10px 0 18px; }
90
+ .intro { color: var(--fg-2); margin: 10px 0 18px; }
91
+ /* the intro renders markdown; keep it muted (out-specificity .blk-markdown .md)
92
+ and trim its block margins so it reads as a lead paragraph, not a content block */
93
+ .intro.blk-markdown .md { color: var(--fg-2); }
94
+ .intro.blk-markdown .md > :first-child { margin-top: 0; }
95
+ .intro.blk-markdown .md > :last-child { margin-bottom: 0; }
91
96
  .theme-btn {
92
97
  background: transparent; color: var(--fg-2);
93
98
  border: 1px solid var(--border-strong); border-radius: 999px;
@@ -262,6 +267,47 @@ textarea { min-height: 90px; resize: vertical; }
262
267
  .scale button { width: 40px; height: 40px; }
263
268
  }
264
269
 
270
+ /* Persistence-lost block: when the board can no longer save the user's input
271
+ (server gone / port taken / machine slept) AND recovery probes have failed,
272
+ we disable every control and overlay this unmissable scrim so the user is
273
+ physically prevented from typing feedback that would be silently discarded.
274
+ Sits above everything (full-screen overlay z 49 / popover / rail / toast). */
275
+ .relay-blocked #app { filter: grayscale(0.4) blur(1px); pointer-events: none; user-select: none; }
276
+ .lost-overlay {
277
+ position: fixed; inset: 0; z-index: 60;
278
+ display: flex; align-items: center; justify-content: center;
279
+ padding: 24px;
280
+ background: color-mix(in srgb, var(--bg) 78%, transparent);
281
+ backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
282
+ }
283
+ .lost-card {
284
+ max-width: 460px; width: 100%;
285
+ background: var(--card); color: var(--fg);
286
+ border: 1px solid var(--border-strong); border-radius: 14px;
287
+ box-shadow: var(--shadow-lift);
288
+ padding: 28px 26px; text-align: center;
289
+ }
290
+ .lost-card .lost-mark {
291
+ width: 52px; height: 52px; border-radius: 50%;
292
+ background: var(--danger); color: var(--danger-fg);
293
+ font-size: 1.6rem; line-height: 52px; margin: 0 auto 16px;
294
+ }
295
+ .lost-card h2 {
296
+ font-family: var(--serif); font-weight: 500; letter-spacing: -0.015em;
297
+ font-size: 1.3rem; margin: 0 0 10px;
298
+ }
299
+ .lost-card p { color: var(--fg-2); font-size: 0.92rem; line-height: 1.55; margin: 0 0 8px; }
300
+ .lost-card .lost-sub { color: var(--muted); font-size: 0.82rem; margin: 12px 0 18px; }
301
+ .lost-card .lost-retry {
302
+ background: var(--accent); color: var(--accent-fg);
303
+ border: none; border-radius: 10px;
304
+ padding: 11px 26px; font: inherit; font-size: 0.95rem; font-weight: 600; cursor: pointer;
305
+ transition: background 150ms var(--ease);
306
+ }
307
+ .lost-card .lost-retry:hover { background: var(--accent-hover); }
308
+ .lost-card .lost-retry:disabled { opacity: 0.6; cursor: default; }
309
+ .lost-card .lost-retry:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
310
+
265
311
  @media (prefers-reduced-motion: reduce) {
266
312
  * { transition: none !important; animation: none !important; }
267
313
  }