@commentray/render 0.0.6 → 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 (39) hide show
  1. package/dist/code-browser-client.bundle.js +10 -10
  2. package/dist/code-browser-client.js +747 -104
  3. package/dist/code-browser-client.js.map +1 -1
  4. package/dist/code-browser-color-theme.d.ts +15 -0
  5. package/dist/code-browser-color-theme.d.ts.map +1 -0
  6. package/dist/code-browser-color-theme.js +73 -0
  7. package/dist/code-browser-color-theme.js.map +1 -0
  8. package/dist/code-browser-pair-nav.d.ts +8 -0
  9. package/dist/code-browser-pair-nav.d.ts.map +1 -1
  10. package/dist/code-browser-pair-nav.js +20 -2
  11. package/dist/code-browser-pair-nav.js.map +1 -1
  12. package/dist/code-browser-scroll-sync.js +1 -1
  13. package/dist/code-browser-scroll-sync.js.map +1 -1
  14. package/dist/code-browser.d.ts +2 -2
  15. package/dist/code-browser.d.ts.map +1 -1
  16. package/dist/code-browser.js +903 -228
  17. package/dist/code-browser.js.map +1 -1
  18. package/dist/hljs-stylesheet-themes.d.ts +13 -0
  19. package/dist/hljs-stylesheet-themes.d.ts.map +1 -0
  20. package/dist/hljs-stylesheet-themes.js +19 -0
  21. package/dist/hljs-stylesheet-themes.js.map +1 -0
  22. package/dist/inline-favicon.d.ts +2 -0
  23. package/dist/inline-favicon.d.ts.map +1 -0
  24. package/dist/inline-favicon.js +25 -0
  25. package/dist/inline-favicon.js.map +1 -0
  26. package/dist/markdown-pipeline.d.ts.map +1 -1
  27. package/dist/markdown-pipeline.js +37 -2
  28. package/dist/markdown-pipeline.js.map +1 -1
  29. package/dist/mermaid-runtime-html.d.ts.map +1 -1
  30. package/dist/mermaid-runtime-html.js +10 -2
  31. package/dist/mermaid-runtime-html.js.map +1 -1
  32. package/dist/package-version.d.ts.map +1 -1
  33. package/dist/package-version.js +4 -4
  34. package/dist/package-version.js.map +1 -1
  35. package/dist/side-by-side-layout.css +58 -0
  36. package/dist/side-by-side.d.ts.map +1 -1
  37. package/dist/side-by-side.js +10 -12
  38. package/dist/side-by-side.js.map +1 -1
  39. package/package.json +2 -2
@@ -4,24 +4,221 @@ import { mirroredScrollTop, pickCommentrayLineForSourceScroll, pickSourceLine0Fo
4
4
  import { decodeBase64Utf8 } from "./code-browser-encoding.js";
5
5
  import { readEmbeddedRawB64Strings } from "./code-browser-embedded-payload.js";
6
6
  import { escapeHtmlHighlightingSearchTokens, filterPairsByDocumentedTreeQuery, findOrderedTokenSpans, lineAtIndex, offsetToLineIndex, pathRowsFromDocumentedPairs, tokenizeQuery, uniqueSourceFilePreviewRows, } from "./code-browser-search.js";
7
- import { findDocumentedPair, isSameDocumentedPair, normPosixPath, resolveStaticBrowseHref, } from "./code-browser-pair-nav.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";
8
9
  import { readWebStorageItem, writeWebStorageItem } from "./code-browser-web-storage.js";
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
+ }
9
26
  function runMermaidOnFreshDocNodes(docBody) {
10
27
  if (typeof globalThis.location !== "undefined" && globalThis.location.protocol === "file:")
11
28
  return;
12
- const nodes = docBody.querySelectorAll(".mermaid");
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
+ });
13
36
  if (nodes.length === 0)
14
37
  return;
15
38
  const m = globalThis
16
39
  .commentrayMermaid;
17
40
  if (!m)
