@commentray/render 0.0.5 → 0.0.8

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.
Files changed (59) hide show
  1. package/dist/browse-page-slug.d.ts +6 -0
  2. package/dist/browse-page-slug.d.ts.map +1 -0
  3. package/dist/browse-page-slug.js +9 -0
  4. package/dist/browse-page-slug.js.map +1 -0
  5. package/dist/build-commentray-nav-search.d.ts +11 -5
  6. package/dist/build-commentray-nav-search.d.ts.map +1 -1
  7. package/dist/build-commentray-nav-search.js +11 -11
  8. package/dist/build-commentray-nav-search.js.map +1 -1
  9. package/dist/code-browser-block-rays.d.ts +48 -0
  10. package/dist/code-browser-block-rays.d.ts.map +1 -0
  11. package/dist/code-browser-block-rays.js +95 -0
  12. package/dist/code-browser-block-rays.js.map +1 -0
  13. package/dist/code-browser-client.bundle.js +12 -12
  14. package/dist/code-browser-client.js +1106 -145
  15. package/dist/code-browser-client.js.map +1 -1
  16. package/dist/code-browser-color-theme.d.ts +15 -0
  17. package/dist/code-browser-color-theme.d.ts.map +1 -0
  18. package/dist/code-browser-color-theme.js +73 -0
  19. package/dist/code-browser-color-theme.js.map +1 -0
  20. package/dist/code-browser-pair-nav.d.ts +31 -0
  21. package/dist/code-browser-pair-nav.d.ts.map +1 -0
  22. package/dist/code-browser-pair-nav.js +77 -0
  23. package/dist/code-browser-pair-nav.js.map +1 -0
  24. package/dist/code-browser-scroll-sync.js +1 -1
  25. package/dist/code-browser-scroll-sync.js.map +1 -1
  26. package/dist/code-browser-search.d.ts +45 -0
  27. package/dist/code-browser-search.d.ts.map +1 -1
  28. package/dist/code-browser-search.js +89 -0
  29. package/dist/code-browser-search.js.map +1 -1
  30. package/dist/code-browser.d.ts +24 -3
  31. package/dist/code-browser.d.ts.map +1 -1
  32. package/dist/code-browser.js +1202 -288
  33. package/dist/code-browser.js.map +1 -1
  34. package/dist/hljs-stylesheet-themes.d.ts +13 -0
  35. package/dist/hljs-stylesheet-themes.d.ts.map +1 -0
  36. package/dist/hljs-stylesheet-themes.js +19 -0
  37. package/dist/hljs-stylesheet-themes.js.map +1 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +1 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/inline-favicon.d.ts +2 -0
  43. package/dist/inline-favicon.d.ts.map +1 -0
  44. package/dist/inline-favicon.js +25 -0
  45. package/dist/inline-favicon.js.map +1 -0
  46. package/dist/markdown-pipeline.d.ts.map +1 -1
  47. package/dist/markdown-pipeline.js +38 -2
  48. package/dist/markdown-pipeline.js.map +1 -1
  49. package/dist/mermaid-runtime-html.d.ts.map +1 -1
  50. package/dist/mermaid-runtime-html.js +13 -3
  51. package/dist/mermaid-runtime-html.js.map +1 -1
  52. package/dist/package-version.d.ts.map +1 -1
  53. package/dist/package-version.js +4 -4
  54. package/dist/package-version.js.map +1 -1
  55. package/dist/side-by-side-layout.css +58 -0
  56. package/dist/side-by-side.d.ts.map +1 -1
  57. package/dist/side-by-side.js +10 -12
  58. package/dist/side-by-side.js.map +1 -1
  59. package/package.json +2 -2
@@ -1,15 +1,224 @@
1
1
  import { FuzzySearcher, PrefixSearcher, Query, SearcherFactory, SubstringSearcher, } from "@m31coding/fuzzy-search";
2
+ import { activeBlockIdForViewport, clampViewportYToGutterLocal, codeLineDomIndex0, gutterRayBezierPaths, sortBlockLinksBySource, } from "./code-browser-block-rays.js";
2
3
  import { mirroredScrollTop, pickCommentrayLineForSourceScroll, pickSourceLine0ForCommentrayScroll, } from "./code-browser-scroll-sync.js";
3
4
  import { decodeBase64Utf8 } from "./code-browser-encoding.js";
4
5
  import { readEmbeddedRawB64Strings } from "./code-browser-embedded-payload.js";
5
- import { escapeHtmlHighlightingSearchTokens, findOrderedTokenSpans, lineAtIndex, offsetToLineIndex, } from "./code-browser-search.js";
6
+ import { escapeHtmlHighlightingSearchTokens, filterPairsByDocumentedTreeQuery, findOrderedTokenSpans, lineAtIndex, offsetToLineIndex, pathRowsFromDocumentedPairs, tokenizeQuery, uniqueSourceFilePreviewRows, } from "./code-browser-search.js";
7
+ import { findDocumentedPair, isHubRelativeStaticBrowseHref, isSameDocumentedPair, normPosixPath, resolveStaticBrowseHref, staticBrowseHrefForShellDataAttribute, } from "./code-browser-pair-nav.js";
8
+ import { COMMENTRAY_COLOR_THEME_STORAGE_KEY, applyCommentrayColorTheme, nextCommentrayColorThemeMode, parseCommentrayColorThemeMode, } from "./code-browser-color-theme.js";
6
9
  import { readWebStorageItem, writeWebStorageItem } from "./code-browser-web-storage.js";