18
41
  return;
19
- const list = Array.from(nodes);
20
- void m.run({ nodes: list }).catch(() => { });
42
+ void m.run({ nodes }).catch((err) => {
43
+ console.error("Commentray: mermaid.run failed", err);
44
+ });
21
45
  }
22
46
  function clamp(n, lo, hi) {
23
47
  return Math.max(lo, Math.min(hi, n));
24
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
+ }
25
222
  function escapeHtmlText(s) {
26
223
  return s
27
224
  .replace(/&/g, "&amp;")
@@ -198,17 +395,16 @@ function emptySearchBrowsePreviewInnerHtml(hint, rows, ctx) {
198
395
  function scrollDocToMarkdownLine0(docScrollEl, line0, mdLineCount) {
199
396
  const el = docScrollEl.querySelector(`#commentray-md-line-${String(line0)}`);
200
397
  if (el instanceof HTMLElement) {
201
- const top = el.getBoundingClientRect().top -
202
- docScrollEl.getBoundingClientRect().top +
203
- docScrollEl.scrollTop;
204
- 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" });
205
401
  return;
206
402
  }
207
403
  if (mdLineCount <= 1)
208
404
  return;
209
405
  const ratio = line0 / Math.max(1, mdLineCount - 1);
210
- const maxScroll = docScrollEl.scrollHeight - docScrollEl.clientHeight;
211
- 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" });
212
408
  }
213
409
  function navigateToDocumentedPair(pair, mdLine0) {
214
410
  if (pair.staticBrowseUrl?.trim()) {
@@ -424,20 +620,51 @@ function wireSearchUi(ctx) {
424
620
  e.preventDefault();
425
621
  });
426
622
  }
427
- 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);
428
633
  const wrap = readWebStorageItem(localStorage, storageWrap) === "1";
429
634
  wrapCb.checked = wrap;
430
- if (wrap)
635
+ if (wrap) {
431
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
+ }
432
645
  wrapCb.addEventListener("change", () => {
433
646
  if (wrapCb.checked) {
434
647
  codePane.classList.add("wrap");
648
+ for (const el of docTargets)
649
+ el.classList.add("wrap");
435
650
  writeWebStorageItem(localStorage, storageWrap, "1");
436
651
  }
437
652
  else {
438
653
  codePane.classList.remove("wrap");
654
+ for (const el of docTargets)
655
+ el.classList.remove("wrap");
439
656
  writeWebStorageItem(localStorage, storageWrap, "0");
440
657
  }
658
+ if (!onAfterLayout)
659
+ return;
660
+ queueMicrotask(() => {
661
+ requestAnimationFrame(() => {
662
+ onAfterLayout();
663
+ requestAnimationFrame(() => {
664
+ onAfterLayout();
665
+ });
666
+ });
667
+ });
441
668
  });
442
669
  }
443
670
  function parseScrollBlockLinksFromShell(b64) {
@@ -471,101 +698,218 @@ function parseScrollBlockLinksFromShell(b64) {
471
698
  return [];
472
699
  }
473
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
+ }
474
706
  function probeCodeLine1FromViewport(codePane) {
475
- const y = codePane.getBoundingClientRect().top + 2;
476
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;
477
736
  for (const el of rows) {
478
737
  const r = el.getBoundingClientRect();
479
- if (r.bottom > y) {
738
+ if (r.bottom > y - 1e-3) {
480
739
  const m = /^code-line-(\d+)$/.exec(el.id);
481
740
  if (m)
482
741
  return Number(m[1]) + 1;
483
742
  return 1;
484
743
  }
485
744
  }
486
- return rows.length > 0 ? rows.length : 1;
745
+ return rows.length;
487
746
  }
488
747
  function probeCommentrayLine0FromDoc(docPane) {
489
- const y = docPane.getBoundingClientRect().top + 2;
490
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;
491
776
  let best = 0;
492
777
  for (const a of anchors) {
493
778
  const lineAttr = a.getAttribute("data-commentray-line");
494
779
  if (lineAttr === null || lineAttr === "")
495
780
  continue;
496
- if (a.getBoundingClientRect().top <= y + 1)
781
+ if (a.getBoundingClientRect().top <= y + 1 + 1e-3)
497
782
  best = Number(lineAttr);
498
783
  else
499
784
  break;
500
785
  }
501
786
  return best;
502
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
+ }
503
805
  function wireBidirectionalScroll(codePane, docPane, syncFromCode, syncFromDoc) {
504
806
  let syncing = "none";
807
+ const ignoreCodeScrollFromPartnerSync = { n: 0 };
808
+ const ignoreDocScrollFromPartnerSync = { n: 0 };
505
809
  codePane.addEventListener("scroll", () => {
810
+ if (ignoreCodeScrollFromPartnerSync.n > 0) {
811
+ ignoreCodeScrollFromPartnerSync.n--;
812
+ return;
813
+ }
506
814
  if (syncing === "doc")
507
815
  return;
508
816
  syncing = "code";
817
+ armIgnoreNextPaneScrollReaction(ignoreDocScrollFromPartnerSync);
509
818
  syncFromCode();
510
819
  syncing = "none";
511
820
  }, { passive: true });
512
821
  docPane.addEventListener("scroll", () => {
822
+ if (ignoreDocScrollFromPartnerSync.n > 0) {
823
+ ignoreDocScrollFromPartnerSync.n--;
824
+ return;
825
+ }
513
826
  if (syncing === "code")
514
827
  return;
515
828
  syncing = "doc";
829
+ armIgnoreNextPaneScrollReaction(ignoreCodeScrollFromPartnerSync);
516
830
  syncFromDoc();
517
831
  syncing = "none";
518
832
  }, { passive: true });
519
833
  }
520
834
  /** Index-backed scroll sync when `data-scroll-block-links-b64` is present; else see proportional fallback. */
521
835
  function wireBlockAwareScrollSync(codePane, docPane, getLinks) {
522
- wireBidirectionalScroll(codePane, docPane, () => {
523
- const links = getLinks();
524
- const line1 = probeCodeLine1FromViewport(codePane);
525
- const mdLine0 = pickCommentrayLineForSourceScroll(links, line1);
526
- if (mdLine0 === null) {
527
- docPane.scrollTop = mirroredScrollTop(codePane.scrollTop, codePane.scrollHeight, codePane.clientHeight, docPane.scrollHeight, docPane.clientHeight);
528
- }
529
- else {
530
- const anchor = docPane.querySelector(`[data-commentray-line="${String(mdLine0)}"]`);
531
- if (anchor instanceof HTMLElement) {
532
- const top = anchor.getBoundingClientRect().top -
533
- docPane.getBoundingClientRect().top +
534
- docPane.scrollTop;
535
- docPane.scrollTop = Math.max(0, top - 2);
536
- }
537
- else {
538
- docPane.scrollTop = mirroredScrollTop(codePane.scrollTop, codePane.scrollHeight, codePane.clientHeight, docPane.scrollHeight, docPane.clientHeight);
539
- }
540
- }
541
- }, () => {
542
- const links = getLinks();
543
- const mdLine0 = probeCommentrayLine0FromDoc(docPane);
544
- const src0 = pickSourceLine0ForCommentrayScroll(links, mdLine0);
545
- if (src0 === null) {
546
- codePane.scrollTop = mirroredScrollTop(docPane.scrollTop, docPane.scrollHeight, docPane.clientHeight, codePane.scrollHeight, codePane.clientHeight);
547
- }
548
- else {
549
- const el = document.getElementById(`code-line-${String(src0)}`);
550
- if (el) {
551
- const top = el.getBoundingClientRect().top -
552
- codePane.getBoundingClientRect().top +
553
- codePane.scrollTop;
554
- codePane.scrollTop = Math.max(0, top - 2);
555
- }
556
- else {
557
- codePane.scrollTop = mirroredScrollTop(docPane.scrollTop, docPane.scrollHeight, docPane.clientHeight, codePane.scrollHeight, codePane.clientHeight);
558
- }
559
- }
560
- });
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
+ };
561
873
  }