7
- function tokenizeQuery(q) {
8
- return q.trim().split(/\s+/).filter(Boolean);
10
+ /**
11
+ * Hub pages emit `./browse/…` relative to the site root. From `/…/browse/current.html` the browser
12
+ * would otherwise resolve that to `…/browse/browse/…`.
13
+ */
14
+ function rewriteHubRelativeBrowseAnchorsIn(root) {
15
+ const path = globalThis.location.pathname;
16
+ const origin = globalThis.location.origin;
17
+ for (const el of Array.from(root.querySelectorAll("a[href]"))) {
18
+ if (!(el instanceof HTMLAnchorElement))
19
+ continue;
20
+ const raw = el.getAttribute("href")?.trim() ?? "";
21
+ if (!isHubRelativeStaticBrowseHref(raw))
22
+ continue;
23
+ el.href = resolveStaticBrowseHref(raw, path, origin);
24
+ }
25
+ }
26
+ function runMermaidOnFreshDocNodes(docBody) {
27
+ if (typeof globalThis.location !== "undefined" && globalThis.location.protocol === "file:")
28
+ return;
29
+ /** Only fenced diagram sources; Mermaid leaves other `.mermaid` nodes in the tree after render. */
30
+ const allPres = Array.from(docBody.querySelectorAll("pre.mermaid"));
31
+ /** Do not re-run on wrappers that already have SVG (avoids corrupting output after dual-mobile pane flip). */
32
+ const nodes = allPres.filter((pre) => {
33
+ const wrap = pre.closest(".commentray-mermaid");
34
+ return wrap === null || wrap.querySelector("svg") === null;
35
+ });
36
+ if (nodes.length === 0)
37
+ return;
38
+ const m = globalThis
39
+ .commentrayMermaid;
40
+ if (!m)
41
+ return;
42
+ void m.run({ nodes }).catch((err) => {
43
+ console.error("Commentray: mermaid.run failed", err);
44
+ });
9
45
  }
10
46
  function clamp(n, lo, hi) {
11
47
  return Math.max(lo, Math.min(hi, n));
12
48
  }
49
+ /** Y offset in `scrollEl`’s scroll coordinate space to place `child`’s top at the scrollport (CSS px). */
50
+ function scrollTopToAlignChildTop(scrollEl, child, leadCssPx) {
51
+ const cr = child.getBoundingClientRect();
52
+ const sr = scrollEl.getBoundingClientRect();
53
+ return scrollEl.scrollTop + (cr.top - sr.top) - scrollEl.clientTop - leadCssPx;
54
+ }
55
+ /** Avoid feedback loops when sub-pixel math matches the current position (common with browser zoom). */
56
+ function applyScrollTopClamped(scrollEl, nextTop) {
57
+ const maxY = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
58
+ const clamped = clamp(nextTop, 0, maxY);
59
+ if (Math.abs(scrollEl.scrollTop - clamped) < 0.25)
60
+ return;
61
+ scrollEl.scrollTop = clamped;
62
+ }
63
+ function paneUsesInternalYScroll(el) {
64
+ const max = el.scrollHeight - el.clientHeight;
65
+ if (max <= 1)
66
+ return false;
67
+ const oy = getComputedStyle(el).overflowY;
68
+ return oy === "auto" || oy === "scroll" || oy === "overlay";
69
+ }
70
+ function rootScrollingElement() {
71
+ const s = document.scrollingElement;
72
+ if (s instanceof HTMLElement)
73
+ return s;
74
+ return document.documentElement;
75
+ }
76
+ function applyWindowScrollRatio(ratio) {
77
+ const root = rootScrollingElement();
78
+ const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
79
+ const next = clamp(ratio * maxY, 0, maxY);
80
+ if (Math.abs(root.scrollTop - next) < 0.25)
81
+ return;
82
+ root.scrollTop = next;
83
+ }
84
+ /**
85
+ * Reveal `child` near the top of the reading surface: the pane’s own scrollport when it scrolls
86
+ * internally (desktop dual-pane), otherwise the document root (narrow flow layout).
87
+ */
88
+ function applyRevealChildInPane(scrollport, child, leadCssPx) {
89
+ if (paneUsesInternalYScroll(scrollport)) {
90
+ applyScrollTopClamped(scrollport, Math.round(scrollTopToAlignChildTop(scrollport, child, leadCssPx)));
91
+ return;
92
+ }
93
+ const root = rootScrollingElement();
94
+ const cr = child.getBoundingClientRect();
95
+ const targetTop = globalThis.scrollY + cr.top - leadCssPx;
96
+ const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
97
+ const clamped = clamp(targetTop, 0, maxY);
98
+ if (Math.abs(root.scrollTop - clamped) < 0.25)
99
+ return;
100
+ root.scrollTop = clamped;
101
+ }
102
+ function windowScrollRatio() {
103
+ const root = rootScrollingElement();
104
+ const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
105
+ return maxY > 0 ? clamp(root.scrollTop / maxY, 0, 1) : 0;
106
+ }
107
+ function applyDocToCodeFlipPlanImpl(codePane, _docPane, plan) {
108
+ if (plan.k === "block") {
109
+ const el = codePane.querySelector(`#code-line-${String(plan.src0)}`);
110
+ if (el) {
111
+ applyRevealChildInPane(codePane, el, 2);
112
+ }
113
+ else {
114
+ applyWindowScrollRatio(plan.winRatio);
115
+ }
116
+ return;
117
+ }
118
+ if (plan.k === "mirrorW") {
119
+ if (paneUsesInternalYScroll(codePane)) {
120
+ const maxC = Math.max(0, codePane.scrollHeight - codePane.clientHeight);
121
+ applyScrollTopClamped(codePane, plan.ratio * maxC);
122
+ }
123
+ else {
124
+ applyWindowScrollRatio(plan.ratio);
125
+ }
126
+ return;
127
+ }
128
+ const nextTop = mirroredScrollTop(plan.docTop, plan.docSH, plan.docCH, codePane.scrollHeight, codePane.clientHeight);
129
+ if (paneUsesInternalYScroll(codePane)) {
130
+ applyScrollTopClamped(codePane, nextTop);
131
+ return;
132
+ }
133
+ const denom = Math.max(1, codePane.scrollHeight - codePane.clientHeight);
134
+ applyWindowScrollRatio(clamp(nextTop / denom, 0, 1));
135
+ }
136
+ function applyCodeToDocFlipPlanImpl(_codePane, docPane, plan) {
137
+ if (plan.k === "block") {
138
+ const anchor = docPane.querySelector(`[data-commentray-line="${String(plan.mdLine0)}"]`);
139
+ if (anchor instanceof HTMLElement) {
140
+ applyRevealChildInPane(docPane, anchor, 2);
141
+ }
142
+ else {
143
+ applyWindowScrollRatio(plan.winRatio);
144
+ }
145
+ return;
146
+ }
147
+ if (plan.k === "mirrorW") {
148
+ if (paneUsesInternalYScroll(docPane)) {
149
+ const maxD = Math.max(0, docPane.scrollHeight - docPane.clientHeight);
150
+ applyScrollTopClamped(docPane, plan.ratio * maxD);
151
+ }
152
+ else {
153
+ applyWindowScrollRatio(plan.ratio);
154
+ }
155
+ return;
156
+ }
157
+ const nextTop = mirroredScrollTop(plan.codeTop, plan.codeSH, plan.codeCH, docPane.scrollHeight, docPane.clientHeight);
158
+ if (paneUsesInternalYScroll(docPane)) {
159
+ applyScrollTopClamped(docPane, nextTop);
160
+ return;
161
+ }
162
+ const denom = Math.max(1, docPane.scrollHeight - docPane.clientHeight);
163
+ applyWindowScrollRatio(clamp(nextTop / denom, 0, 1));
164
+ }
165
+ function buildDocToCodeFlipPlanBlockAware(docPane, getLinks) {
166
+ const winRatio = windowScrollRatio();
167
+ const links = getLinks();
168
+ const mdLine0 = probeCommentrayLine0FromDoc(docPane);
169
+ const src0 = pickSourceLine0ForCommentrayScroll(links, mdLine0);
170
+ if (src0 !== null)
171
+ return { k: "block", src0, winRatio };
172
+ if (paneUsesInternalYScroll(docPane)) {
173
+ return {
174
+ k: "mirrorI",
175
+ docTop: docPane.scrollTop,
176
+ docSH: docPane.scrollHeight,
177
+ docCH: docPane.clientHeight,
178
+ };
179
+ }
180
+ return { k: "mirrorW", ratio: winRatio };
181
+ }
182
+ function buildCodeToDocFlipPlanBlockAware(codePane, _docPane, getLinks) {
183
+ const winRatio = windowScrollRatio();
184
+ const links = getLinks();
185
+ const line1 = probeCodeLine1FromViewport(codePane);
186
+ const mdLine0 = pickCommentrayLineForSourceScroll(links, line1);
187
+ if (mdLine0 === null) {
188
+ if (paneUsesInternalYScroll(codePane)) {
189
+ return {
190
+ k: "mirrorI",
191
+ codeTop: codePane.scrollTop,
192
+ codeSH: codePane.scrollHeight,
193
+ codeCH: codePane.clientHeight,
194
+ };
195
+ }
196
+ return { k: "mirrorW", ratio: winRatio };
197
+ }
198
+ return { k: "block", mdLine0, winRatio };
199
+ }
200
+ function buildDocToCodeFlipPlanProportional(docPane) {
201
+ if (paneUsesInternalYScroll(docPane)) {
202
+ return {
203
+ k: "mirrorI",
204
+ docTop: docPane.scrollTop,
205
+ docSH: docPane.scrollHeight,
206
+ docCH: docPane.clientHeight,
207
+ };
208
+ }
209
+ return { k: "mirrorW", ratio: windowScrollRatio() };
210
+ }
211
+ function buildCodeToDocFlipPlanProportional(codePane) {
212
+ if (paneUsesInternalYScroll(codePane)) {
213
+ return {
214
+ k: "mirrorI",
215
+ codeTop: codePane.scrollTop,
216
+ codeSH: codePane.scrollHeight,
217
+ codeCH: codePane.clientHeight,
218
+ };
219
+ }
220
+ return { k: "mirrorW", ratio: windowScrollRatio() };
221
+ }
13
222
  function escapeHtmlText(s) {
14
223
  return s
15
224
  .replace(/&/g, "&amp;")
@@ -154,37 +363,62 @@ function searchResultsInnerHtml(scope, combined, tokens, ctx) {
154
363
  }
155
364
  return buf.join("");
156
365
  }
366
+ function emptyBrowsePreviewHint(scope, rowCount, totalUnique, usedIndexFallback) {
367
+ if (scope === "full") {
368
+ return "Documented source for this page. Type to search.";
369
+ }
370
+ if (usedIndexFallback) {
371
+ return "Documented source on this page. Type to search the index when it is available.";
372
+ }
373
+ if (totalUnique > rowCount) {
374
+ return `Indexed source files (${String(rowCount)} of ${String(totalUnique)} shown). Type to search.`;
375
+ }
376
+ return `Indexed source files (${String(totalUnique)}). Type to search.`;
377
+ }
378
+ function emptySearchBrowsePreviewInnerHtml(hint, rows, ctx) {
379
+ const tokens = [];
380
+ const buf = [`<div class="hint">${escapeHtmlText(hint)}</div>`];
381
+ const hits = rows.map((r, i) => ({
382
+ kind: "path",
383
+ line: i,
384
+ text: r.sourcePath,
385
+ score: 1000,
386
+ source: "ordered",
387
+ spPath: r.sourcePath,
388
+ crPath: r.commentrayPath,
389
+ }));
390
+ for (const h of hits) {
391
+ buf.push(searchHitButtonHtml(h, tokens, ctx));
392
+ }
393
+ return buf.join("");
394
+ }
157
395
  function scrollDocToMarkdownLine0(docScrollEl, line0, mdLineCount) {
158
396
  const el = docScrollEl.querySelector(`#commentray-md-line-${String(line0)}`);
159
397
  if (el instanceof HTMLElement) {
160
- const top = el.getBoundingClientRect().top -
161
- docScrollEl.getBoundingClientRect().top +
162
- docScrollEl.scrollTop;
163
- docScrollEl.scrollTo({ top: Math.max(0, top - 8), behavior: "smooth" });
398
+ const top = Math.round(scrollTopToAlignChildTop(docScrollEl, el, 8));
399
+ const maxY = Math.round(Math.max(0, docScrollEl.scrollHeight - docScrollEl.clientHeight));
400
+ docScrollEl.scrollTo({ top: clamp(top, 0, maxY), behavior: "smooth" });
164
401
  return;
165
402
  }
166
403
  if (mdLineCount <= 1)
167
404
  return;
168
405
  const ratio = line0 / Math.max(1, mdLineCount - 1);
169
- const maxScroll = docScrollEl.scrollHeight - docScrollEl.clientHeight;
170
- docScrollEl.scrollTo({ top: ratio * Math.max(0, maxScroll), behavior: "smooth" });
406
+ const maxScroll = Math.max(0, docScrollEl.scrollHeight - docScrollEl.clientHeight);
407
+ docScrollEl.scrollTo({ top: ratio * maxScroll, behavior: "smooth" });
171
408
  }
172
- function openForeignPairBrowseOrGithub(pairs, commentrayPath, mdLine0) {
173
- const p = pairs.find((x) => x.commentrayPath === commentrayPath);
174
- if (!p)
175
- return;
176
- if (p.staticBrowseUrl?.trim()) {
177
- const u = new URL(p.staticBrowseUrl.trim(), globalThis.location.href);
409
+ function navigateToDocumentedPair(pair, mdLine0) {
410
+ if (pair.staticBrowseUrl?.trim()) {
411
+ const href = resolveStaticBrowseHref(pair.staticBrowseUrl.trim(), globalThis.location.pathname, globalThis.location.origin);
412
+ const u = new URL(href);
178
413
  if (mdLine0 !== null && mdLine0 >= 0)
179
414
  u.hash = `commentray-md-line-${String(mdLine0)}`;
180
- globalThis.open(u.toString(), "_blank", "noopener,noreferrer");
415
+ globalThis.location.assign(u.toString());
181
416
  return;
182
417
  }
183
- if (p.commentrayOnGithub) {
184
- const url = mdLine0 !== null && mdLine0 >= 0
185
- ? `${p.commentrayOnGithub}#L${String(mdLine0 + 1)}`
186
- : p.commentrayOnGithub;
187
- globalThis.open(url, "_blank", "noopener,noreferrer");
418
+ const gh = (pair.commentrayOnGithub ?? "").trim();
419
+ if (gh.length > 0) {
420
+ const url = mdLine0 !== null && mdLine0 >= 0 ? `${gh}#L${String(mdLine0 + 1)}` : gh;
421
+ globalThis.location.assign(url);
188
422
  }
189
423
  }
190
424
  function readSearchScopeFromShell(shell) {
@@ -243,18 +477,25 @@ function scrollCodeHitToView(line) {
243
477
  el.scrollIntoView({ block: "nearest", behavior: "smooth" });
244
478
  }
245
479
  function handlePathSearchHit(button, deps) {
246
- const cr = (button.getAttribute("data-cr-path")?.trim() ?? "").trim();
247
- const curCr = deps.mutable.commentrayPathLabel.trim();
248
- const isCurrentPair = cr.length === 0 || cr === curCr;
249
- if (isCurrentPair) {
480
+ const hitCr = (button.getAttribute("data-cr-path") ?? "").trim();
481
+ const hitSp = (button.getAttribute("data-sp-path") ?? "").trim();
482
+ const pair = findDocumentedPair(deps.mutable.documentedPairs, hitCr, hitSp);
483
+ if (pair && isSameDocumentedPair(pair, deps.filePathLabel, deps.mutable.commentrayPathLabel)) {
250
484
  deps.docScrollEl.scrollTo({ top: 0, behavior: "smooth" });
251
485
  return;
252
486
  }
253
- openForeignPairBrowseOrGithub(deps.mutable.documentedPairs, cr, null);
487
+ if (pair)
488
+ navigateToDocumentedPair(pair, null);
254
489
  }
255
490
  function handleMdSearchHit(line, crHit, deps) {
256
- if (crHit.length > 0 && crHit !== deps.mutable.commentrayPathLabel) {
257
- openForeignPairBrowseOrGithub(deps.mutable.documentedPairs, crHit, line);
491
+ const curCr = deps.mutable.commentrayPathLabel.trim();
492
+ const cr = crHit.trim();
493
+ if (cr.length > 0 && normPosixPath(cr) !== normPosixPath(curCr)) {
494
+ const pair = findDocumentedPair(deps.mutable.documentedPairs, cr, "");
495
+ if (pair) {
496
+ navigateToDocumentedPair(pair, line);
497
+ return;
498
+ }
258
499
  return;
259
500
  }
260
501
  scrollDocToMarkdownLine0(deps.docScrollEl, line, deps.mutable.mdLines.length);
@@ -273,6 +514,36 @@ function handleSearchHitButtonClick(button, deps) {
273
514
  }
274
515
  handleMdSearchHit(line, crHit, deps);
275
516
  }
517
+ /** Empty query + ArrowDown: browse preview HTML, or null when there is nothing to show. */
518
+ function emptyBrowsePreviewInnerHtml(scope, filePathLabel, mutable) {
519
+ const hitCtx = {
520
+ currentCommentrayPath: mutable.commentrayPathLabel,
521
+ currentSourcePath: filePathLabel,
522
+ };
523
+ if (scope === "full") {
524
+ const sp = filePathLabel.trim();
525
+ if (sp.length === 0)
526
+ return null;
527
+ const rows = [
528
+ { sourcePath: sp, commentrayPath: mutable.commentrayPathLabel.trim() },
529
+ ];
530
+ const hint = emptyBrowsePreviewHint("full", rows.length, rows.length, false);
531
+ return emptySearchBrowsePreviewInnerHtml(hint, rows, hitCtx);
532
+ }
533
+ const { rows, totalUnique } = uniqueSourceFilePreviewRows(mutable.documentedPairs);
534
+ if (rows.length > 0) {
535
+ const hint = emptyBrowsePreviewHint("commentray-and-paths", rows.length, totalUnique, false);
536
+ return emptySearchBrowsePreviewInnerHtml(hint, rows, hitCtx);
537
+ }
538
+ const sp = filePathLabel.trim();
539
+ if (sp.length === 0)
540
+ return null;
541
+ const fb = [
542
+ { sourcePath: sp, commentrayPath: mutable.commentrayPathLabel.trim() },
543
+ ];
544
+ const hint = emptyBrowsePreviewHint("commentray-and-paths", fb.length, fb.length, true);
545
+ return emptySearchBrowsePreviewInnerHtml(hint, fb, hitCtx);
546
+ }
276
547
  function wireSearchUi(ctx) {
277
548
  const { scope, filePathLabel, mutable, rawCode, searchInput, searchClear, searchResults, docScrollEl, } = ctx;
278
549
  let debounceTimer;
@@ -283,6 +554,13 @@ function wireSearchUi(ctx) {
283
554
  searchResults.innerHTML = "";
284
555
  searchResults.hidden = true;
285
556
  }
557
+ function renderEmptyBrowsePreview() {
558
+ const html = emptyBrowsePreviewInnerHtml(scope, filePathLabel, mutable);
559
+ if (html === null)
560
+ return;
561
+ searchResults.hidden = false;
562
+ searchResults.innerHTML = html;
563
+ }
286
564
  function runSearch() {
287
565
  const tokens = tokenizeQuery(searchInput.value);
288
566
  if (tokens.length === 0) {
@@ -308,7 +586,7 @@ function wireSearchUi(ctx) {
308
586
  currentSourcePath: filePathLabel,
309
587
  });
310
588
  }
311
- const hitClickDeps = { mutable, docScrollEl };
589
+ const hitClickDeps = { mutable, docScrollEl, filePathLabel };
312
590
  searchResults.addEventListener("click", (ev) => {
313
591
  const hit = findSearchHitButton(ev.target, searchResults);
314
592
  if (!hit)
@@ -319,6 +597,14 @@ function wireSearchUi(ctx) {
319
597
  clearTimeout(debounceTimer);
320
598
  debounceTimer = setTimeout(runSearch, 200);
321
599
  });
600
+ searchInput.addEventListener("keydown", (e) => {
601
+ if (e.key !== "ArrowDown")
602
+ return;
603
+ if (tokenizeQuery(searchInput.value).length > 0)
604
+ return;
605
+ renderEmptyBrowsePreview();
606
+ e.preventDefault();
607
+ });
322
608
  searchClear.addEventListener("click", clearSearch);
323
609
  document.addEventListener("keydown", (e) => {
324
610
  if (e.key !== "Escape")
@@ -334,20 +620,51 @@ function wireSearchUi(ctx) {
334
620
  e.preventDefault();
335
621
  });
336
622
  }
337
- function wireWrapToggle(storageWrap, codePane, wrapCb) {
623
+ /**
624
+ * After toggling `pre-wrap`, line rows reflow without necessarily resizing the pane’s border box,
625
+ * so gutter block rays and scroll sync must be nudged explicitly.
626
+ *
627
+ * Pass optional `docWrapRoots` (e.g. `#doc-pane` and `#doc-pane-body` in dual layout): the toggle
628
+ * syncs `wrap` on those nodes so commentary fenced blocks and prose honor the control. `#doc-pane-body`
629
+ * is targeted in CSS with an id so rules win over `pre code.hljs` from the highlight.js theme.
630
+ */
631
+ function wireWrapToggle(storageWrap, codePane, wrapCb, onAfterLayout, ...docWrapRoots) {
632
+ const docTargets = docWrapRoots.filter((el) => el instanceof HTMLElement);
338
633
  const wrap = readWebStorageItem(localStorage, storageWrap) === "1";
339
634
  wrapCb.checked = wrap;
340
- if (wrap)
635
+ if (wrap) {
341
636
  codePane.classList.add("wrap");
637
+ for (const el of docTargets)
638
+ el.classList.add("wrap");
639
+ }
640
+ else {
641
+ codePane.classList.remove("wrap");
642
+ for (const el of docTargets)
643
+ el.classList.remove("wrap");
644
+ }
342
645
  wrapCb.addEventListener("change", () => {
343
646
  if (wrapCb.checked) {
344
647
  codePane.classList.add("wrap");
648
+ for (const el of docTargets)
649
+ el.classList.add("wrap");
345
650
  writeWebStorageItem(localStorage, storageWrap, "1");
346
651
  }
347
652
  else {
348
653
  codePane.classList.remove("wrap");
654
+ for (const el of docTargets)
655
+ el.classList.remove("wrap");
349
656
  writeWebStorageItem(localStorage, storageWrap, "0");
350
657
  }
658
+ if (!onAfterLayout)
659
+ return;
660
+ queueMicrotask(() => {
661
+ requestAnimationFrame(() => {
662
+ onAfterLayout();
663
+ requestAnimationFrame(() => {
664
+ onAfterLayout();
665
+ });
666
+ });
667
+ });
351
668
  });
352
669
  }
353
670
  function parseScrollBlockLinksFromShell(b64) {
@@ -381,111 +698,367 @@ function parseScrollBlockLinksFromShell(b64) {
381
698
  return [];
382
699
  }
383
700
  }
701
+ function rootScrollNearDocumentEnd(edgePx = 56) {
702
+ const root = rootScrollingElement();
703
+ const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
704
+ return maxY > 0 && root.scrollTop >= maxY - edgePx;
705
+ }
384
706
  function probeCodeLine1FromViewport(codePane) {
385
- const y = codePane.getBoundingClientRect().top + 2;
386
707
  const rows = codePane.querySelectorAll('[id^="code-line-"]');
708
+ if (rows.length === 0)
709
+ return 1;
710
+ if (!paneUsesInternalYScroll(codePane)) {
711
+ if (rootScrollNearDocumentEnd()) {
712
+ const last = rows[rows.length - 1];
713
+ const m = /^code-line-(\d+)$/.exec(last.id);
714
+ if (m)
715
+ return Number(m[1]) + 1;
716
+ return rows.length;
717
+ }
718
+ const sr = codePane.getBoundingClientRect();
719
+ const vh = globalThis.innerHeight;
720
+ const clipT = Math.max(0, sr.top);
721
+ const clipB = Math.min(vh, sr.bottom);
722
+ const y = clipT + Math.max(2, Math.min(40, (clipB - clipT) * 0.15));
723
+ for (const el of rows) {
724
+ const r = el.getBoundingClientRect();
725
+ if (r.bottom > y - 1e-3) {
726
+ const m = /^code-line-(\d+)$/.exec(el.id);
727
+ if (m)
728
+ return Number(m[1]) + 1;
729
+ return 1;
730
+ }
731
+ }
732
+ return rows.length;
733
+ }
734
+ const sr = codePane.getBoundingClientRect();
735
+ const y = sr.top + codePane.clientTop + 2;
387
736
  for (const el of rows) {
388
737
  const r = el.getBoundingClientRect();
389
- if (r.bottom > y) {
738
+ if (r.bottom > y - 1e-3) {
390
739
  const m = /^code-line-(\d+)$/.exec(el.id);
391
740
  if (m)
392
741
  return Number(m[1]) + 1;
393
742
  return 1;
394
743
  }
395
744
  }
396
- return rows.length > 0 ? rows.length : 1;
745
+ return rows.length;
397
746
  }
398
747
  function probeCommentrayLine0FromDoc(docPane) {
399
- const y = docPane.getBoundingClientRect().top + 2;
400
748
  const anchors = docPane.querySelectorAll(".commentray-block-anchor");
749
+ if (anchors.length === 0)
750
+ return 0;
751
+ if (!paneUsesInternalYScroll(docPane)) {
752
+ if (rootScrollNearDocumentEnd()) {
753
+ const last = anchors[anchors.length - 1];
754
+ const lineAttr = last.getAttribute("data-commentray-line");
755
+ return lineAttr !== null && lineAttr !== "" ? Number(lineAttr) : 0;
756
+ }
757
+ const dr = docPane.getBoundingClientRect();
758
+ const vh = globalThis.innerHeight;
759
+ const clipT = Math.max(0, dr.top);
760
+ const clipB = Math.min(vh, dr.bottom);
761
+ const y = clipT + Math.max(2, Math.min(40, (clipB - clipT) * 0.15));
762
+ let best = 0;
763
+ for (const a of anchors) {
764
+ const lineAttr = a.getAttribute("data-commentray-line");
765
+ if (lineAttr === null || lineAttr === "")
766
+ continue;
767
+ if (a.getBoundingClientRect().top <= y + 1 + 1e-3)
768
+ best = Number(lineAttr);
769
+ else
770
+ break;
771
+ }
772
+ return best;
773
+ }
774
+ const dr = docPane.getBoundingClientRect();
775
+ const y = dr.top + docPane.clientTop + 2;
401
776
  let best = 0;
402
777
  for (const a of anchors) {
403
778
  const lineAttr = a.getAttribute("data-commentray-line");
404
779
  if (lineAttr === null || lineAttr === "")
405
780
  continue;
406
- if (a.getBoundingClientRect().top <= y + 1)
781
+ if (a.getBoundingClientRect().top <= y + 1 + 1e-3)
407
782
  best = Number(lineAttr);
408
783
  else
409
784
  break;
410
785
  }
411
786
  return best;
412
787
  }
788
+ /**
789
+ * Programmatic `scrollTop` on the partner pane can emit a `scroll` event after the
790
+ * synchronous `syncing` guard is cleared; that late event would mirror back and
791
+ * jerk the pane the user is scrolling. We arm a short-lived skip on the partner
792
+ * before each sync-driven update, and release one skip after two rAFs if no event
793
+ * consumed it (e.g. `applyScrollTopClamped` no-oped).
794
+ */
795
+ function armIgnoreNextPaneScrollReaction(armed) {
796
+ armed.n++;
797
+ queueMicrotask(() => {
798
+ requestAnimationFrame(() => {
799
+ requestAnimationFrame(() => {
800
+ armed.n = Math.max(0, armed.n - 1);
801
+ });
802
+ });
803
+ });
804
+ }
413
805
  function wireBidirectionalScroll(codePane, docPane, syncFromCode, syncFromDoc) {
414
806
  let syncing = "none";
807
+ const ignoreCodeScrollFromPartnerSync = { n: 0 };
808
+ const ignoreDocScrollFromPartnerSync = { n: 0 };
415
809
  codePane.addEventListener("scroll", () => {
810
+ if (ignoreCodeScrollFromPartnerSync.n > 0) {
811
+ ignoreCodeScrollFromPartnerSync.n--;
812
+ return;
813
+ }
416
814
  if (syncing === "doc")
417
815
  return;
418
816
  syncing = "code";
817
+ armIgnoreNextPaneScrollReaction(ignoreDocScrollFromPartnerSync);
419
818
  syncFromCode();
420
819
  syncing = "none";
421
820
  }, { passive: true });
422
821
  docPane.addEventListener("scroll", () => {
822
+ if (ignoreDocScrollFromPartnerSync.n > 0) {
823
+ ignoreDocScrollFromPartnerSync.n--;
824
+ return;
825
+ }
423
826
  if (syncing === "code")
424
827
  return;
425
828
  syncing = "doc";
829
+ armIgnoreNextPaneScrollReaction(ignoreCodeScrollFromPartnerSync);
426
830
  syncFromDoc();
427
831
  syncing = "none";
428
832
  }, { passive: true });
429
833
  }
430
834
  /** Index-backed scroll sync when `data-scroll-block-links-b64` is present; else see proportional fallback. */
431
835
  function wireBlockAwareScrollSync(codePane, docPane, getLinks) {
432
- wireBidirectionalScroll(codePane, docPane, () => {
433
- const links = getLinks();
434
- const line1 = probeCodeLine1FromViewport(codePane);
435
- const mdLine0 = pickCommentrayLineForSourceScroll(links, line1);
436
- if (mdLine0 === null) {
437
- docPane.scrollTop = mirroredScrollTop(codePane.scrollTop, codePane.scrollHeight, codePane.clientHeight, docPane.scrollHeight, docPane.clientHeight);
438
- }
439
- else {
440
- const anchor = docPane.querySelector(`[data-commentray-line="${String(mdLine0)}"]`);
441
- if (anchor instanceof HTMLElement) {
442
- const top = anchor.getBoundingClientRect().top -
443
- docPane.getBoundingClientRect().top +
444
- docPane.scrollTop;
445
- docPane.scrollTop = Math.max(0, top - 2);
446
- }
447
- else {
448
- docPane.scrollTop = mirroredScrollTop(codePane.scrollTop, codePane.scrollHeight, codePane.clientHeight, docPane.scrollHeight, docPane.clientHeight);
449
- }
450
- }
451
- }, () => {
452
- const links = getLinks();
453
- const mdLine0 = probeCommentrayLine0FromDoc(docPane);
454
- const src0 = pickSourceLine0ForCommentrayScroll(links, mdLine0);
455
- if (src0 === null) {
456
- codePane.scrollTop = mirroredScrollTop(docPane.scrollTop, docPane.scrollHeight, docPane.clientHeight, codePane.scrollHeight, codePane.clientHeight);
457
- }
458
- else {
459
- const el = document.getElementById(`code-line-${String(src0)}`);
460
- if (el) {
461
- const top = el.getBoundingClientRect().top -
462
- codePane.getBoundingClientRect().top +
463
- codePane.scrollTop;
464
- codePane.scrollTop = Math.max(0, top - 2);
465
- }
466
- else {
467
- codePane.scrollTop = mirroredScrollTop(docPane.scrollTop, docPane.scrollHeight, docPane.clientHeight, codePane.scrollHeight, codePane.clientHeight);
468
- }
469
- }
470
- });
836
+ let pendingDocToCode = null;
837
+ let pendingCodeToDoc = null;
838
+ const syncFromCodeToDoc = () => {
839
+ applyCodeToDocFlipPlanImpl(codePane, docPane, buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks));
840
+ };
841
+ const syncFromDocToCode = () => {
842
+ applyDocToCodeFlipPlanImpl(codePane, docPane, buildDocToCodeFlipPlanBlockAware(docPane, getLinks));
843
+ };
844
+ const prepareMobileFlipToCode = () => {
845
+ pendingDocToCode = buildDocToCodeFlipPlanBlockAware(docPane, getLinks);
846
+ };
847
+ const finishMobileFlipToCode = () => {
848
+ if (!pendingDocToCode)
849
+ return;
850
+ const p = pendingDocToCode;
851
+ pendingDocToCode = null;
852
+ applyDocToCodeFlipPlanImpl(codePane, docPane, p);
853
+ };
854
+ const prepareMobileFlipToDoc = () => {
855
+ pendingCodeToDoc = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks);
856
+ };
857
+ const finishMobileFlipToDoc = () => {
858
+ if (!pendingCodeToDoc)
859
+ return;
860
+ const p = pendingCodeToDoc;
861
+ pendingCodeToDoc = null;
862
+ applyCodeToDocFlipPlanImpl(codePane, docPane, p);
863
+ };
864
+ wireBidirectionalScroll(codePane, docPane, syncFromCodeToDoc, syncFromDocToCode);
865
+ return {
866
+ syncFromCodeToDoc,
867
+ syncFromDocToCode,
868
+ prepareMobileFlipToCode,
869
+ finishMobileFlipToCode,
870
+ prepareMobileFlipToDoc,
871
+ finishMobileFlipToDoc,
872
+ };
471
873
  }
472
874
  /** Proportional scroll sync when there is no index-backed block map (GitHub Pages default). */
473
875
  function wireProportionalScrollSync(codePane, docPane) {
474
- wireBidirectionalScroll(codePane, docPane, () => {
475
- docPane.scrollTop = mirroredScrollTop(codePane.scrollTop, codePane.scrollHeight, codePane.clientHeight, docPane.scrollHeight, docPane.clientHeight);
476
- }, () => {
477
- codePane.scrollTop = mirroredScrollTop(docPane.scrollTop, docPane.scrollHeight, docPane.clientHeight, codePane.scrollHeight, codePane.clientHeight);
876
+ let pendingDocToCode = null;
877
+ let pendingCodeToDoc = null;
878
+ const syncFromCodeToDoc = () => {
879
+ applyCodeToDocFlipPlanImpl(codePane, docPane, buildCodeToDocFlipPlanProportional(codePane));
880
+ };
881
+ const syncFromDocToCode = () => {
882
+ applyDocToCodeFlipPlanImpl(codePane, docPane, buildDocToCodeFlipPlanProportional(docPane));
883
+ };
884
+ const prepareMobileFlipToCode = () => {
885
+ pendingDocToCode = buildDocToCodeFlipPlanProportional(docPane);
886
+ };
887
+ const finishMobileFlipToCode = () => {
888
+ if (!pendingDocToCode)
889
+ return;
890
+ const p = pendingDocToCode;
891
+ pendingDocToCode = null;
892
+ applyDocToCodeFlipPlanImpl(codePane, docPane, p);
893
+ };
894
+ const prepareMobileFlipToDoc = () => {
895
+ pendingCodeToDoc = buildCodeToDocFlipPlanProportional(codePane);
896
+ };
897
+ const finishMobileFlipToDoc = () => {
898
+ if (!pendingCodeToDoc)
899
+ return;
900
+ const p = pendingCodeToDoc;
901
+ pendingCodeToDoc = null;
902
+ applyCodeToDocFlipPlanImpl(codePane, docPane, p);
903
+ };
904
+ wireBidirectionalScroll(codePane, docPane, syncFromCodeToDoc, syncFromDocToCode);
905
+ return {
906
+ syncFromCodeToDoc,
907
+ syncFromDocToCode,
908
+ prepareMobileFlipToCode,
909
+ finishMobileFlipToCode,
910
+ prepareMobileFlipToDoc,
911
+ finishMobileFlipToDoc,
912
+ };
913
+ }
914
+ function centerYInViewport(el) {
915
+ const r = el.getBoundingClientRect();
916
+ return (r.top + r.bottom) / 2;
917
+ }
918
+ /**
919
+ * Vertical anchor for gutter rays on the source side. Must match the **numbered row** the reader
920
+ * sees: the full `.code-line` row (line number + highlighted code) shares one grid row with
921
+ * aligned line-heights. Measuring only `pre code` can shift Y (hljs spans, sub-pixel layout) so
922
+ * rays sit above the line labels; the row’s geometric center tracks `lines:a-b` anchors reliably.
923
+ */
924
+ function codeLineHighlightCenterYViewport(lineEl) {
925
+ return centerYInViewport(lineEl);
926
+ }
927
+ function commentaryBandEndYViewport(docScrollEl, next, docTop) {
928
+ if (next) {
929
+ const nextEl = document.getElementById(`commentray-block-${next.id}`);
930
+ return nextEl ? nextEl.getBoundingClientRect().top - 3 : centerYInViewport(docTop);
931
+ }
932
+ const dr = docScrollEl.getBoundingClientRect();
933
+ let bottom = dr.bottom - 4;
934
+ const lastKid = docScrollEl.children[docScrollEl.children.length - 1];
935
+ if (lastKid)
936
+ bottom = Math.min(bottom, lastKid.getBoundingClientRect().bottom - 4);
937
+ return bottom;
938
+ }
939
+ function subscribeBlockRayRedraw(gutter, codePane, docScrollEl, scheduleDraw) {
940
+ const onScrollOrResize = () => scheduleDraw();
941
+ codePane.addEventListener("scroll", onScrollOrResize, { passive: true });
942
+ docScrollEl.addEventListener("scroll", onScrollOrResize, { passive: true });
943
+ globalThis.addEventListener("resize", onScrollOrResize);
944
+ const ro = new ResizeObserver(onScrollOrResize);
945
+ ro.observe(gutter);
946
+ ro.observe(codePane);
947
+ ro.observe(docScrollEl);
948
+ const shell = gutter.parentElement;
949
+ if (shell)
950
+ ro.observe(shell);
951
+ }
952
+ function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSourceLine1Based) {
953
+ const links = getLinks();
954
+ const sorted = sortBlockLinksBySource(links);
955
+ const gutterRect = gutter.getBoundingClientRect();
956
+ const w = gutterRect.width;
957
+ const h = gutterRect.height;
958
+ if (w <= 0 || h <= 0 || sorted.length === 0) {
959
+ svg.replaceChildren();
960
+ return;
961
+ }
962
+ const activeId = activeBlockIdForViewport(links, probeTopSourceLine1Based());
963
+ svg.setAttribute("viewBox", `0 0 ${String(w)} ${String(h)}`);
964
+ svg.setAttribute("preserveAspectRatio", "none");
965
+ const parts = [];
966
+ for (let i = 0; i < sorted.length; i++) {
967
+ const link = sorted[i];
968
+ if (!link)
969
+ continue;
970
+ const next = sorted[i + 1];
971
+ const i0 = codeLineDomIndex0(link.sourceStart);
972
+ const i1 = codeLineDomIndex0(link.sourceEnd);
973
+ const codeTop = document.getElementById(`code-line-${String(i0)}`);
974
+ const codeBot = document.getElementById(`code-line-${String(i1)}`);
975
+ const docTop = document.getElementById(`commentray-block-${link.id}`);
976
+ if (!codeTop || !codeBot || !docTop)
977
+ continue;
978
+ const docEndYViewport = commentaryBandEndYViewport(docScrollEl, next, docTop);
979
+ const yCodeTop = codeLineHighlightCenterYViewport(codeTop);
980
+ const yCodeBot = codeLineHighlightCenterYViewport(codeBot);
981
+ const yDocTop = docTop.getBoundingClientRect().top + 2;
982
+ const yDocEnd = Math.max(docEndYViewport, yDocTop + 4);
983
+ const c0 = clampViewportYToGutterLocal(yCodeTop, gutterRect.top, h);
984
+ const c1 = clampViewportYToGutterLocal(yDocTop, gutterRect.top, h);
985
+ const c2 = clampViewportYToGutterLocal(yCodeBot, gutterRect.top, h);
986
+ const c3 = clampViewportYToGutterLocal(yDocEnd, gutterRect.top, h);
987
+ const strokeClass = link.id === activeId ? "gutter__rays-path gutter__rays-path--active" : "gutter__rays-path";
988
+ const trailClass = `${strokeClass} gutter__rays-path--trail`;
989
+ const topPaths = gutterRayBezierPaths(0, c0.y, w, c1.y, {
990
+ tension: 0.38,
991
+ clipStart: c0.clipped,
992
+ clipEnd: c1.clipped,
993
+ });
994
+ const botPaths = gutterRayBezierPaths(0, c2.y, w, c3.y, {
995
+ tension: 0.38,
996
+ clipStart: c2.clipped,
997
+ clipEnd: c3.clipped,
998
+ });
999
+ const topExtra = topPaths.dotted ? `<path class="${trailClass}" d="${topPaths.dotted}" />` : "";
1000
+ const botExtra = botPaths.dotted ? `<path class="${trailClass}" d="${botPaths.dotted}" />` : "";
1001
+ parts.push(`<g class="gutter__rays-block" data-commentray-block="${escapeHtmlText(link.id)}">` +
1002
+ `<path class="${strokeClass}" d="${topPaths.solid}" />` +
1003
+ topExtra +
1004
+ `<path class="${strokeClass}" d="${botPaths.solid}" />` +
1005
+ botExtra +
1006
+ `</g>`);
1007
+ }
1008
+ svg.innerHTML = parts.join("");
1009
+ }
1010
+ /**
1011
+ * Splines in the gutter between each block’s source range and its commentary band (dual pane,
1012
+ * index-backed blocks). Emphasizes the block aligned with the current source viewport; clamps
1013
+ * off-screen endpoints so readers see which way to scroll.
1014
+ *
1015
+ * @returns Request a redraw after DOM changes that do not resize the panes (e.g. multi-angle body swap).
1016
+ */
1017
+ function wireBlockRayConnectors(args) {
1018
+ const { gutter, codePane, docScrollEl, getLinks, probeTopSourceLine1Based } = args;
1019
+ const svgNs = "http://www.w3.org/2000/svg";
1020
+ const host = document.createElement("div");
1021
+ host.className = "gutter__rays";
1022
+ host.setAttribute("aria-hidden", "true");
1023
+ const svg = document.createElementNS(svgNs, "svg");
1024
+ host.appendChild(svg);
1025
+ gutter.appendChild(host);
1026
+ let raf = 0;
1027
+ function scheduleDraw() {
1028
+ if (raf !== 0)
1029
+ return;
1030
+ raf = globalThis.requestAnimationFrame(() => {
1031
+ raf = 0;
1032
+ drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSourceLine1Based);
1033
+ });
1034
+ }
1035
+ subscribeBlockRayRedraw(gutter, codePane, docScrollEl, scheduleDraw);
1036
+ scheduleDraw();
1037
+ /** First paint can report gutter height 0 before flex layout settles; redraw after layout. */
1038
+ globalThis.requestAnimationFrame(() => {
1039
+ scheduleDraw();
1040
+ globalThis.requestAnimationFrame(scheduleDraw);
478
1041
  });
1042
+ return scheduleDraw;
479
1043
  }
480
1044
  function isDocumentedPairNav(x) {
481
1045
  if (typeof x !== "object" || x === null)
482
1046
  return false;
483
1047
  const o = x;
484
- return (typeof o.sourcePath === "string" &&
485
- typeof o.commentrayPath === "string" &&
486
- typeof o.sourceOnGithub === "string" &&
487
- typeof o.commentrayOnGithub === "string" &&
488
- (o.staticBrowseUrl === undefined || typeof o.staticBrowseUrl === "string"));
1048
+ if (typeof o.sourcePath !== "string" || typeof o.commentrayPath !== "string")
1049
+ return false;
1050
+ if (o.staticBrowseUrl !== undefined && typeof o.staticBrowseUrl !== "string")
1051
+ return false;
1052
+ const sg = o.sourceOnGithub;
1053
+ const cg = o.commentrayOnGithub;
1054
+ const hasSg = typeof sg === "string";
1055
+ const hasCg = typeof cg === "string";
1056
+ if (hasSg !== hasCg)
1057
+ return false;
1058
+ const browseOk = typeof o.staticBrowseUrl === "string" && o.staticBrowseUrl.trim().length > 0;
1059
+ if (!browseOk && !hasSg)
1060
+ return false;
1061
+ return true;
489
1062
  }
490
1063
  function pairsFromJsonArray(raw) {
491
1064
  const pairs = [];
@@ -497,27 +1070,6 @@ function pairsFromJsonArray(raw) {
497
1070
  }
498
1071
  return pairs;
499
1072
  }
500
- function pathRowsFromDocumentedPairs(pairs) {
501
- const seen = new Set();
502
- const out = [];
503
- let line = 0;
504
- for (const p of pairs) {
505
- for (const seg of [p.sourcePath, p.commentrayPath]) {
506
- const t = seg.trim();
507
- if (!t || seen.has(t))
508
- continue;
509
- seen.add(t);
510
- out.push({
511
- kind: "path",
512
- line: line++,
513
- text: t,
514
- spPath: p.sourcePath,
515
- crPath: p.commentrayPath,
516
- });
517
- }
518
- }
519
- return out;
520
- }
521
1073
  function commentrayLineRowFromNavJson(r) {
522
1074
  if (r.kind !== "commentrayLine")
523
1075
  return null;
@@ -612,6 +1164,25 @@ function treeFileLinkLabel(pr, disambiguate) {
612
1164
  const stem = companionDocStem(pr.commentrayPath);
613
1165
  return stem !== "" && stem !== base ? `${base} · ${stem}` : base;
614
1166
  }
1167
+ /** Prefer static hub browse page; optional SCM blob URL when the export has no `staticBrowseUrl`. */
1168
+ function treeFileLinkHref(pr) {
1169
+ const browse = (pr.staticBrowseUrl ?? "").trim();
1170
+ if (browse.length > 0) {
1171
+ return resolveStaticBrowseHref(browse, globalThis.location.pathname, globalThis.location.origin);
1172
+ }
1173
+ const gh = (pr.commentrayOnGithub ?? "").trim();
1174
+ return gh.length > 0 ? gh : "#";
1175
+ }
1176
+ function treeFileLinkTitle(pr) {
1177
+ const browse = (pr.staticBrowseUrl ?? "").trim();
1178
+ if (browse.length > 0) {
1179
+ return `${pr.sourcePath} — open this pair in the site viewer`;
1180
+ }
1181
+ if ((pr.commentrayOnGithub ?? "").trim().length > 0) {
1182
+ return `${pr.sourcePath} — open companion commentray on the repository host`;
1183
+ }
1184
+ return pr.sourcePath;
1185
+ }
615
1186
  function renderDocumentedTreeHtml(node) {
616
1187
  const keys = [...node.children.keys()].sort((a, b) => a.localeCompare(b));
617
1188
  if (keys.length === 0)
@@ -629,19 +1200,23 @@ function renderDocumentedTreeHtml(node) {
629
1200
  const multi = ch.pairs.length > 1;
630
1201
  for (const pr of ch.pairs) {
631
1202
  const label = escapeHtmlText(treeFileLinkLabel(pr, multi));
632
- const title = escapeHtmlText(`${pr.sourcePath} — open companion on GitHub`);
1203
+ const title = escapeHtmlText(treeFileLinkTitle(pr));
1204
+ const href = escapeHtmlText(treeFileLinkHref(pr));
1205
+ const useSiteBrowse = (pr.staticBrowseUrl?.trim() ?? "").length > 0;
1206
+ const external = useSiteBrowse ? "" : ' target="_blank" rel="noopener noreferrer"';
633
1207
  lis.push(`<li><div class="tree-file">` +
634
- `<a class="tree-file-link" href="${escapeHtmlText(pr.commentrayOnGithub)}" target="_blank" rel="noopener noreferrer" title="${title}">${label}</a>` +
1208
+ `<a class="tree-file-link" href="${href}"${external} title="${title}">${label}</a>` +
635
1209
  `</div></li>`);
636
1210
  }
637
1211
  }
638
1212
  }
639
1213
  return `<ul>${lis.join("")}</ul>`;
640
1214
  }
641
- function renderDocumentedPairsIntoHost(treeHost, pairs) {
1215
+ function renderDocumentedPairsIntoHost(treeHost, pairs, emptyBecauseFilter) {
642
1216
  if (pairs.length === 0) {
643
- treeHost.innerHTML =
644
- '<p class="nav-rail__doc-hub-hint" role="status">No documented pairs in this export.</p>';
1217
+ treeHost.innerHTML = emptyBecauseFilter
1218
+ ? '<p class="nav-rail__doc-hub-hint" role="status">No paths match this filter.</p>'
1219
+ : '<p class="nav-rail__doc-hub-hint" role="status">No documented pairs in this export.</p>';
645
1220
  return;
646
1221
  }
647
1222
  const root = { children: new Map(), pairs: [] };
@@ -677,9 +1252,56 @@ function loadDocumentedPairs(jsonUrl, embeddedB64) {
677
1252
  return loaded ?? [];
678
1253
  };
679
1254
  }
1255
+ /**
1256
+ * On narrow viewports the toolbar strip uses horizontal overflow; absolutely positioned
1257
+ * `.nav-rail__doc-hub-inner` is clipped. Pin the panel with `position: fixed` while open.
1258
+ */
1259
+ function wireDocumentedFilesTreeMobileFlyout(hub) {
1260
+ const innerCandidate = hub.querySelector(".nav-rail__doc-hub-inner");
1261
+ if (!(innerCandidate instanceof HTMLElement)) {
1262
+ return () => { };
1263
+ }
1264
+ const flyoutInner = innerCandidate;
1265
+ const mq = globalThis.matchMedia("(max-width: 767px)");
1266
+ function summaryEl() {
1267
+ const s = hub.querySelector("summary");
1268
+ return s instanceof HTMLElement ? s : null;
1269
+ }
1270
+ function placeFlyout() {
1271
+ if (!hub.open || !mq.matches) {
1272
+ flyoutInner.style.removeProperty("position");
1273
+ flyoutInner.style.removeProperty("top");
1274
+ flyoutInner.style.removeProperty("left");
1275
+ flyoutInner.style.removeProperty("right");
1276
+ flyoutInner.style.removeProperty("width");
1277
+ flyoutInner.style.removeProperty("max-width");
1278
+ flyoutInner.style.removeProperty("max-height");
1279
+ flyoutInner.style.removeProperty("z-index");
1280
+ return;
1281
+ }
1282
+ const sum = summaryEl();
1283
+ if (!sum)
1284
+ return;
1285
+ const r = sum.getBoundingClientRect();
1286
+ const pad = 8;
1287
+ flyoutInner.style.position = "fixed";
1288
+ flyoutInner.style.top = `${String(Math.round(r.bottom + 4))}px`;
1289
+ flyoutInner.style.left = `${String(Math.round(pad))}px`;
1290
+ flyoutInner.style.right = `${String(Math.round(pad))}px`;
1291
+ flyoutInner.style.width = "auto";
1292
+ flyoutInner.style.maxWidth = "none";
1293
+ flyoutInner.style.maxHeight = "min(52vh, 400px)";
1294
+ flyoutInner.style.zIndex = "200";
1295
+ }
1296
+ mq.addEventListener("change", placeFlyout);
1297
+ globalThis.addEventListener("resize", placeFlyout);
1298
+ globalThis.addEventListener("scroll", placeFlyout, true);
1299
+ return placeFlyout;
1300
+ }
680
1301
  function wireDocumentedFilesTree() {
681
1302
  const hub = document.getElementById("documented-files-hub");
682
1303
  const treeHost = document.getElementById("documented-files-tree");
1304
+ const filterInput = document.getElementById("documented-files-filter");
683
1305
  const shell = document.getElementById("shell");
684
1306
  if (!(hub instanceof HTMLDetailsElement) || !(treeHost instanceof HTMLElement)) {
685
1307
  return;
@@ -689,22 +1311,45 @@ function wireDocumentedFilesTree() {
689
1311
  const embeddedB64 = shell?.getAttribute("data-documented-pairs-b64")?.trim() ?? "";
690
1312
  if (jsonUrl.length === 0 && embeddedB64.length === 0)
691
1313
  return;
1314
+ const placeDocHubFlyout = wireDocumentedFilesTreeMobileFlyout(hub);
692
1315
  const ensureLoaded = loadDocumentedPairs(jsonUrl, embeddedB64);
1316
+ let cachedPairs = null;
1317
+ function applyFilterAndRender() {
1318
+ if (cachedPairs === null)
1319
+ return;
1320
+ const q = filterInput instanceof HTMLInputElement ? filterInput.value : "";
1321
+ const pairs = filterPairsByDocumentedTreeQuery(cachedPairs, q);
1322
+ const filterActive = q.trim().length > 0;
1323
+ renderDocumentedPairsIntoHost(treeMount, pairs, filterActive && cachedPairs.length > 0 && pairs.length === 0);
1324
+ }
693
1325
  async function hydrateTree() {
694
1326
  try {
695
1327
  const pairs = await ensureLoaded();
696
- renderDocumentedPairsIntoHost(treeMount, pairs);
1328
+ cachedPairs = pairs;
1329
+ applyFilterAndRender();
697
1330
  }
698
1331
  catch {
1332
+ cachedPairs = null;
699
1333
  treeMount.innerHTML =
700
1334
  '<p class="nav-rail__doc-hub-hint" role="alert">Could not load the file list.</p>';
701
1335
  }
702
1336
  }
703
1337
  hub.addEventListener("toggle", () => {
1338
+ placeDocHubFlyout();
1339
+ if (hub.open) {
1340
+ globalThis.requestAnimationFrame(placeDocHubFlyout);
1341
+ }
704
1342
  if (!hub.open)
705
1343
  return;
706
1344
  void hydrateTree();
707
1345
  });
1346
+ if (filterInput instanceof HTMLInputElement) {
1347
+ filterInput.addEventListener("input", () => {
1348
+ if (!hub.open || cachedPairs === null)
1349
+ return;
1350
+ applyFilterAndRender();
1351
+ });
1352
+ }
708
1353
  }
709
1354
  function wireSplitter(storageSplit, shell, codePane, gutter, initialPct) {
710
1355
  let dragging = false;
@@ -717,6 +1362,7 @@ function wireSplitter(storageSplit, shell, codePane, gutter, initialPct) {
717
1362
  const p = clamp((x / rect.width) * 100, 15, 85);
718
1363
  lastPct = p;
719
1364
  codePane.style.flex = `0 0 ${p}%`;
1365
+ shell.style.setProperty("--split-pct", `${String(p)}%`);
720
1366
  }
721
1367
  function stop() {
722
1368
  dragging = false;
@@ -737,10 +1383,118 @@ function wireSplitter(storageSplit, shell, codePane, gutter, initialPct) {
737
1383
  }
738
1384
  const STORAGE_SPLIT_PCT = "commentray.codeCommentrayStatic.splitPct";
739
1385
  const STORAGE_WRAP_LINES = "commentray.codeCommentrayStatic.wrap";
1386
+ const STORAGE_DUAL_MOBILE_PANE = "commentray.codeCommentrayStatic.dualMobilePane";
1387
+ /** Matches `code-browser.ts` `@media (max-width: 767px)` (dual column from 768px up). */
1388
+ const DUAL_MOBILE_SINGLE_PANE_MQ = "(max-width: 767px)";
1389
+ function normalizedDualMobilePane(v) {
1390
+ return v === "code" ? "code" : "doc";
1391
+ }
1392
+ /** When the commentary pane is visible, (re)run Mermaid so diagrams are not laid out under display:none. */
1393
+ function scheduleMermaidWhenDualDocPaneVisible(shell, mq) {
1394
+ const kick = () => {
1395
+ if (shell.getAttribute("data-layout") !== "dual")
1396
+ return;
1397
+ if (!mq.matches)
1398
+ return;
1399
+ if (normalizedDualMobilePane(shell.getAttribute("data-dual-mobile-pane")) !== "doc")
1400
+ return;
1401
+ const docBody = document.getElementById("doc-pane-body");
1402
+ if (!(docBody instanceof HTMLElement))
1403
+ return;
1404
+ runMermaidOnFreshDocNodes(docBody);
1405
+ };
1406
+ queueMicrotask(() => {
1407
+ kick();
1408
+ requestAnimationFrame(() => {
1409
+ kick();
1410
+ requestAnimationFrame(kick);
1411
+ });
1412
+ });
1413
+ }
1414
+ function wireDualMobilePaneFlipScrollAffordance(primaryFlip, scrollFlip, mq) {
1415
+ const hideScroll = () => {
1416
+ scrollFlip.hidden = true;
1417
+ scrollFlip.classList.remove("is-visible");
1418
+ };
1419
+ const showScroll = () => {
1420
+ scrollFlip.hidden = false;
1421
+ scrollFlip.classList.add("is-visible");
1422
+ };
1423
+ /** Prefer geometry over IntersectionObserver: a sliver “intersecting” the viewport is still unusable. */
1424
+ const tick = () => {
1425
+ if (!mq.matches) {
1426
+ hideScroll();
1427
+ return;
1428
+ }
1429
+ const r = primaryFlip.getBoundingClientRect();
1430
+ const vh = globalThis.innerHeight;
1431
+ const margin = 10;
1432
+ const offScreen = r.bottom < margin || r.top > vh - margin;
1433
+ if (offScreen)
1434
+ showScroll();
1435
+ else
1436
+ hideScroll();
1437
+ };
1438
+ globalThis.addEventListener("scroll", tick, { passive: true });
1439
+ globalThis.addEventListener("resize", tick, { passive: true });
1440
+ mq.addEventListener("change", tick);
1441
+ globalThis.requestAnimationFrame(tick);
1442
+ }
1443
+ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
1444
+ const mq = globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ);
1445
+ function readStoredPane() {
1446
+ return normalizedDualMobilePane(readWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE));
1447
+ }
1448
+ function applyForViewport() {
1449
+ if (mq.matches) {
1450
+ shell.setAttribute("data-dual-mobile-pane", readStoredPane());
1451
+ }
1452
+ else {
1453
+ shell.removeAttribute("data-dual-mobile-pane");
1454
+ }
1455
+ }
1456
+ const runFlip = () => {
1457
+ if (!mq.matches)
1458
+ return;
1459
+ const cur = normalizedDualMobilePane(shell.getAttribute("data-dual-mobile-pane"));
1460
+ const next = cur === "code" ? "doc" : "code";
1461
+ if (next === "code") {
1462
+ scrollRunners.prepareMobileFlipToCode();
1463
+ }
1464
+ else {
1465
+ scrollRunners.prepareMobileFlipToDoc();
1466
+ }
1467
+ shell.setAttribute("data-dual-mobile-pane", next);
1468
+ writeWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE, next);
1469
+ globalThis.requestAnimationFrame(() => {
1470
+ globalThis.requestAnimationFrame(() => {
1471
+ if (next === "code") {
1472
+ scrollRunners.finishMobileFlipToCode();
1473
+ }
1474
+ else {
1475
+ scrollRunners.finishMobileFlipToDoc();
1476
+ }
1477
+ });
1478
+ });
1479
+ // Only here (not on every viewport apply): avoids redundant Mermaid passes on load/resize for the default commentary-first shell.
1480
+ if (next === "doc") {
1481
+ scheduleMermaidWhenDualDocPaneVisible(shell, mq);
1482
+ }
1483
+ };
1484
+ flipBtn.addEventListener("click", runFlip);
1485
+ if (flipScrollBtn) {
1486
+ flipScrollBtn.addEventListener("click", runFlip);
1487
+ wireDualMobilePaneFlipScrollAffordance(flipBtn, flipScrollBtn, mq);
1488
+ }
1489
+ mq.addEventListener("change", applyForViewport);
1490
+ applyForViewport();
1491
+ }
740
1492
  function wireStretchLayoutChrome(codePane) {
741
1493
  const wrapCb = document.getElementById("wrap-lines");
742
1494
  if (wrapCb) {
743
- wireWrapToggle(STORAGE_WRAP_LINES, codePane, wrapCb);
1495
+ wireWrapToggle(STORAGE_WRAP_LINES, codePane, wrapCb, () => {
1496
+ globalThis.dispatchEvent(new Event("resize"));
1497
+ });
744
1498
  }
745
1499
  }
746
1500
  function parseMultiAnglePayload(script) {
@@ -805,7 +1559,46 @@ function initialCommentrayScopePathState(shell, scope, filePathLabel, commentray
805
1559
  : [filePathLabel, commentrayPathLabel].filter((s) => s.trim().length > 0).join("\n");
806
1560
  return { documentedPairs, pathRowsForOrdering, pathBlobWide };
807
1561
  }
808
- function wireDualPaneNavSearchFetch(navSearchUrl, indexState, mutable, rebuildSearcher, searchInput) {
1562
+ /**
1563
+ * Fetched `commentray-nav-search.json` sometimes omits `staticBrowseUrl` on pairs; the hub embed
1564
+ * carries browse URLs from the same build — merge so search hits open `_site/browse/…`, not GitHub.
1565
+ */
1566
+ function mergeFetchedDocumentedPairsWithEmbeddedBrowse(embedded, fetched) {
1567
+ if (fetched.length === 0)
1568
+ return embedded;
1569
+ if (embedded.length === 0)
1570
+ return fetched;
1571
+ const browseByCr = new Map();
1572
+ for (const p of embedded) {
1573
+ const b = (p.staticBrowseUrl ?? "").trim();
1574
+ if (b.length === 0)
1575
+ continue;
1576
+ browseByCr.set(normPosixPath(p.commentrayPath), b);
1577
+ }
1578
+ return fetched.map((p) => {
1579
+ const have = (p.staticBrowseUrl ?? "").trim();
1580
+ if (have.length > 0)
1581
+ return p;
1582
+ const fromEmb = browseByCr.get(normPosixPath(p.commentrayPath));
1583
+ if (fromEmb !== undefined && fromEmb.length > 0) {
1584
+ return { ...p, staticBrowseUrl: fromEmb };
1585
+ }
1586
+ return p;
1587
+ });
1588
+ }
1589
+ function resolvedNavSearchJsonUrl(shell) {
1590
+ const raw = shell.getAttribute("data-nav-search-json-url")?.trim() ?? "";
1591
+ if (raw.length === 0)
1592
+ return "";
1593
+ try {
1594
+ return new URL(raw, globalThis.location.href).href;
1595
+ }
1596
+ catch {
1597
+ return raw;
1598
+ }
1599
+ }
1600
+ function wireDualPaneNavSearchFetch(shell, embeddedPairs, indexState, mutable, rebuildSearcher, searchInput) {
1601
+ const navSearchUrl = resolvedNavSearchJsonUrl(shell);
809
1602
  if (navSearchUrl.length === 0)
810
1603
  return;
811
1604
  void (async () => {
@@ -815,9 +1608,10 @@ function wireDualPaneNavSearchFetch(navSearchUrl, indexState, mutable, rebuildSe
815
1608
  return;
816
1609
  const doc = (await res.json());
817
1610
  const fetched = pairsFromJsonArray(doc.documentedPairs);
818
- if (fetched.length > 0) {
819
- indexState.documentedPairs = fetched;
820
- mutable.documentedPairs = fetched;
1611
+ const mergedPairs = mergeFetchedDocumentedPairsWithEmbeddedBrowse(embeddedPairs, fetched);
1612
+ if (mergedPairs.length > 0) {
1613
+ indexState.documentedPairs = mergedPairs;
1614
+ mutable.documentedPairs = mergedPairs;
821
1615
  }
822
1616
  const nr = rowsFromNavSearchJson(doc);
823
1617
  if (nr.length === 0)
@@ -837,10 +1631,9 @@ function wireDualPaneNavSearchFetch(navSearchUrl, indexState, mutable, rebuildSe
837
1631
  })();
838
1632
  }
839
1633
  function wireDualPaneMultiAngleAndScroll(args) {
840
- const { codePane, docScrollEl, docBody, shell, scrollLinks, multiPayload, mutable, rebuildSearcher, searchInput, searchResults, } = args;
841
- const activeLinks = { current: scrollLinks };
1634
+ const { codePane, docScrollEl, docBody, shell, scrollLinksRef, multiPayload, mutable, rebuildSearcher, searchInput, searchResults, requestBlockRayRedraw, } = args;
842
1635
  if (multiPayload) {
843
- wireBlockAwareScrollSync(codePane, docScrollEl, () => activeLinks.current);
1636
+ const runners = wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current);
844
1637
  const angleSel = document.getElementById("angle-select");
845
1638
  if (angleSel && docBody) {
846
1639
  angleSel.addEventListener("change", () => {
@@ -848,29 +1641,56 @@ function wireDualPaneMultiAngleAndScroll(args) {
848
1641
  if (!a)
849
1642
  return;
850
1643
  docBody.innerHTML = decodeBase64Utf8(a.docInnerHtmlB64);
1644
+ runMermaidOnFreshDocNodes(docBody);
1645
+ rewriteHubRelativeBrowseAnchorsIn(docBody);
851
1646
  mutable.rawMd = decodeBase64Utf8(a.rawMdB64);
852
1647
  mutable.mdLines = mutable.rawMd.split("\n");
853
1648
  mutable.commentrayPathLabel = a.commentrayPathForSearch;
854
1649
  rebuildSearcher();
855
- activeLinks.current = parseScrollBlockLinksFromShell(a.scrollBlockLinksB64);
1650
+ scrollLinksRef.current = parseScrollBlockLinksFromShell(a.scrollBlockLinksB64);
856
1651
  shell.setAttribute("data-scroll-block-links-b64", a.scrollBlockLinksB64);
857
1652
  shell.setAttribute("data-search-commentray-path", a.commentrayPathForSearch);
858
- const gh = document.getElementById("toolbar-commentray-github");
859
- if (gh instanceof HTMLAnchorElement && a.commentrayOnGithubUrl?.trim()) {
860
- gh.href = a.commentrayOnGithubUrl.trim();
1653
+ const docPathEl = document.getElementById("nav-rail-doc-path");
1654
+ if (docPathEl) {
1655
+ const path = a.commentrayPathForSearch.trim();
1656
+ docPathEl.textContent = path.length > 0 ? path : "—";
1657
+ if (path.length > 0)
1658
+ docPathEl.setAttribute("title", path);
1659
+ else
1660
+ docPathEl.removeAttribute("title");
1661
+ }
1662
+ const browse = a.staticBrowseUrl?.trim() ?? "";
1663
+ if (browse.length > 0) {
1664
+ const resolved = staticBrowseHrefForShellDataAttribute(browse, globalThis.location.pathname, globalThis.location.origin);
1665
+ shell.setAttribute("data-commentray-pair-browse-href", resolved);
1666
+ }
1667
+ else {
1668
+ const ghu = a.commentrayOnGithubUrl?.trim();
1669
+ if (ghu) {
1670
+ shell.setAttribute("data-commentray-pair-browse-href", ghu);
1671
+ }
1672
+ else {
1673
+ shell.removeAttribute("data-commentray-pair-browse-href");
1674
+ }
861
1675
  }
862
1676
  searchInput.value = "";
863
1677
  searchResults.innerHTML = "";
864
1678
  searchResults.hidden = true;
1679
+ requestBlockRayRedraw?.();
1680
+ globalThis.requestAnimationFrame(() => {
1681
+ requestBlockRayRedraw?.();
1682
+ globalThis.requestAnimationFrame(() => {
1683
+ requestBlockRayRedraw?.();
1684
+ });
1685
+ });
865
1686
  });
866
1687
  }
867
- return;
1688
+ return runners;
868
1689
  }
869
- if (scrollLinks.length > 0) {
870
- wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinks);
871
- return;
1690
+ if (scrollLinksRef.current.length > 0) {
1691
+ return wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current);
872
1692
  }
873
- wireProportionalScrollSync(codePane, docScrollEl);
1693
+ return wireProportionalScrollSync(codePane, docScrollEl);
874
1694
  }
875
1695
  function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
876
1696
  function applyCommentrayLocationHash() {
@@ -887,15 +1707,12 @@ function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
887
1707
  globalThis.requestAnimationFrame(applyCommentrayLocationHash);
888
1708
  });
889
1709
  }
890
- function wireDualPaneCodeBrowser(shell, codePane) {
891
- const dom = readDualPaneDomBundle();
892
- if (!dom)
893
- return;
894
- const { docBody, docScrollEl, gutter, wrapCb, searchInput, searchClear, searchResults } = dom;
1710
+ function buildDualPaneSearcherBundle(shell, codePane) {
895
1711
  const { rawCodeB64, rawMdB64 } = readEmbeddedRawB64Strings(shell, codePane);
896
1712
  const rawCode = decodeBase64Utf8(rawCodeB64);
897
1713
  const rawMd = decodeBase64Utf8(rawMdB64);
898
1714
  const scrollLinks = parseScrollBlockLinksFromShell(shell.getAttribute("data-scroll-block-links-b64") || "");
1715
+ const scrollLinksRef = { current: scrollLinks };
899
1716
  const { scope, filePathLabel, commentrayPathLabel } = readSearchScopeFromShell(shell);
900
1717
  const pathInit = initialCommentrayScopePathState(shell, scope, filePathLabel, commentrayPathLabel);
901
1718
  const indexState = {
@@ -924,40 +1741,184 @@ function wireDualPaneCodeBrowser(shell, codePane) {
924
1741
  }));
925
1742
  }
926
1743
  rebuildSearcher();
927
- wireSearchUi({
1744
+ return {
1745
+ rawCode,
1746
+ rawMd,
1747
+ scrollLinksRef,
928
1748
  scope,
929
1749
  filePathLabel,
1750
+ commentrayPathLabel,
1751
+ pathInit,
1752
+ indexState,
930
1753
  mutable,
931
- rawCode,
1754
+ rebuildSearcher,
1755
+ };
1756
+ }
1757
+ function wireDualPaneCodeBrowser(shell, codePane) {
1758
+ const dom = readDualPaneDomBundle();
1759
+ if (!dom)
1760
+ return;
1761
+ const { docBody, docScrollEl, gutter, wrapCb, searchInput, searchClear, searchResults } = dom;
1762
+ const bundle = buildDualPaneSearcherBundle(shell, codePane);
1763
+ rewriteHubRelativeBrowseAnchorsIn(document);
1764
+ wireSearchUi({
1765
+ scope: bundle.scope,
1766
+ filePathLabel: bundle.filePathLabel,
1767
+ mutable: bundle.mutable,
1768
+ rawCode: bundle.rawCode,
932
1769
  searchInput,
933
1770
  searchClear,
934
1771
  searchResults,
935
1772
  docScrollEl,
936
1773
  });
937
- const navSearchUrl = shell.getAttribute("data-nav-search-json-url")?.trim() ?? "";
938
- wireDualPaneNavSearchFetch(navSearchUrl, indexState, mutable, rebuildSearcher, searchInput);
939
- const pct0 = parseFloat(readWebStorageItem(localStorage, STORAGE_SPLIT_PCT) || "50");
940
- const pct = clamp(Number.isFinite(pct0) ? pct0 : 50, 15, 85);
1774
+ wireDualPaneNavSearchFetch(shell, bundle.pathInit.documentedPairs, bundle.indexState, bundle.mutable, bundle.rebuildSearcher, searchInput);
1775
+ const pct0 = parseFloat(readWebStorageItem(localStorage, STORAGE_SPLIT_PCT) || "46");
1776
+ const pct = clamp(Number.isFinite(pct0) ? pct0 : 46, 15, 85);
941
1777
  codePane.style.flex = `0 0 ${pct}%`;
942
- wireWrapToggle(STORAGE_WRAP_LINES, codePane, wrapCb);
1778
+ shell.style.setProperty("--split-pct", `${String(pct)}%`);
1779
+ const docPaneEl = document.getElementById("doc-pane");
1780
+ const docPaneForWrap = docPaneEl instanceof HTMLElement ? docPaneEl : null;
1781
+ const blockRayRedraw = {};
1782
+ wireWrapToggle(STORAGE_WRAP_LINES, codePane, wrapCb, () => {
1783
+ blockRayRedraw.request?.();
1784
+ codePane.dispatchEvent(new Event("scroll"));
1785
+ docScrollEl.dispatchEvent(new Event("scroll"));
1786
+ }, docPaneForWrap, docBody);
943
1787
  wireSplitter(STORAGE_SPLIT_PCT, shell, codePane, gutter, pct);
944
1788
  const multiScript = document.getElementById("commentray-multi-angle-b64");
945
1789
  const multiPayload = parseMultiAnglePayload(multiScript);
946
- wireDualPaneMultiAngleAndScroll({
1790
+ const shouldWireBlockRays = multiPayload !== null || bundle.scrollLinksRef.current.length > 0;
1791
+ const requestBlockRayRedraw = shouldWireBlockRays
1792
+ ? wireBlockRayConnectors({
1793
+ gutter,
1794
+ codePane,
1795
+ docScrollEl,
1796
+ getLinks: () => bundle.scrollLinksRef.current,
1797
+ probeTopSourceLine1Based: () => probeCodeLine1FromViewport(codePane),
1798
+ })
1799
+ : undefined;
1800
+ blockRayRedraw.request = requestBlockRayRedraw;
1801
+ const scrollRunners = wireDualPaneMultiAngleAndScroll({
947
1802
  codePane,
948
1803
  docScrollEl,
949
1804
  docBody,
950
1805
  shell,
951
- scrollLinks,
1806
+ scrollLinksRef: bundle.scrollLinksRef,
952
1807
  multiPayload,
953
- mutable,
954
- rebuildSearcher,
1808
+ mutable: bundle.mutable,
1809
+ rebuildSearcher: bundle.rebuildSearcher,
955
1810
  searchInput,
956
1811
  searchResults,
1812
+ requestBlockRayRedraw,
957
1813
  });
958
- wireDualPaneCommentrayLocationHash(docScrollEl, () => mutable.mdLines.length);
1814
+ const flipBtn = document.getElementById("mobile-pane-flip");
1815
+ const flipScrollBtn = document.getElementById("mobile-pane-flip-scroll");
1816
+ if (flipBtn instanceof HTMLButtonElement) {
1817
+ wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn instanceof HTMLButtonElement ? flipScrollBtn : null);
1818
+ }
1819
+ wireDualPaneCommentrayLocationHash(docScrollEl, () => bundle.mutable.mdLines.length);
1820
+ }
1821
+ function commentrayThemeModeLabel(mode) {
1822
+ if (mode === "light")
1823
+ return "Light";
1824
+ if (mode === "dark")
1825
+ return "Dark";
1826
+ return "System";
1827
+ }
1828
+ function setCommentrayThemeTriggerHints(trigger, mode) {
1829
+ const label = commentrayThemeModeLabel(mode);
1830
+ trigger.setAttribute("aria-label", `Color theme: ${label}. Left-click opens the menu. Right-click cycles System, Light, and Dark.`);
1831
+ trigger.title = `Appearance: ${label} — left-click menu, right-click cycle`;
1832
+ }
1833
+ function wireColorThemeToolbar() {
1834
+ const wrapEl = document.querySelector(".toolbar-theme");
1835
+ const triggerEl = document.getElementById("commentray-theme-trigger");
1836
+ const menuEl = document.getElementById("commentray-theme-menu");
1837
+ if (!wrapEl || !(triggerEl instanceof HTMLButtonElement) || !(menuEl instanceof HTMLElement))
1838
+ return;
1839
+ const themeToolbarWrap = wrapEl;
1840
+ const themeButton = triggerEl;
1841
+ const themeMenu = menuEl;
1842
+ let currentMode = parseCommentrayColorThemeMode(readWebStorageItem(localStorage, COMMENTRAY_COLOR_THEME_STORAGE_KEY));
1843
+ applyCommentrayColorTheme(currentMode);
1844
+ let menuOpen = false;
1845
+ function syncUi() {
1846
+ themeButton.dataset.commentrayTriggerMode = currentMode;
1847
+ themeButton.setAttribute("aria-expanded", menuOpen ? "true" : "false");
1848
+ setCommentrayThemeTriggerHints(themeButton, currentMode);
1849
+ for (const el of themeMenu.querySelectorAll("[data-commentray-theme-value]")) {
1850
+ const v = parseCommentrayColorThemeMode(el.dataset.commentrayThemeValue ?? "");
1851
+ el.setAttribute("aria-checked", v === currentMode ? "true" : "false");
1852
+ }
1853
+ }
1854
+ function openMenu() {
1855
+ menuOpen = true;
1856
+ themeMenu.removeAttribute("hidden");
1857
+ syncUi();
1858
+ const checked = themeMenu.querySelector('[role="menuitemradio"][aria-checked="true"]');
1859
+ (checked ?? themeMenu.querySelector('[role="menuitemradio"]'))?.focus();
1860
+ }
1861
+ function closeMenu() {
1862
+ menuOpen = false;
1863
+ themeMenu.setAttribute("hidden", "");
1864
+ syncUi();
1865
+ }
1866
+ function persistAndApply(mode) {
1867
+ currentMode = mode;
1868
+ writeWebStorageItem(localStorage, COMMENTRAY_COLOR_THEME_STORAGE_KEY, mode);
1869
+ applyCommentrayColorTheme(mode);
1870
+ syncUi();
1871
+ }
1872
+ themeButton.addEventListener("click", (ev) => {
1873
+ ev.preventDefault();
1874
+ ev.stopPropagation();
1875
+ if (menuOpen) {
1876
+ closeMenu();
1877
+ }
1878
+ else {
1879
+ openMenu();
1880
+ }
1881
+ });
1882
+ themeButton.addEventListener("contextmenu", (ev) => {
1883
+ ev.preventDefault();
1884
+ if (menuOpen)
1885
+ closeMenu();
1886
+ persistAndApply(nextCommentrayColorThemeMode(currentMode));
1887
+ });
1888
+ for (const item of themeMenu.querySelectorAll("[data-commentray-theme-value]")) {
1889
+ item.addEventListener("click", (ev) => {
1890
+ ev.stopPropagation();
1891
+ const mode = parseCommentrayColorThemeMode(item.dataset.commentrayThemeValue ?? "");
1892
+ persistAndApply(mode);
1893
+ closeMenu();
1894
+ themeButton.focus();
1895
+ });
1896
+ }
1897
+ function onDocumentPointerDown(ev) {
1898
+ if (!menuOpen)
1899
+ return;
1900
+ const t = ev.target;
1901
+ if (!(t instanceof Node))
1902
+ return;
1903
+ if (themeToolbarWrap.contains(t))
1904
+ return;
1905
+ closeMenu();
1906
+ }
1907
+ function onDocumentKeydown(ev) {
1908
+ if (!menuOpen || ev.key !== "Escape")
1909
+ return;
1910
+ ev.preventDefault();
1911
+ ev.stopPropagation();
1912
+ closeMenu();
1913
+ themeButton.focus();
1914
+ }
1915
+ document.addEventListener("mousedown", onDocumentPointerDown, true);
1916
+ document.addEventListener("touchstart", onDocumentPointerDown, true);
1917
+ document.addEventListener("keydown", onDocumentKeydown, true);
1918
+ syncUi();
959
1919
  }
960
1920
  function main() {
1921
+ wireColorThemeToolbar();
961
1922
  wireDocumentedFilesTree();
962
1923
  const shell = document.getElementById("shell");
963
1924
  const codePane = document.getElementById("code-pane");