562
874
  /** Proportional scroll sync when there is no index-backed block map (GitHub Pages default). */
563
875
  function wireProportionalScrollSync(codePane, docPane) {
564
- wireBidirectionalScroll(codePane, docPane, () => {
565
- docPane.scrollTop = mirroredScrollTop(codePane.scrollTop, codePane.scrollHeight, codePane.clientHeight, docPane.scrollHeight, docPane.clientHeight);
566
- }, () => {
567
- codePane.scrollTop = mirroredScrollTop(docPane.scrollTop, docPane.scrollHeight, docPane.clientHeight, codePane.scrollHeight, codePane.clientHeight);
568
- });
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
+ };
569
913
  }
570
914
  function centerYInViewport(el) {
571
915
  const r = el.getBoundingClientRect();
@@ -667,6 +1011,8 @@ function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSource
667
1011
  * Splines in the gutter between each block’s source range and its commentary band (dual pane,
668
1012
  * index-backed blocks). Emphasizes the block aligned with the current source viewport; clamps
669
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).
670
1016
  */
671
1017
  function wireBlockRayConnectors(args) {
672
1018
  const { gutter, codePane, docScrollEl, getLinks, probeTopSourceLine1Based } = args;
@@ -693,6 +1039,7 @@ function wireBlockRayConnectors(args) {
693
1039
  scheduleDraw();
694
1040
  globalThis.requestAnimationFrame(scheduleDraw);
695
1041
  });
1042
+ return scheduleDraw;
696
1043
  }
697
1044
  function isDocumentedPairNav(x) {
698
1045
  if (typeof x !== "object" || x === null)
@@ -905,6 +1252,52 @@ function loadDocumentedPairs(jsonUrl, embeddedB64) {
905
1252
  return loaded ?? [];
906
1253
  };
907
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
+ }
908
1301
  function wireDocumentedFilesTree() {
909
1302
  const hub = document.getElementById("documented-files-hub");
910
1303
  const treeHost = document.getElementById("documented-files-tree");
@@ -918,6 +1311,7 @@ function wireDocumentedFilesTree() {
918
1311
  const embeddedB64 = shell?.getAttribute("data-documented-pairs-b64")?.trim() ?? "";
919
1312
  if (jsonUrl.length === 0 && embeddedB64.length === 0)
920
1313
  return;
1314
+ const placeDocHubFlyout = wireDocumentedFilesTreeMobileFlyout(hub);
921
1315
  const ensureLoaded = loadDocumentedPairs(jsonUrl, embeddedB64);
922
1316
  let cachedPairs = null;
923
1317
  function applyFilterAndRender() {
@@ -941,6 +1335,10 @@ function wireDocumentedFilesTree() {
941
1335
  }
942
1336
  }
943
1337
  hub.addEventListener("toggle", () => {
1338
+ placeDocHubFlyout();
1339
+ if (hub.open) {
1340
+ globalThis.requestAnimationFrame(placeDocHubFlyout);
1341
+ }
944
1342
  if (!hub.open)
945
1343
  return;
946
1344
  void hydrateTree();
@@ -964,6 +1362,7 @@ function wireSplitter(storageSplit, shell, codePane, gutter, initialPct) {
964
1362
  const p = clamp((x / rect.width) * 100, 15, 85);
965
1363
  lastPct = p;
966
1364
  codePane.style.flex = `0 0 ${p}%`;
1365
+ shell.style.setProperty("--split-pct", `${String(p)}%`);
967
1366
  }
968
1367
  function stop() {
969
1368
  dragging = false;
@@ -984,10 +1383,118 @@ function wireSplitter(storageSplit, shell, codePane, gutter, initialPct) {
984
1383
  }
985
1384
  const STORAGE_SPLIT_PCT = "commentray.codeCommentrayStatic.splitPct";
986
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
+ }
987
1492
  function wireStretchLayoutChrome(codePane) {
988
1493
  const wrapCb = document.getElementById("wrap-lines");
989
1494
  if (wrapCb) {
990
- wireWrapToggle(STORAGE_WRAP_LINES, codePane, wrapCb);
1495
+ wireWrapToggle(STORAGE_WRAP_LINES, codePane, wrapCb, () => {
1496
+ globalThis.dispatchEvent(new Event("resize"));
1497
+ });
991
1498
  }
992
1499
  }
993
1500
  function parseMultiAnglePayload(script) {
@@ -1124,9 +1631,9 @@ function wireDualPaneNavSearchFetch(shell, embeddedPairs, indexState, mutable, r
1124
1631
  })();
1125
1632
  }
1126
1633
  function wireDualPaneMultiAngleAndScroll(args) {
1127
- const { codePane, docScrollEl, docBody, shell, scrollLinksRef, multiPayload, mutable, rebuildSearcher, searchInput, searchResults, } = args;
1634
+ const { codePane, docScrollEl, docBody, shell, scrollLinksRef, multiPayload, mutable, rebuildSearcher, searchInput, searchResults, requestBlockRayRedraw, } = args;
1128
1635
  if (multiPayload) {
1129
- wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current);
1636
+ const runners = wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current);
1130
1637
  const angleSel = document.getElementById("angle-select");
1131
1638
  if (angleSel && docBody) {
1132
1639
  angleSel.addEventListener("change", () => {
@@ -1135,6 +1642,7 @@ function wireDualPaneMultiAngleAndScroll(args) {
1135
1642
  return;
1136
1643
  docBody.innerHTML = decodeBase64Utf8(a.docInnerHtmlB64);
1137
1644
  runMermaidOnFreshDocNodes(docBody);
1645
+ rewriteHubRelativeBrowseAnchorsIn(docBody);
1138
1646
  mutable.rawMd = decodeBase64Utf8(a.rawMdB64);
1139
1647
  mutable.mdLines = mutable.rawMd.split("\n");
1140
1648
  mutable.commentrayPathLabel = a.commentrayPathForSearch;
@@ -1151,35 +1659,38 @@ function wireDualPaneMultiAngleAndScroll(args) {
1151
1659
  else
1152
1660
  docPathEl.removeAttribute("title");
1153
1661
  }
1154
- const gh = document.getElementById("toolbar-commentray-github");
1155
- if (gh instanceof HTMLAnchorElement) {
1156
- const browse = a.staticBrowseUrl?.trim() ?? "";
1157
- if (browse.length > 0) {
1158
- gh.href = resolveStaticBrowseHref(browse, globalThis.location.pathname, globalThis.location.origin);
1159
- gh.removeAttribute("target");
1160
- gh.setAttribute("rel", "noopener");
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);
1161
1671
  }
1162
1672
  else {
1163
- const ghu = a.commentrayOnGithubUrl?.trim();
1164
- if (ghu) {
1165
- gh.href = ghu;
1166
- gh.target = "_blank";
1167
- gh.setAttribute("rel", "noopener noreferrer");
1168
- }
1673
+ shell.removeAttribute("data-commentray-pair-browse-href");
1169
1674
  }
1170
1675
  }
1171
1676
  searchInput.value = "";
1172
1677
  searchResults.innerHTML = "";
1173
1678
  searchResults.hidden = true;
1679
+ requestBlockRayRedraw?.();
1680
+ globalThis.requestAnimationFrame(() => {
1681
+ requestBlockRayRedraw?.();
1682
+ globalThis.requestAnimationFrame(() => {
1683
+ requestBlockRayRedraw?.();
1684
+ });
1685
+ });
1174
1686
  });
1175
1687
  }
1176
- return;
1688
+ return runners;
1177
1689
  }
1178
1690
  if (scrollLinksRef.current.length > 0) {
1179
- wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current);
1180
- return;
1691
+ return wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current);
1181
1692
  }
1182
- wireProportionalScrollSync(codePane, docScrollEl);
1693
+ return wireProportionalScrollSync(codePane, docScrollEl);
1183
1694
  }
1184
1695
  function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
1185
1696
  function applyCommentrayLocationHash() {
@@ -1196,11 +1707,7 @@ function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
1196
1707
  globalThis.requestAnimationFrame(applyCommentrayLocationHash);
1197
1708
  });
1198
1709
  }
1199
- function wireDualPaneCodeBrowser(shell, codePane) {
1200
- const dom = readDualPaneDomBundle();
1201
- if (!dom)
1202
- return;
1203
- const { docBody, docScrollEl, gutter, wrapCb, searchInput, searchClear, searchResults } = dom;
1710
+ function buildDualPaneSearcherBundle(shell, codePane) {
1204
1711
  const { rawCodeB64, rawMdB64 } = readEmbeddedRawB64Strings(shell, codePane);
1205
1712
  const rawCode = decodeBase64Utf8(rawCodeB64);
1206
1713
  const rawMd = decodeBase64Utf8(rawMdB64);
@@ -1234,48 +1741,184 @@ function wireDualPaneCodeBrowser(shell, codePane) {
1234
1741
  }));
1235
1742
  }
1236
1743
  rebuildSearcher();
1237
- wireSearchUi({
1744
+ return {
1745
+ rawCode,
1746
+ rawMd,
1747
+ scrollLinksRef,
1238
1748
  scope,
1239
1749
  filePathLabel,
1750
+ commentrayPathLabel,
1751
+ pathInit,
1752
+ indexState,
1240
1753
  mutable,
1241
- 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,
1242
1769
  searchInput,
1243
1770
  searchClear,
1244
1771
  searchResults,
1245
1772
  docScrollEl,
1246
1773
  });
1247
- wireDualPaneNavSearchFetch(shell, pathInit.documentedPairs, indexState, mutable, rebuildSearcher, searchInput);
1248
- const pct0 = parseFloat(readWebStorageItem(localStorage, STORAGE_SPLIT_PCT) || "50");
1249
- 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);
1250
1777
  codePane.style.flex = `0 0 ${pct}%`;
1251
- 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);
1252
1787
  wireSplitter(STORAGE_SPLIT_PCT, shell, codePane, gutter, pct);
1253
1788
  const multiScript = document.getElementById("commentray-multi-angle-b64");
1254
1789
  const multiPayload = parseMultiAnglePayload(multiScript);
1255
- 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({
1256
1802
  codePane,
1257
1803
  docScrollEl,
1258
1804
  docBody,
1259
1805
  shell,
1260
- scrollLinksRef,
1806
+ scrollLinksRef: bundle.scrollLinksRef,
1261
1807
  multiPayload,
1262
- mutable,
1263
- rebuildSearcher,
1808
+ mutable: bundle.mutable,
1809
+ rebuildSearcher: bundle.rebuildSearcher,
1264
1810
  searchInput,
1265
1811
  searchResults,
1812
+ requestBlockRayRedraw,
1266
1813
  });
1267
- if (scrollLinksRef.current.length > 0) {
1268
- wireBlockRayConnectors({
1269
- gutter,
1270
- codePane,
1271
- docScrollEl,
1272
- getLinks: () => scrollLinksRef.current,
1273
- probeTopSourceLine1Based: () => probeCodeLine1FromViewport(codePane),
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();
1274
1895
  });
1275
1896
  }
1276
- wireDualPaneCommentrayLocationHash(docScrollEl, () => mutable.mdLines.length);
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();
1277
1919
  }
1278
1920
  function main() {
1921
+ wireColorThemeToolbar();
1279
1922
  wireDocumentedFilesTree();
1280
1923
  const shell = document.getElementById("shell");
1281
1924
  const codePane = document.getElementById("code-pane");