@commentray/render 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/dist/block-stretch-buffer-sync.d.ts +16 -0
  2. package/dist/block-stretch-buffer-sync.d.ts.map +1 -0
  3. package/dist/block-stretch-buffer-sync.js +271 -0
  4. package/dist/block-stretch-buffer-sync.js.map +1 -0
  5. package/dist/block-stretch-layout.d.ts +18 -5
  6. package/dist/block-stretch-layout.d.ts.map +1 -1
  7. package/dist/block-stretch-layout.js +121 -43
  8. package/dist/block-stretch-layout.js.map +1 -1
  9. package/dist/browse-page-slug.d.ts +3 -4
  10. package/dist/browse-page-slug.d.ts.map +1 -1
  11. package/dist/browse-page-slug.js +3 -4
  12. package/dist/browse-page-slug.js.map +1 -1
  13. package/dist/build-commentray-nav-search.d.ts +2 -2
  14. package/dist/build-commentray-nav-search.d.ts.map +1 -1
  15. package/dist/build-commentray-nav-search.js +8 -3
  16. package/dist/build-commentray-nav-search.js.map +1 -1
  17. package/dist/code-browser-block-rays.d.ts +11 -0
  18. package/dist/code-browser-block-rays.d.ts.map +1 -1
  19. package/dist/code-browser-block-rays.js +25 -5
  20. package/dist/code-browser-block-rays.js.map +1 -1
  21. package/dist/code-browser-client.bundle.js +12 -11
  22. package/dist/code-browser-client.js +1484 -265
  23. package/dist/code-browser-client.js.map +1 -1
  24. package/dist/code-browser-pair-nav.d.ts +9 -2
  25. package/dist/code-browser-pair-nav.d.ts.map +1 -1
  26. package/dist/code-browser-pair-nav.js +53 -14
  27. package/dist/code-browser-pair-nav.js.map +1 -1
  28. package/dist/code-browser-scroll-buffer-equalize.d.ts +25 -0
  29. package/dist/code-browser-scroll-buffer-equalize.d.ts.map +1 -0
  30. package/dist/code-browser-scroll-buffer-equalize.js +316 -0
  31. package/dist/code-browser-scroll-buffer-equalize.js.map +1 -0
  32. package/dist/code-browser-scroll-sync-monotonic.d.ts +17 -0
  33. package/dist/code-browser-scroll-sync-monotonic.d.ts.map +1 -0
  34. package/dist/code-browser-scroll-sync-monotonic.js +22 -0
  35. package/dist/code-browser-scroll-sync-monotonic.js.map +1 -0
  36. package/dist/code-browser-scroll-sync-strategy.d.ts +12 -0
  37. package/dist/code-browser-scroll-sync-strategy.d.ts.map +1 -0
  38. package/dist/code-browser-scroll-sync-strategy.js +28 -0
  39. package/dist/code-browser-scroll-sync-strategy.js.map +1 -0
  40. package/dist/code-browser-scroll-sync.d.ts +2 -2
  41. package/dist/code-browser-scroll-sync.d.ts.map +1 -1
  42. package/dist/code-browser-scroll-sync.js +1 -1
  43. package/dist/code-browser-scroll-sync.js.map +1 -1
  44. package/dist/code-browser-smooth-reveal-dedup.d.ts +25 -0
  45. package/dist/code-browser-smooth-reveal-dedup.d.ts.map +1 -0
  46. package/dist/code-browser-smooth-reveal-dedup.js +25 -0
  47. package/dist/code-browser-smooth-reveal-dedup.js.map +1 -0
  48. package/dist/code-browser.d.ts +25 -8
  49. package/dist/code-browser.d.ts.map +1 -1
  50. package/dist/code-browser.js +359 -86
  51. package/dist/code-browser.js.map +1 -1
  52. package/dist/commentray-anchor-viewport-probe.d.ts +5 -1
  53. package/dist/commentray-anchor-viewport-probe.d.ts.map +1 -1
  54. package/dist/commentray-anchor-viewport-probe.js +8 -2
  55. package/dist/commentray-anchor-viewport-probe.js.map +1 -1
  56. package/dist/index.d.ts +2 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +1 -0
  59. package/dist/index.js.map +1 -1
  60. package/dist/inject-md-line-anchors.d.ts +1 -1
  61. package/dist/inject-md-line-anchors.d.ts.map +1 -1
  62. package/dist/inject-md-line-anchors.js +9 -5
  63. package/dist/inject-md-line-anchors.js.map +1 -1
  64. package/dist/markdown-pipeline.js +1 -1
  65. package/dist/markdown-pipeline.js.map +1 -1
  66. package/dist/mermaid-runtime-html.d.ts.map +1 -1
  67. package/dist/mermaid-runtime-html.js +4 -1
  68. package/dist/mermaid-runtime-html.js.map +1 -1
  69. package/dist/reading-viewport-comfort.d.ts +12 -0
  70. package/dist/reading-viewport-comfort.d.ts.map +1 -0
  71. package/dist/reading-viewport-comfort.js +14 -0
  72. package/dist/reading-viewport-comfort.js.map +1 -0
  73. package/package.json +2 -2
@@ -1,14 +1,19 @@
1
1
  import { FuzzySearcher, PrefixSearcher, Query, SearcherFactory, SubstringSearcher, } from "@m31coding/fuzzy-search";
2
- import { activeBlockIdForCommentrayLine0, activeBlockIdForViewport, clampViewportYToGutterLocal, codeLineDomIndex0, dedupeBlockScrollLinksById, gutterRayBezierPaths, maxRenderableCommentaryContentBottomViewport, nextBlockLinkInCommentrayOrder, sortBlockLinksBySource, } from "./code-browser-block-rays.js";
3
- import { mirroredScrollTop, pickCommentrayLineForSourceScroll, pickSourceLine0ForCommentrayScroll, } from "./code-browser-scroll-sync.js";
2
+ import { activeBlockIdForCommentrayLine0, activeBlockIdForViewport, clampViewportYToGutterLocal, codeLineDomIndex0, dedupeBlockScrollLinksById, gutterRayBezierPaths, commentaryGutterDocBandBottomViewport, maxRenderableCommentaryContentBottomViewport, nextBlockLinkInCommentrayOrder, sortBlockLinksBySource, } from "./code-browser-block-rays.js";
3
+ import { blockStrictlyContainingSourceViewportLine, mirroredScrollTop, pickBlockScrollLinkForCommentrayScroll, sourceTopLineStrictlyBeforeFirstIndexLine, } from "./code-browser-scroll-sync.js";
4
4
  import { maxCommentrayAnchorLine0AtOrAboveViewportY } from "./commentray-anchor-viewport-probe.js";
5
5
  import { decodeBase64Utf8 } from "./code-browser-encoding.js";
6
6
  import { readEmbeddedRawB64Strings } from "./code-browser-embedded-payload.js";
7
7
  import { escapeHtmlHighlightingSearchTokens, filterPairsByDocumentedTreeQuery, findOrderedTokenSpans, lineAtIndex, offsetToLineIndex, pathRowsFromDocumentedPairs, tokenizeQuery, uniqueSourceFilePreviewRows, } from "./code-browser-search.js";
8
- import { findDocumentedPair, isHubRelativeStaticBrowseHref, isSameDocumentedPair, normPosixPath, resolveStaticBrowseHref, siteRootPathnameFromPathname, staticBrowseHrefForShellDataAttribute, } from "./code-browser-pair-nav.js";
8
+ import { findDocumentedPair, isHubRelativeStaticBrowseHref, isSameDocumentedPair, normPosixPath, resolveStaticBrowseHref, staticBrowseHrefForShellDataAttribute, } from "./code-browser-pair-nav.js";
9
9
  import { COMMENTRAY_COLOR_THEME_STORAGE_KEY, applyCommentrayColorTheme, nextCommentrayColorThemeMode, parseCommentrayColorThemeMode, } from "./code-browser-color-theme.js";
10
+ import { shouldRevertPartnerScrollForMonotonicity } from "./code-browser-scroll-sync-monotonic.js";
11
+ import { SMOOTH_REVEAL_INFLIGHT_DEDUP_MS } from "./code-browser-smooth-reveal-dedup.js";
12
+ import { parseDualPaneScrollSyncStrategy } from "./code-browser-scroll-sync-strategy.js";
13
+ import { DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX, READING_LEAD_ALIGN_TOLERANCE_CSS_PX, READING_VIEWPORT_BOTTOM_EDGE_CSS_PX, readingViewportTopInsetCssPx, } from "./reading-viewport-comfort.js";
10
14
  import { wireWideModeIntroTour } from "./code-browser-wide-intro-controller.js";
11
15
  import { readWebStorageItem, writeWebStorageItem } from "./code-browser-web-storage.js";
16
+ import { dispatchCommentrayMermaidDone, wireBlockStretchBufferSync, } from "./block-stretch-buffer-sync.js";
12
17
  /**
13
18
  * Hub pages emit `./browse/…` relative to the site root. From `/…/browse/current.html` the browser
14
19
  * would otherwise resolve that to `…/browse/browse/…`.
@@ -27,7 +32,7 @@ function rewriteHubRelativeBrowseAnchorsIn(root) {
27
32
  }
28
33
  function runMermaidOnFreshDocNodes(docBody) {
29
34
  if (typeof globalThis.location !== "undefined" && globalThis.location.protocol === "file:")
30
- return;
35
+ return Promise.resolve();
31
36
  /** Only fenced diagram sources; Mermaid leaves other `.mermaid` nodes in the tree after render. */
32
37
  const allPres = Array.from(docBody.querySelectorAll("pre.mermaid"));
33
38
  /** Do not re-run on wrappers that already have SVG (avoids corrupting output after dual-mobile pane flip). */
@@ -36,12 +41,17 @@ function runMermaidOnFreshDocNodes(docBody) {
36
41
  return wrap === null || wrap.querySelector("svg") === null;
37
42
  });
38
43
  if (nodes.length === 0)
39
- return;
44
+ return Promise.resolve();
40
45
  const m = globalThis
41
46
  .commentrayMermaid;
42
47
  if (!m)
43
- return;
44
- void m.run({ nodes }).catch((err) => {
48
+ return Promise.resolve();
49
+ return m
50
+ .run({ nodes })
51
+ .then(() => {
52
+ dispatchCommentrayMermaidDone();
53
+ })
54
+ .catch((err) => {
45
55
  console.error("Commentray: mermaid.run failed", err);
46
56
  });
47
57
  }
@@ -54,7 +64,14 @@ function scrollTopToAlignChildTop(scrollEl, child, leadCssPx) {
54
64
  const sr = scrollEl.getBoundingClientRect();
55
65
  return scrollEl.scrollTop + (cr.top - sr.top) - scrollEl.clientTop - leadCssPx;
56
66
  }
57
- /** Avoid feedback loops when sub-pixel math matches the current position (common with browser zoom). */
67
+ /**
68
+ * Partner write for **proportional** mirror plans (`mirrorI` / `mirrorW`), which produce a fresh
69
+ * target on every driver scroll event. Must be **instant** — wrapping these in `behavior: "smooth"`
70
+ * makes the partner re-ease toward a moving target each frame, which the eye reads as up/down
71
+ * jitter while the user keeps scrolling. Block snaps also use instant writes (see
72
+ * {@link applyRevealChildInPane} and `docs/spec/dual-pane-scroll-sync.md`). Sub-pixel skip avoids
73
+ * feedback loops when zoom math matches the current position.
74
+ */
58
75
  function applyScrollTopClamped(scrollEl, nextTop) {
59
76
  const maxY = Math.max(0, scrollEl.scrollHeight - scrollEl.clientHeight);
60
77
  const clamped = clamp(nextTop, 0, maxY);
@@ -75,6 +92,41 @@ function rootScrollingElement() {
75
92
  return s;
76
93
  return document.documentElement;
77
94
  }
95
+ function readPaneVerticalScroll(pane) {
96
+ return paneUsesInternalYScroll(pane) ? pane.scrollTop : rootScrollingElement().scrollTop;
97
+ }
98
+ /** Monotonic revert must not use {@link applyScrollTopClamped}’s sub-pixel skip, or the partner never moves back. */
99
+ function writePaneVerticalScrollForced(partnerPane, target) {
100
+ if (paneUsesInternalYScroll(partnerPane)) {
101
+ const maxY = Math.max(0, partnerPane.scrollHeight - partnerPane.clientHeight);
102
+ partnerPane.scrollTop = clamp(target, 0, maxY);
103
+ return;
104
+ }
105
+ const root = rootScrollingElement();
106
+ const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
107
+ root.scrollTop = clamp(target, 0, maxY);
108
+ }
109
+ /**
110
+ * Enforces dual-pane scroll sync monotonicity (`docs/spec/dual-pane-scroll-sync.md`):
111
+ * partner never moves opposite the driver on a decisive scroll step.
112
+ */
113
+ function enforceScrollSyncMonotonic(args) {
114
+ const { driverDelta, partnerBefore, partnerPane, axis } = args;
115
+ const partnerAfter = readPaneVerticalScroll(partnerPane);
116
+ if (shouldRevertPartnerScrollForMonotonicity({ driverDelta, partnerBefore, partnerAfter })) {
117
+ if (scrollSyncTraceFeatureFlag()) {
118
+ globalThis.console.warn("[commentray:scroll-sync] monotonic.revert", {
119
+ t: Math.round(performance.now()),
120
+ axis: axis ?? "?",
121
+ driverDelta,
122
+ partnerBefore,
123
+ partnerAfter,
124
+ });
125
+ }
126
+ writePaneVerticalScrollForced(partnerPane, partnerBefore);
127
+ }
128
+ }
129
+ /** Window-root counterpart of {@link applyScrollTopClamped} for the narrow single-flow layout — same instant-vs-jitter rationale. */
78
130
  function applyWindowScrollRatio(ratio) {
79
131
  const root = rootScrollingElement();
80
132
  const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
@@ -86,10 +138,16 @@ function applyWindowScrollRatio(ratio) {
86
138
  /**
87
139
  * Reveal `child` near the top of the reading surface: the pane’s own scrollport when it scrolls
88
140
  * internally (desktop dual-pane), otherwise the document root (narrow flow layout).
141
+ *
142
+ * **Always instant** (`scrollTop` / `scrollTo` auto): smooth interpolation on a partner pane during
143
+ * bidirectional sync multiplies `scroll` events, shrinks the useful echo-suppression window, and
144
+ * fights {@link enforceScrollSyncMonotonic} — reads as rubber-band / desync. See
145
+ * `docs/spec/dual-pane-scroll-sync.md` (“Scroll-behavior on dual-pane scrollports”).
89
146
  */
90
147
  function applyRevealChildInPane(scrollport, child, leadCssPx) {
91
148
  if (paneUsesInternalYScroll(scrollport)) {
92
- applyScrollTopClamped(scrollport, Math.round(scrollTopToAlignChildTop(scrollport, child, leadCssPx)));
149
+ const target = Math.round(scrollTopToAlignChildTop(scrollport, child, leadCssPx));
150
+ applyScrollTopClamped(scrollport, target);
93
151
  return;
94
152
  }
95
153
  const root = rootScrollingElement();
@@ -101,23 +159,228 @@ function applyRevealChildInPane(scrollport, child, leadCssPx) {
101
159
  return;
102
160
  root.scrollTop = clamped;
103
161
  }
162
+ /**
163
+ * True when `child` is already placed like {@link applyRevealChildInPane} would (same few-pixel
164
+ * tolerance as code→doc’s {@link commentrayBlockAnchorAlignedWithLead}).
165
+ */
166
+ function revealTargetAlreadyAtLead(scrollport, child, leadCssPx) {
167
+ if (paneUsesInternalYScroll(scrollport)) {
168
+ const cr = child.getBoundingClientRect();
169
+ const sr = scrollport.getBoundingClientRect();
170
+ const dist = cr.top - sr.top - scrollport.clientTop;
171
+ return Math.abs(dist - leadCssPx) <= READING_LEAD_ALIGN_TOLERANCE_CSS_PX;
172
+ }
173
+ const cr = child.getBoundingClientRect();
174
+ return Math.abs(cr.top - leadCssPx) <= READING_LEAD_ALIGN_TOLERANCE_CSS_PX;
175
+ }
176
+ /** True when the block anchor for `mdLine0` is already aligned like {@link applyRevealChildInPane} would. */
177
+ function commentrayBlockAnchorAlignedWithLead(docPane, mdLine0, leadCssPx) {
178
+ const anchor = docPane.querySelector(`[data-commentray-line="${String(mdLine0)}"]`);
179
+ if (!(anchor instanceof HTMLElement))
180
+ return false;
181
+ return revealTargetAlreadyAtLead(docPane, anchor, leadCssPx);
182
+ }
183
+ /** Resolve the code line element used for doc→code block snaps (same lookup as {@link applyDocToCodeFlipPlanImpl}). */
184
+ function findCodeLineElementForBlockSnap(codePane, lineIdPrefix, src0) {
185
+ const exact = codePane.querySelector(`#${lineIdPrefix}${String(src0)}`);
186
+ if (exact instanceof HTMLElement)
187
+ return exact;
188
+ return findAnchorAtOrAfter(sourceAnchorsFromPrefix(lineIdPrefix), src0);
189
+ }
190
+ /** True when the source line for `src0` is already aligned like {@link applyRevealChildInPane} would. */
191
+ function sourceCodeLineAnchorAlignedWithLead(codePane, lineIdPrefix, src0, leadCssPx) {
192
+ const el = findCodeLineElementForBlockSnap(codePane, lineIdPrefix, src0);
193
+ if (!el)
194
+ return false;
195
+ return revealTargetAlreadyAtLead(codePane, el, leadCssPx);
196
+ }
197
+ /** If the code line head is already at the block-snap lead, doc→code should not re-issue a reveal (symmetric with code→doc). */
198
+ function docToCodePlanIfCodeAnchorAlreadyAligned(codePane, lineIdPrefix, src0, traceReason, extraFields) {
199
+ if (!sourceCodeLineAnchorAlignedWithLead(codePane, lineIdPrefix, src0, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX)) {
200
+ return null;
201
+ }
202
+ scrollSyncTrace("doc→code.plan", {
203
+ reason: traceReason,
204
+ ...extraFields,
205
+ src0,
206
+ lineIdPrefix,
207
+ });
208
+ return { k: "noop", skipProportionalFallbackOnFlip: true };
209
+ }
210
+ /**
211
+ * After the strict `[lo, hiExclusive)` span ends, keep treating the same block as active for a few
212
+ * more source lines so `line1` probe noise at the boundary does not flip code→doc between block
213
+ * snap and gap mirror every frame.
214
+ */
215
+ const SOURCE_BLOCK_TRAILING_SLACK_LINES = 6;
216
+ function blockForCodeToDocSync(links, line1, sticky) {
217
+ const strict = blockStrictlyContainingSourceViewportLine(links, line1);
218
+ if (strict)
219
+ return strict;
220
+ const id = sticky.sourceSticky.lockedId;
221
+ if (id === null)
222
+ return null;
223
+ const b = links.find((x) => x.id === id);
224
+ if (!b)
225
+ return null;
226
+ const { lo, hiExclusive } = b.markerViewportHalfOpen1Based;
227
+ const slack = SOURCE_BLOCK_TRAILING_SLACK_LINES;
228
+ if (line1 >= lo && line1 < hiExclusive + slack)
229
+ return b;
230
+ return null;
231
+ }
104
232
  function windowScrollRatio() {
105
233
  const root = rootScrollingElement();
106
234
  const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
107
235
  return maxY > 0 ? clamp(root.scrollTop / maxY, 0, 1) : 0;
108
236
  }
237
+ const SCROLL_SYNC_DEBUG_FLAG = "commentrayDebugScroll";
238
+ function scrollSyncDebugQueryOn() {
239
+ const v = new URLSearchParams(globalThis.location.search).get(SCROLL_SYNC_DEBUG_FLAG);
240
+ return v === "1" || v === "true" || v === "";
241
+ }
242
+ function scrollSyncDebugStorageOn(s) {
243
+ try {
244
+ const v = s.getItem(SCROLL_SYNC_DEBUG_FLAG);
245
+ return v === "1" || v === "true";
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ }
251
+ function scrollSyncDebugHashOn() {
252
+ const h = globalThis.location.hash;
253
+ return h === `#${SCROLL_SYNC_DEBUG_FLAG}` || h === `#${SCROLL_SYNC_DEBUG_FLAG}=1`;
254
+ }
255
+ /**
256
+ * True on common dev hostnames (`commentray serve`, local preview). Used only for the one-line
257
+ * boot banner — **not** for high-volume per-scroll tracing (that stays opt-in below).
258
+ */
259
+ function scrollSyncDebugDevHostOn() {
260
+ const host = globalThis.location.hostname;
261
+ return host === "localhost" || host === "127.0.0.1" || host === "0.0.0.0" || host === "::1";
262
+ }
263
+ /**
264
+ * High-volume scroll-sync tracing (`scrollSyncTrace`, `wire.*.driver`, plan/apply lines): opt-in
265
+ * only via URL, hash, or Web Storage — **not** implied by localhost alone (keeps DevTools quiet
266
+ * during normal reading).
267
+ *
268
+ * Turn on any one of:
269
+ * - Query: `?commentrayDebugScroll=1` (or `=true`, or present with an empty value)
270
+ * - Hash: `#commentrayDebugScroll` or `#commentrayDebugScroll=1` (fragment is not sent to the server)
271
+ * - DevTools, then reload: `sessionStorage.setItem("commentrayDebugScroll", "1")` or the same for `localStorage`
272
+ *
273
+ * Log lines (filter DevTools console on `[commentray:scroll-sync]`):
274
+ * - `code→doc.plan` / `doc→code.plan` — which branch built the flip plan (`reason` field)
275
+ * - `code→doc.apply` / `doc→code.apply` — partner `scrollTop` before vs after apply, `driverDelta`, `plan`
276
+ * - `wire.code.driver` / `wire.doc.driver` — RAF-coalesced driver flush (delta, pane `scrollTop`)
277
+ * - `wire.code.flush-skipped` / `wire.doc.flush-skipped` — flush skipped (`partner-echo` throttled ~5/s; `sync-in-progress` immediate)
278
+ * - `monotonic.revert` — `console.warn` when the partner move was opposite the driver and was rolled back
279
+ *
280
+ * Tracing consults a **cached feature flag** (rechecked at most every 500ms and on `hashchange` /
281
+ * cross-tab `storage`) so hot scroll paths avoid reading storage every tick.
282
+ */
283
+ function scrollSyncVerboseTraceEnabled() {
284
+ try {
285
+ if (scrollSyncDebugQueryOn())
286
+ return true;
287
+ if (scrollSyncDebugHashOn())
288
+ return true;
289
+ if (scrollSyncDebugStorageOn(globalThis.sessionStorage))
290
+ return true;
291
+ if (scrollSyncDebugStorageOn(globalThis.localStorage))
292
+ return true;
293
+ return false;
294
+ }
295
+ catch {
296
+ return false;
297
+ }
298
+ }
299
+ /** Throttle for re-reading URL/storage into {@link scrollSyncTraceFeatureOn}. */
300
+ const SCROLL_SYNC_TRACE_FLAG_RECHECK_MS = 500;
301
+ /**
302
+ * Feature flag for scroll-sync tracing: mirrors {@link scrollSyncVerboseTraceEnabled} but cached so
303
+ * high-frequency scroll paths do not hit Web Storage on every event.
304
+ */
305
+ let scrollSyncTraceFeatureOn = false;
306
+ let scrollSyncTraceFeatureLastCheckMs = -Infinity;
307
+ function invalidateScrollSyncTraceFeatureFlag() {
308
+ scrollSyncTraceFeatureLastCheckMs = -Infinity;
309
+ }
310
+ function scrollSyncTraceFeatureFlag() {
311
+ const now = performance.now();
312
+ if (now - scrollSyncTraceFeatureLastCheckMs < SCROLL_SYNC_TRACE_FLAG_RECHECK_MS) {
313
+ return scrollSyncTraceFeatureOn;
314
+ }
315
+ scrollSyncTraceFeatureLastCheckMs = now;
316
+ scrollSyncTraceFeatureOn = scrollSyncVerboseTraceEnabled();
317
+ return scrollSyncTraceFeatureOn;
318
+ }
319
+ if (typeof globalThis.addEventListener === "function") {
320
+ globalThis.addEventListener("hashchange", invalidateScrollSyncTraceFeatureFlag);
321
+ globalThis.addEventListener("storage", invalidateScrollSyncTraceFeatureFlag);
322
+ }
323
+ /**
324
+ * Boot announcement: **always** fires on dev hosts so a stale bundle is impossible to miss.
325
+ * On production hosts it still fires only when the opt-in flag is on. Uses `console.log`
326
+ * (visible at the default DevTools "Default" level filter) so a Verbose toggle is not required.
327
+ */
328
+ function announceScrollSyncTraceOnBoot() {
329
+ const devHost = scrollSyncDebugDevHostOn();
330
+ const traceVerbose = scrollSyncVerboseTraceEnabled();
331
+ if (!devHost && !traceVerbose)
332
+ return;
333
+ globalThis.console.log("[commentray:scroll-sync] boot", {
334
+ t: Math.round(performance.now()),
335
+ href: globalThis.location.href,
336
+ devHost,
337
+ traceVerbose,
338
+ });
339
+ }
340
+ /**
341
+ * Structured scroll-sync log line when {@link scrollSyncTraceFeatureFlag} is on. Tags you can filter on:
342
+ * - `code→doc.plan` — branch taken inside {@link buildCodeToDocFlipPlanBlockAware} (`reason` explains why)
343
+ * - `doc→code.plan` — branch inside {@link buildDocToCodeFlipPlanBlockAware}
344
+ * - `code→doc.apply` / `doc→code.apply` — partner `scrollTop` before/after apply for that driver step
345
+ * - `wire.code.flush-skipped` / `wire.doc.flush-skipped` — RAF driver flush skipped (echo gate or `syncing`)
346
+ * - `wire.code.driver` / `wire.doc.driver` — driver pane ran sync this frame (`delta`, `scrollTop`)
347
+ * - `monotonic.revert` — emitted with `console.warn` (see {@link enforceScrollSyncMonotonic})
348
+ */
349
+ function scrollSyncTrace(tag, fields) {
350
+ if (!scrollSyncTraceFeatureFlag())
351
+ return;
352
+ globalThis.console.log(`[commentray:scroll-sync] ${tag}`, {
353
+ t: Math.round(performance.now()),
354
+ ...fields,
355
+ });
356
+ }
357
+ function formatDocToCodePlanForLog(p) {
358
+ if (p.k === "noop") {
359
+ return p.skipProportionalFallbackOnFlip === true ? "noop(skipFlipFallback)" : "noop";
360
+ }
361
+ if (p.k === "block")
362
+ return `block(src0=${String(p.src0)})`;
363
+ if (p.k === "mirrorW")
364
+ return `mirrorW(ratio=${p.ratio.toFixed(4)})`;
365
+ return `mirrorI(docTop=${String(p.docTop)})`;
366
+ }
367
+ function formatCodeToDocPlanForLog(p) {
368
+ if (p.k === "noop")
369
+ return "noop";
370
+ if (p.k === "block")
371
+ return `block(mdLine0=${String(p.mdLine0)})`;
372
+ if (p.k === "mirrorW")
373
+ return `mirrorW(ratio=${p.ratio.toFixed(4)})`;
374
+ return `mirrorI(codeTop=${String(p.codeTop)})`;
375
+ }
109
376
  function applyDocToCodeFlipPlanImpl(codePane, _docPane, plan, lineIdPrefix = "code-line-") {
377
+ if (plan.k === "noop")
378
+ return;
110
379
  const narrowSinglePane = globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ).matches;
111
380
  if (plan.k === "block") {
112
- const exact = codePane.querySelector(`#${lineIdPrefix}${String(plan.src0)}`);
113
- const el = exact instanceof HTMLElement
114
- ? exact
115
- : findAnchorAtOrAfter(sourceAnchorsFromPrefix(lineIdPrefix), plan.src0);
381
+ const el = findCodeLineElementForBlockSnap(codePane, lineIdPrefix, plan.src0);
116
382
  if (el) {
117
- applyRevealChildInPane(codePane, el, 2);
118
- }
119
- else {
120
- applyWindowScrollRatio(plan.winRatio);
383
+ applyRevealChildInPane(codePane, el, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX);
121
384
  }
122
385
  return;
123
386
  }
@@ -146,13 +409,12 @@ function applyDocToCodeFlipPlanImpl(codePane, _docPane, plan, lineIdPrefix = "co
146
409
  applyWindowScrollRatio(clamp(nextTop / denom, 0, 1));
147
410
  }
148
411
  function applyCodeToDocFlipPlanImpl(_codePane, docPane, plan) {
412
+ if (plan.k === "noop")
413
+ return;
149
414
  if (plan.k === "block") {
150
415
  const anchor = docPane.querySelector(`[data-commentray-line="${String(plan.mdLine0)}"]`);
151
416
  if (anchor instanceof HTMLElement) {
152
- applyRevealChildInPane(docPane, anchor, 2);
153
- }
154
- else {
155
- applyWindowScrollRatio(plan.winRatio);
417
+ applyRevealChildInPane(docPane, anchor, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX);
156
418
  }
157
419
  return;
158
420
  }
@@ -174,45 +436,254 @@ function applyCodeToDocFlipPlanImpl(_codePane, docPane, plan) {
174
436
  const denom = Math.max(1, docPane.scrollHeight - docPane.clientHeight);
175
437
  applyWindowScrollRatio(clamp(nextTop / denom, 0, 1));
176
438
  }
177
- function buildDocToCodeFlipPlanBlockAware(docPane, getLinks) {
439
+ function resetBlockScrollStickyIfLinksChanged(sticky, links) {
440
+ const key = links.map((l) => l.id).join("\0");
441
+ if (key !== sticky.linksKey.current) {
442
+ sticky.sourceSticky.lockedId = null;
443
+ sticky.commentraySticky.lockedId = null;
444
+ sticky.linksKey.current = key;
445
+ }
446
+ }
447
+ function tryDocToCodeFlipPlanFromMdProbe(codePane, links, sticky, lineIdPrefix, winRatio, mdLine0) {
448
+ if (mdLine0 === null)
449
+ return null;
450
+ const block = pickBlockScrollLinkForCommentrayScroll(links, mdLine0);
451
+ const src0 = block !== null ? block.markerViewportHalfOpen1Based.lo - 1 : null;
452
+ if (block !== null) {
453
+ sticky.commentraySticky.lockedId = block.id;
454
+ }
455
+ if (src0 === null)
456
+ return null;
457
+ const alignedNoop = docToCodePlanIfCodeAnchorAlreadyAligned(codePane, lineIdPrefix, src0, "block-code-anchor-already-aligned-noop", { mdLine0, blockId: block?.id ?? null });
458
+ if (alignedNoop)
459
+ return alignedNoop;
460
+ const plan = { k: "block", src0, winRatio };
461
+ scrollSyncTrace("doc→code.plan", {
462
+ reason: "block-from-md-probe",
463
+ plan: formatDocToCodePlanForLog(plan),
464
+ mdLine0,
465
+ blockId: block?.id ?? null,
466
+ markerSpan: block?.markerViewportHalfOpen1Based ?? null,
467
+ });
468
+ return plan;
469
+ }
470
+ function tryDocToCodeFlipPlanFromPageBreakPull(docPane, codePane, lineIdPrefix, winRatio, mdLine0) {
471
+ const pulledSrc0 = pulledSourceLine0FromPageBreak(docPane);
472
+ if (pulledSrc0 === null)
473
+ return null;
474
+ const pbAlignedNoop = docToCodePlanIfCodeAnchorAlreadyAligned(codePane, lineIdPrefix, pulledSrc0, "page-break-pull-code-anchor-already-aligned-noop", { mdLine0, pulledSrc0 });
475
+ if (pbAlignedNoop)
476
+ return pbAlignedNoop;
477
+ const plan = { k: "block", src0: pulledSrc0, winRatio };
478
+ scrollSyncTrace("doc→code.plan", {
479
+ reason: "page-break-pull",
480
+ plan: formatDocToCodePlanForLog(plan),
481
+ pulledSrc0,
482
+ mdLine0,
483
+ });
484
+ return plan;
485
+ }
486
+ function buildDocToCodeFlipPlanBlockAware(docPane, codePane, getLinks, sticky, lineIdPrefix, allowProportionalMirror) {
178
487
  const winRatio = paneUsesInternalYScroll(docPane)
179
488
  ? clamp(docPane.scrollTop / Math.max(1, docPane.scrollHeight - docPane.clientHeight), 0, 1)
180
489
  : windowScrollRatio();
181
- const pulledSrc0 = pulledSourceLine0FromPageBreak(docPane);
182
- if (pulledSrc0 !== null)
183
- return { k: "block", src0: pulledSrc0, winRatio };
490
+ /**
491
+ * Block info is authoritative — `pulledSourceLine0FromPageBreak` is "active" for the entire span
492
+ * from a page-break element to its `nextAnchor`, which can cover whole blocks. If we let the
493
+ * page-break heuristic run first, it snaps code far forward (e.g. to the next segment's source
494
+ * line) while the doc viewport is still genuinely inside an earlier block; the later real-block
495
+ * resolution is then opposite the prior snap and `enforceScrollSyncMonotonic` reverts it, so
496
+ * code gets stuck hundreds of pixels ahead of the doc. Only fall back to `page-break-pull`
497
+ * when no indexed block matches the doc viewport (true inter-block / inter-segment gap).
498
+ */
184
499
  const links = getLinks();
500
+ resetBlockScrollStickyIfLinksChanged(sticky, links);
185
501
  const mdLine0 = probeCommentrayLine0FromDoc(docPane);
186
- const src0 = pickSourceLine0ForCommentrayScroll(links, mdLine0);
187
- if (src0 !== null)
188
- return { k: "block", src0, winRatio };
502
+ const fromMd = tryDocToCodeFlipPlanFromMdProbe(codePane, links, sticky, lineIdPrefix, winRatio, mdLine0);
503
+ if (fromMd !== null)
504
+ return fromMd;
505
+ const fromPb = tryDocToCodeFlipPlanFromPageBreakPull(docPane, codePane, lineIdPrefix, winRatio, mdLine0);
506
+ if (fromPb !== null)
507
+ return fromPb;
508
+ /**
509
+ * Index-backed pair but no confident block anchor for this viewport (e.g. missing
510
+ * `.commentray-block-anchor` nodes, or probe could not map the doc band to a link).
511
+ * With {@link allowProportionalMirror}, mirror like the no-index path so static dual
512
+ * browse still scrolls both panes together; block-snap-only keeps the historical noop.
513
+ */
514
+ if (links.length > 0) {
515
+ scrollSyncTrace("doc→code.plan", {
516
+ reason: allowProportionalMirror
517
+ ? "indexed-fallback-mirror-no-confident-block"
518
+ : "indexed-noop-no-src0",
519
+ mdLine0,
520
+ linkCount: links.length,
521
+ allowProportionalMirror,
522
+ });
523
+ if (!allowProportionalMirror) {
524
+ return { k: "noop" };
525
+ }
526
+ }
527
+ else if (!allowProportionalMirror) {
528
+ scrollSyncTrace("doc→code.plan", { reason: "snap-only-no-index-noop", linkCount: 0 });
529
+ return { k: "noop" };
530
+ }
189
531
  if (paneUsesInternalYScroll(docPane)) {
190
- return {
532
+ const plan = {
191
533
  k: "mirrorI",
192
534
  docTop: docPane.scrollTop,
193
535
  docSH: docPane.scrollHeight,
194
536
  docCH: docPane.clientHeight,
195
537
  };
538
+ scrollSyncTrace("doc→code.plan", {
539
+ reason: "no-index-mirror-internal",
540
+ plan: formatDocToCodePlanForLog(plan),
541
+ });
542
+ return plan;
543
+ }
544
+ const plan = { k: "mirrorW", ratio: winRatio };
545
+ scrollSyncTrace("doc→code.plan", {
546
+ reason: "no-index-mirror-window",
547
+ plan: formatDocToCodePlanForLog(plan),
548
+ });
549
+ return plan;
550
+ }
551
+ /** Mirror plan for the current source pane geometry — internal scroller (`mirrorI`) or window flow (`mirrorW`). */
552
+ function codeMirrorPlan(codePane, winRatio) {
553
+ if (paneUsesInternalYScroll(codePane)) {
554
+ return {
555
+ k: "mirrorI",
556
+ codeTop: codePane.scrollTop,
557
+ codeSH: codePane.scrollHeight,
558
+ codeCH: codePane.clientHeight,
559
+ };
196
560
  }
197
561
  return { k: "mirrorW", ratio: winRatio };
198
562
  }
199
- function buildCodeToDocFlipPlanBlockAware(codePane, _docPane, getLinks, lineIdPrefix = "code-line-") {
563
+ /** Source viewport sits **above** every indexed span — pin partner to the very first block head. */
564
+ function preludeBlockAwareCodeToDocPlan(links, docPane, sticky, line1, lineIdPrefix, winRatio) {
565
+ const sorted = [...links].sort((a, b) => a.markerViewportHalfOpen1Based.lo - b.markerViewportHalfOpen1Based.lo);
566
+ const first = sorted[0];
567
+ if (!first) {
568
+ scrollSyncTrace("code→doc.plan", { reason: "prelude-missing-first-link", line1, lineIdPrefix });
569
+ return { k: "noop" };
570
+ }
571
+ sticky.sourceSticky.lockedId = first.id;
572
+ const mdLine0 = first.commentrayLine;
573
+ if (commentrayBlockAnchorAlignedWithLead(docPane, mdLine0, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX)) {
574
+ scrollSyncTrace("code→doc.plan", {
575
+ reason: "prelude-anchor-already-aligned-noop",
576
+ line1,
577
+ blockId: first.id,
578
+ mdLine0,
579
+ });
580
+ return { k: "noop" };
581
+ }
582
+ const plan = { k: "block", mdLine0, winRatio };
583
+ scrollSyncTrace("code→doc.plan", {
584
+ reason: "prelude-first-block",
585
+ line1,
586
+ blockId: first.id,
587
+ mdLine0,
588
+ plan: formatCodeToDocPlanForLog(plan),
589
+ });
590
+ return plan;
591
+ }
592
+ /** Source viewport falls inside (or in trailing-slack of) an indexed block — reveal that block's anchor. */
593
+ function activeBlockCodeToDocPlan(active, strictContaining, docPane, sticky, line1, winRatio) {
594
+ sticky.sourceSticky.lockedId = active.id;
595
+ const mdLine0 = active.commentrayLine;
596
+ const pickMode = strictContaining !== null && strictContaining.id === active.id ? "strict" : "trailing-slack";
597
+ if (commentrayBlockAnchorAlignedWithLead(docPane, mdLine0, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX)) {
598
+ scrollSyncTrace("code→doc.plan", {
599
+ reason: "active-block-anchor-aligned-noop",
600
+ line1,
601
+ pickMode,
602
+ blockId: active.id,
603
+ mdLine0,
604
+ });
605
+ return { k: "noop" };
606
+ }
607
+ const plan = { k: "block", mdLine0, winRatio };
608
+ scrollSyncTrace("code→doc.plan", {
609
+ reason: "active-block-reveal",
610
+ line1,
611
+ pickMode,
612
+ blockId: active.id,
613
+ mdLine0,
614
+ markerSpan: active.markerViewportHalfOpen1Based,
615
+ plan: formatCodeToDocPlanForLog(plan),
616
+ });
617
+ return plan;
618
+ }
619
+ /**
620
+ * Releases the sticky source lock once the viewport is past the previous block plus its trailing
621
+ * slack — a true inter-block gap. Without this clear, the gap-mirror branch never reactivates after
622
+ * the slack expires.
623
+ */
624
+ function clearStickySourceLockPastTrailingSlack(sticky, links, line1) {
625
+ const sid = sticky.sourceSticky.lockedId;
626
+ if (!sid)
627
+ return;
628
+ const prev = links.find((x) => x.id === sid);
629
+ if (!prev) {
630
+ sticky.sourceSticky.lockedId = null;
631
+ return;
632
+ }
633
+ const hi = prev.markerViewportHalfOpen1Based.hiExclusive;
634
+ if (line1 >= hi + SOURCE_BLOCK_TRAILING_SLACK_LINES) {
635
+ sticky.sourceSticky.lockedId = null;
636
+ }
637
+ }
638
+ function buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix, sticky, allowProportionalMirror) {
200
639
  const winRatio = windowScrollRatio();
201
640
  const links = getLinks();
202
- const line1 = probeCodeLine1FromViewport(codePane, lineIdPrefix);
203
- const mdLine0 = pickCommentrayLineForSourceScroll(links, line1);
204
- if (mdLine0 === null) {
205
- if (paneUsesInternalYScroll(codePane)) {
206
- return {
207
- k: "mirrorI",
208
- codeTop: codePane.scrollTop,
209
- codeSH: codePane.scrollHeight,
210
- codeCH: codePane.clientHeight,
211
- };
641
+ resetBlockScrollStickyIfLinksChanged(sticky, links);
642
+ if (links.length === 0) {
643
+ if (!allowProportionalMirror) {
644
+ scrollSyncTrace("code→doc.plan", {
645
+ reason: "snap-only-no-block-links-noop",
646
+ lineIdPrefix,
647
+ });
648
+ return { k: "noop" };
212
649
  }
213
- return { k: "mirrorW", ratio: winRatio };
650
+ const plan = codeMirrorPlan(codePane, winRatio);
651
+ scrollSyncTrace("code→doc.plan", {
652
+ reason: plan.k === "mirrorI" ? "no-block-links-mirror-internal" : "no-block-links-mirror-window",
653
+ lineIdPrefix,
654
+ plan: formatCodeToDocPlanForLog(plan),
655
+ });
656
+ return plan;
214
657
  }
215
- return { k: "block", mdLine0, winRatio };
658
+ const line1 = probeCodeLine1FromViewport(codePane, lineIdPrefix);
659
+ if (sourceTopLineStrictlyBeforeFirstIndexLine(links, line1)) {
660
+ return preludeBlockAwareCodeToDocPlan(links, docPane, sticky, line1, lineIdPrefix, winRatio);
661
+ }
662
+ const strictContaining = blockStrictlyContainingSourceViewportLine(links, line1);
663
+ const active = blockForCodeToDocSync(links, line1, sticky);
664
+ if (active) {
665
+ return activeBlockCodeToDocPlan(active, strictContaining, docPane, sticky, line1, winRatio);
666
+ }
667
+ /** Source viewport sits in a true gap between indexed spans — mirror so the doc never snaps to an unrelated block head. */
668
+ clearStickySourceLockPastTrailingSlack(sticky, links, line1);
669
+ if (!allowProportionalMirror) {
670
+ scrollSyncTrace("code→doc.plan", {
671
+ reason: "snap-only-source-gap-noop",
672
+ line1,
673
+ lineIdPrefix,
674
+ stickyLockAfter: sticky.sourceSticky.lockedId,
675
+ });
676
+ return { k: "noop" };
677
+ }
678
+ const plan = codeMirrorPlan(codePane, winRatio);
679
+ scrollSyncTrace("code→doc.plan", {
680
+ reason: plan.k === "mirrorI" ? "source-gap-mirror-internal" : "source-gap-mirror-window",
681
+ line1,
682
+ lineIdPrefix,
683
+ stickyLockAfter: sticky.sourceSticky.lockedId,
684
+ plan: formatCodeToDocPlanForLog(plan),
685
+ });
686
+ return plan;
216
687
  }
217
688
  function buildDocToCodeFlipPlanProportional(docPane) {
218
689
  if (paneUsesInternalYScroll(docPane)) {
@@ -412,7 +883,7 @@ function emptySearchBrowsePreviewInnerHtml(hint, rows, ctx) {
412
883
  function scrollDocToMarkdownLine0(docScrollEl, line0, mdLineCount) {
413
884
  const el = docScrollEl.querySelector(`#commentray-md-line-${String(line0)}`);
414
885
  if (el instanceof HTMLElement) {
415
- const top = Math.round(scrollTopToAlignChildTop(docScrollEl, el, 8));
886
+ const top = Math.round(scrollTopToAlignChildTop(docScrollEl, el, DUAL_PANE_BLOCK_REVEAL_LEAD_CSS_PX));
416
887
  const maxY = Math.round(Math.max(0, docScrollEl.scrollHeight - docScrollEl.clientHeight));
417
888
  docScrollEl.scrollTo({ top: clamp(top, 0, maxY), behavior: "smooth" });
418
889
  return;
@@ -488,6 +959,12 @@ function findSearchHitButton(leaf, searchResults) {
488
959
  }
489
960
  return null;
490
961
  }
962
+ function listSearchHitButtons(searchResults) {
963
+ return [...searchResults.querySelectorAll("button.hit")].filter((el) => el instanceof HTMLButtonElement);
964
+ }
965
+ function listDocumentedTreeFileLinks(treeHost) {
966
+ return [...treeHost.querySelectorAll("a.tree-file-link")].filter((el) => el instanceof HTMLAnchorElement);
967
+ }
491
968
  function scrollCodeHitToView(line) {
492
969
  const el = document.getElementById(`code-line-${String(line)}`);
493
970
  if (el)
@@ -561,12 +1038,77 @@ function emptyBrowsePreviewInnerHtml(scope, filePathLabel, mutable) {
561
1038
  const hint = emptyBrowsePreviewHint("commentray-and-paths", fb.length, fb.length, true);
562
1039
  return emptySearchBrowsePreviewInnerHtml(hint, fb, hitCtx);
563
1040
  }
1041
+ function wireSearchResultsHitListKeyboard(searchResults, searchInput) {
1042
+ searchResults.addEventListener("keydown", (e) => {
1043
+ if (e.isComposing || searchResults.hidden)
1044
+ return;
1045
+ const hits = listSearchHitButtons(searchResults);
1046
+ if (hits.length === 0)
1047
+ return;
1048
+ const active = document.activeElement;
1049
+ if (!(active instanceof HTMLButtonElement) || !active.classList.contains("hit"))
1050
+ return;
1051
+ const idx = hits.indexOf(active);
1052
+ if (idx < 0)
1053
+ return;
1054
+ if (e.key === "ArrowDown" && idx < hits.length - 1) {
1055
+ hits[idx + 1].focus({ preventScroll: true });
1056
+ e.preventDefault();
1057
+ return;
1058
+ }
1059
+ if (e.key === "ArrowUp") {
1060
+ if (idx > 0) {
1061
+ hits[idx - 1].focus({ preventScroll: true });
1062
+ e.preventDefault();
1063
+ return;
1064
+ }
1065
+ searchInput.focus({ preventScroll: true });
1066
+ e.preventDefault();
1067
+ }
1068
+ });
1069
+ }
1070
+ function wireSearchInputKeyboard(searchInput, searchResults, actions) {
1071
+ const { renderEmptyBrowsePreview, runSearch, cancelDebounceTimer, hitClickDeps } = actions;
1072
+ searchInput.addEventListener("keydown", (e) => {
1073
+ if (e.isComposing)
1074
+ return;
1075
+ if (e.key === "ArrowDown") {
1076
+ if (!searchResults.hidden) {
1077
+ const hits = listSearchHitButtons(searchResults);
1078
+ if (hits.length > 0 && document.activeElement === searchInput) {
1079
+ hits[0].focus({ preventScroll: true });
1080
+ e.preventDefault();
1081
+ return;
1082
+ }
1083
+ }
1084
+ if (tokenizeQuery(searchInput.value).length > 0)
1085
+ return;
1086
+ renderEmptyBrowsePreview();
1087
+ e.preventDefault();
1088
+ return;
1089
+ }
1090
+ if (e.key !== "Enter")
1091
+ return;
1092
+ cancelDebounceTimer();
1093
+ if (tokenizeQuery(searchInput.value).length > 0) {
1094
+ runSearch();
1095
+ }
1096
+ const hits = listSearchHitButtons(searchResults);
1097
+ if (!searchResults.hidden && hits.length > 0 && document.activeElement === searchInput) {
1098
+ e.preventDefault();
1099
+ handleSearchHitButtonClick(hits[0], hitClickDeps);
1100
+ }
1101
+ });
1102
+ }
564
1103
  function wireSearchUi(ctx) {
565
1104
  const { scope, filePathLabel, mutable, rawCode, searchInput, searchClear, searchResults, docScrollEl, } = ctx;
566
1105
  let debounceTimer;
567
- function clearSearch() {
1106
+ function cancelDebounceTimer() {
568
1107
  clearTimeout(debounceTimer);
569
1108
  debounceTimer = undefined;
1109
+ }
1110
+ function clearSearch() {
1111
+ cancelDebounceTimer();
570
1112
  searchInput.value = "";
571
1113
  searchResults.innerHTML = "";
572
1114
  searchResults.hidden = true;
@@ -610,17 +1152,16 @@ function wireSearchUi(ctx) {
610
1152
  return;
611
1153
  handleSearchHitButtonClick(hit, hitClickDeps);
612
1154
  });
1155
+ wireSearchResultsHitListKeyboard(searchResults, searchInput);
613
1156
  searchInput.addEventListener("input", () => {
614
1157
  clearTimeout(debounceTimer);
615
1158
  debounceTimer = setTimeout(runSearch, 200);
616
1159
  });
617
- searchInput.addEventListener("keydown", (e) => {
618
- if (e.key !== "ArrowDown")
619
- return;
620
- if (tokenizeQuery(searchInput.value).length > 0)
621
- return;
622
- renderEmptyBrowsePreview();
623
- e.preventDefault();
1160
+ wireSearchInputKeyboard(searchInput, searchResults, {
1161
+ renderEmptyBrowsePreview,
1162
+ runSearch,
1163
+ cancelDebounceTimer,
1164
+ hitClickDeps,
624
1165
  });
625
1166
  searchClear.addEventListener("click", clearSearch);
626
1167
  document.addEventListener("keydown", (e) => {
@@ -726,13 +1267,13 @@ function parseScrollBlockLinksFromShell(b64) {
726
1267
  return [];
727
1268
  }
728
1269
  }
729
- function rootScrollNearDocumentEnd(edgePx = 56) {
1270
+ function rootScrollNearDocumentEnd(edgePx = READING_VIEWPORT_BOTTOM_EDGE_CSS_PX) {
730
1271
  const root = rootScrollingElement();
731
1272
  const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
732
1273
  return maxY > 0 && root.scrollTop >= maxY - edgePx;
733
1274
  }
734
1275
  /** When the pane itself is the scrollport (dual desktop), mirror root “near end” behavior. */
735
- function paneScrollNearEnd(pane, edgePx = 56) {
1276
+ function paneScrollNearEnd(pane, edgePx = READING_VIEWPORT_BOTTOM_EDGE_CSS_PX) {
736
1277
  const maxY = Math.max(0, pane.scrollHeight - pane.clientHeight);
737
1278
  return maxY > 0 && pane.scrollTop >= maxY - edgePx;
738
1279
  }
@@ -753,10 +1294,15 @@ function bestCommentrayAnchorLine0AtOrAboveY(anchors, y) {
753
1294
  return maxCommentrayAnchorLine0AtOrAboveViewportY(readings, y);
754
1295
  }
755
1296
  function lastCommentrayAnchorLine0(anchors) {
756
- const last = anchors[anchors.length - 1];
757
- if (!last)
758
- return 0;
759
- return readCommentrayLine0FromAnchor(last) ?? 0;
1297
+ for (let i = anchors.length - 1; i >= 0; i--) {
1298
+ const el = anchors.item(i);
1299
+ if (!el)
1300
+ continue;
1301
+ const v = readCommentrayLine0FromAnchor(el);
1302
+ if (v !== null)
1303
+ return v;
1304
+ }
1305
+ return null;
760
1306
  }
761
1307
  function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
762
1308
  const rows = codePane.querySelectorAll(`[id^="${lineIdPrefix}"]`);
@@ -774,7 +1320,7 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
774
1320
  const vh = globalThis.innerHeight;
775
1321
  const clipT = Math.max(0, sr.top);
776
1322
  const clipB = Math.min(vh, sr.bottom);
777
- const y = clipT + Math.max(2, Math.min(40, (clipB - clipT) * 0.15));
1323
+ const y = clipT + readingViewportTopInsetCssPx(clipB - clipT);
778
1324
  for (const el of rows) {
779
1325
  const r = el.getBoundingClientRect();
780
1326
  if (r.bottom > y - 1e-3) {
@@ -794,7 +1340,7 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
794
1340
  return rows.length;
795
1341
  }
796
1342
  const sr = codePane.getBoundingClientRect();
797
- const y = sr.top + codePane.clientTop + 2;
1343
+ const y = sr.top + codePane.clientTop + readingViewportTopInsetCssPx(codePane.clientHeight);
798
1344
  for (const el of rows) {
799
1345
  const r = el.getBoundingClientRect();
800
1346
  if (r.bottom > y - 1e-3) {
@@ -809,22 +1355,28 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
809
1355
  function probeCommentrayLine0FromDoc(docPane) {
810
1356
  const anchors = docPane.querySelectorAll(".commentray-block-anchor");
811
1357
  if (anchors.length === 0)
812
- return 0;
1358
+ return null;
813
1359
  if (!paneUsesInternalYScroll(docPane)) {
814
- if (rootScrollNearDocumentEnd())
815
- return lastCommentrayAnchorLine0(anchors);
1360
+ if (rootScrollNearDocumentEnd()) {
1361
+ const tail = lastCommentrayAnchorLine0(anchors);
1362
+ if (tail !== null)
1363
+ return tail;
1364
+ }
816
1365
  const dr = docPane.getBoundingClientRect();
817
1366
  const vh = globalThis.innerHeight;
818
1367
  const clipT = Math.max(0, dr.top);
819
1368
  const clipB = Math.min(vh, dr.bottom);
820
- const y = clipT + Math.max(2, Math.min(40, (clipB - clipT) * 0.15));
1369
+ const y = clipT + readingViewportTopInsetCssPx(clipB - clipT);
821
1370
  return bestCommentrayAnchorLine0AtOrAboveY(anchors, y);
822
1371
  }
823
- if (paneScrollNearEnd(docPane))
824
- return lastCommentrayAnchorLine0(anchors);
1372
+ if (paneScrollNearEnd(docPane)) {
1373
+ const tail = lastCommentrayAnchorLine0(anchors);
1374
+ if (tail !== null)
1375
+ return tail;
1376
+ }
825
1377
  const dr = docPane.getBoundingClientRect();
826
- /** Same band as the root-scroll branch: a few px below the pane top so block anchors sit inside `top <= y` while their prose is what the reader sees first. */
827
- const y = dr.top + docPane.clientTop + Math.max(2, Math.min(40, docPane.clientHeight * 0.15));
1378
+ /** Same band as the root-scroll branch: inset below the pane top so anchors qualify while body text sits in the comfort zone. */
1379
+ const y = dr.top + docPane.clientTop + readingViewportTopInsetCssPx(docPane.clientHeight);
828
1380
  return bestCommentrayAnchorLine0AtOrAboveY(anchors, y);
829
1381
  }
830
1382
  function pageBreakPullEnabled() {
@@ -839,10 +1391,10 @@ function docProbeTopY(docPane) {
839
1391
  const vh = globalThis.innerHeight;
840
1392
  const clipT = Math.max(0, dr.top);
841
1393
  const clipB = Math.min(vh, dr.bottom);
842
- return clipT + Math.max(2, Math.min(40, (clipB - clipT) * 0.15));
1394
+ return clipT + readingViewportTopInsetCssPx(clipB - clipT);
843
1395
  }
844
1396
  const dr = docPane.getBoundingClientRect();
845
- return dr.top + docPane.clientTop + 2;
1397
+ return dr.top + docPane.clientTop + readingViewportTopInsetCssPx(docPane.clientHeight);
846
1398
  }
847
1399
  /**
848
1400
  * In long synthetic page-break gaps, shift source toward the next block once
@@ -852,13 +1404,13 @@ function pulledSourceLine0FromPageBreak(docPane) {
852
1404
  if (!pageBreakPullEnabled())
853
1405
  return null;
854
1406
  const topY = docProbeTopY(docPane);
855
- const breaks = Array.from(docPane.querySelectorAll(".commentray-page-break[data-next-source-start]"));
1407
+ const breaks = Array.from(docPane.querySelectorAll(".commentray-page-break[data-next-source-viewport-line]"));
856
1408
  for (const pageBreak of breaks) {
857
- const nextSourceStartRaw = pageBreak.getAttribute("data-next-source-start");
858
- if (!nextSourceStartRaw)
1409
+ const nextViewportLineRaw = pageBreak.getAttribute("data-next-source-viewport-line");
1410
+ if (!nextViewportLineRaw)
859
1411
  continue;
860
- const nextSourceStart = Number.parseInt(nextSourceStartRaw, 10);
861
- if (!Number.isFinite(nextSourceStart) || nextSourceStart <= 0)
1412
+ const nextViewportLine1Based = Number.parseInt(nextViewportLineRaw, 10);
1413
+ if (!Number.isFinite(nextViewportLine1Based) || nextViewportLine1Based <= 0)
862
1414
  continue;
863
1415
  const breakTop = pageBreak.getBoundingClientRect().top;
864
1416
  const nextLineRaw = pageBreak.getAttribute("data-next-commentray-line");
@@ -877,91 +1429,251 @@ function pulledSourceLine0FromPageBreak(docPane) {
877
1429
  const pullThreshold = narrow ? 0.2 : 0.35;
878
1430
  if (progress < pullThreshold)
879
1431
  return null;
880
- return nextSourceStart - 1;
1432
+ return nextViewportLine1Based - 1;
881
1433
  }
882
1434
  return null;
883
1435
  }
884
1436
  /**
885
- * Programmatic `scrollTop` on the partner pane can emit a `scroll` event after the
886
- * synchronous `syncing` guard is cleared; that late event would mirror back and
887
- * jerk the pane the user is scrolling. We arm a short-lived skip on the partner
888
- * before each sync-driven update, and release one skip after two rAFs if no event
889
- * consumed it (e.g. `applyScrollTopClamped` no-oped).
1437
+ * After we move the **partner** pane from a driver sync, its `scroll` handler must not run the
1438
+ * opposite mapper until things settle one user wheel can produce dozens of programmatic scroll
1439
+ * events (layout, smooth interpolation, nested scrollports). A **fixed event budget** was
1440
+ * exhausted mid-gesture and caused doc↔code ping-pong. We extend a **deadline** instead; each driver
1441
+ * sync pushes `until` forward so continuous scrolling stays stable.
890
1442
  */
891
- function armIgnoreNextPaneScrollReaction(armed) {
892
- armed.n++;
893
- queueMicrotask(() => {
894
- requestAnimationFrame(() => {
895
- requestAnimationFrame(() => {
896
- armed.n = Math.max(0, armed.n - 1);
897
- });
898
- });
1443
+ /**
1444
+ * Ignore partner `scroll` echoes after a driver sync. Must cover the tail of
1445
+ * tail of a partner programmatic scroll; otherwise the partner’s frames can be treated as a driver
1446
+ * and the panes ping-pong. Uses {@link SMOOTH_REVEAL_INFLIGHT_DEDUP_MS} as a conservative ceiling
1447
+ * for any remaining smooth paths (e.g. hash navigation helpers).
1448
+ */
1449
+ const PARTNER_SCROLL_ECHO_SUPPRESS_MS = Math.max(320, SMOOTH_REVEAL_INFLIGHT_DEDUP_MS + 250);
1450
+ function armPartnerScrollEchoGate(gate) {
1451
+ const next = performance.now() + PARTNER_SCROLL_ECHO_SUPPRESS_MS;
1452
+ gate.until = Math.max(gate.until, next);
1453
+ }
1454
+ function partnerScrollEchoActive(gate) {
1455
+ return performance.now() < gate.until;
1456
+ }
1457
+ /** Throttle for the `wire.*.flush-skipped` `partner-echo` log line — without it we get one per animation frame. */
1458
+ const WIRE_ECHO_SKIP_TRACE_MIN_MS = 200;
1459
+ function emitFlushSkippedPartnerEcho(axis, state, ownEchoGate, driverPane) {
1460
+ const t = performance.now();
1461
+ if (!scrollSyncTraceFeatureFlag() ||
1462
+ t - state.lastEchoSkipTraceAt < WIRE_ECHO_SKIP_TRACE_MIN_MS) {
1463
+ return;
1464
+ }
1465
+ state.lastEchoSkipTraceAt = t;
1466
+ scrollSyncTrace(`wire.${axis}.flush-skipped`, {
1467
+ reason: "partner-echo",
1468
+ echoUntilMs: Math.round(ownEchoGate.until),
1469
+ [`${axis}ScrollTop`]: driverPane.scrollTop,
899
1470
  });
900
1471
  }
901
- function wireBidirectionalScroll(codePane, docPane, syncFromCode, syncFromDoc) {
902
- let syncing = "none";
903
- const ignoreCodeScrollFromPartnerSync = { n: 0 };
904
- const ignoreDocScrollFromPartnerSync = { n: 0 };
905
- codePane.addEventListener("scroll", () => {
906
- if (ignoreCodeScrollFromPartnerSync.n > 0) {
907
- ignoreCodeScrollFromPartnerSync.n--;
1472
+ /** Builds the RAF flush closure that consumes one driver pane's coalesced scroll bursts. */
1473
+ function makeDriverFlush(args) {
1474
+ const { axis, driverPane, state, syncingRef, ownEchoGate, partnerEchoGate, syncFromDriver } = args;
1475
+ return () => {
1476
+ state.pendingRaf = 0;
1477
+ if (partnerScrollEchoActive(ownEchoGate)) {
1478
+ emitFlushSkippedPartnerEcho(axis, state, ownEchoGate, driverPane);
1479
+ state.lastSeenTop = driverPane.scrollTop;
908
1480
  return;
909
1481
  }
910
- if (syncing === "doc")
1482
+ if (syncingRef.current !== "none") {
1483
+ scrollSyncTrace(`wire.${axis}.flush-skipped`, {
1484
+ reason: "sync-in-progress",
1485
+ syncing: syncingRef.current,
1486
+ });
911
1487
  return;
912
- syncing = "code";
913
- armIgnoreNextPaneScrollReaction(ignoreDocScrollFromPartnerSync);
914
- syncFromCode();
915
- syncing = "none";
916
- }, { passive: true });
917
- docPane.addEventListener("scroll", () => {
918
- if (ignoreDocScrollFromPartnerSync.n > 0) {
919
- ignoreDocScrollFromPartnerSync.n--;
1488
+ }
1489
+ const now = driverPane.scrollTop;
1490
+ const delta = now - state.lastSeenTop;
1491
+ state.lastSeenTop = now;
1492
+ syncingRef.current = axis;
1493
+ armPartnerScrollEchoGate(partnerEchoGate);
1494
+ scrollSyncTrace(`wire.${axis}.driver`, { delta, [`${axis}ScrollTop`]: now });
1495
+ syncFromDriver(delta);
1496
+ syncingRef.current = "none";
1497
+ };
1498
+ }
1499
+ /** Installs the `scroll` listener on one driver pane that schedules at most one flush per frame. */
1500
+ function attachDriverScrollListener(args) {
1501
+ const { driverPane, state, syncingRef, ownEchoGate, flush } = args;
1502
+ driverPane.addEventListener("scroll", () => {
1503
+ if (partnerScrollEchoActive(ownEchoGate)) {
1504
+ state.lastSeenTop = driverPane.scrollTop;
1505
+ if (state.pendingRaf !== 0) {
1506
+ cancelAnimationFrame(state.pendingRaf);
1507
+ state.pendingRaf = 0;
1508
+ }
920
1509
  return;
921
1510
  }
922
- if (syncing === "code")
1511
+ if (syncingRef.current !== "none") {
1512
+ state.lastSeenTop = driverPane.scrollTop;
923
1513
  return;
924
- syncing = "doc";
925
- armIgnoreNextPaneScrollReaction(ignoreCodeScrollFromPartnerSync);
926
- syncFromDoc();
927
- syncing = "none";
1514
+ }
1515
+ if (state.pendingRaf !== 0) {
1516
+ cancelAnimationFrame(state.pendingRaf);
1517
+ }
1518
+ state.pendingRaf = requestAnimationFrame(flush);
928
1519
  }, { passive: true });
929
1520
  }
1521
+ /**
1522
+ * Coalesces high-frequency `scroll` bursts into **one** partner sync per pane per animation
1523
+ * frame so block-aware snaps do not chain immediate reverse syncs (“slot machine” feel).
1524
+ */
1525
+ function wireBidirectionalScroll(codePane, docPane, syncFromCode, syncFromDoc) {
1526
+ const syncingRef = { current: "none" };
1527
+ /** Code pane: ignore scroll echoes while we are applying a doc→code partner update. */
1528
+ const suppressCodeScrollEcho = { until: 0 };
1529
+ /** Doc pane: ignore scroll echoes while we are applying a code→doc partner update. */
1530
+ const suppressDocScrollEcho = { until: 0 };
1531
+ const codeState = {
1532
+ lastSeenTop: codePane.scrollTop,
1533
+ pendingRaf: 0,
1534
+ lastEchoSkipTraceAt: -Infinity,
1535
+ };
1536
+ const docState = {
1537
+ lastSeenTop: docPane.scrollTop,
1538
+ pendingRaf: 0,
1539
+ lastEchoSkipTraceAt: -Infinity,
1540
+ };
1541
+ const flushCode = makeDriverFlush({
1542
+ axis: "code",
1543
+ driverPane: codePane,
1544
+ state: codeState,
1545
+ syncingRef,
1546
+ ownEchoGate: suppressCodeScrollEcho,
1547
+ partnerEchoGate: suppressDocScrollEcho,
1548
+ syncFromDriver: syncFromCode,
1549
+ });
1550
+ const flushDoc = makeDriverFlush({
1551
+ axis: "doc",
1552
+ driverPane: docPane,
1553
+ state: docState,
1554
+ syncingRef,
1555
+ ownEchoGate: suppressDocScrollEcho,
1556
+ partnerEchoGate: suppressCodeScrollEcho,
1557
+ syncFromDriver: syncFromDoc,
1558
+ });
1559
+ attachDriverScrollListener({
1560
+ driverPane: codePane,
1561
+ state: codeState,
1562
+ syncingRef,
1563
+ ownEchoGate: suppressCodeScrollEcho,
1564
+ flush: flushCode,
1565
+ });
1566
+ attachDriverScrollListener({
1567
+ driverPane: docPane,
1568
+ state: docState,
1569
+ syncingRef,
1570
+ ownEchoGate: suppressDocScrollEcho,
1571
+ flush: flushDoc,
1572
+ });
1573
+ }
1574
+ function blockAwareSyncFromCodeToDoc(bundle, driverDelta) {
1575
+ const { codePane, docPane, getLinks, lineIdPrefix, sticky } = bundle;
1576
+ const docBefore = readPaneVerticalScroll(docPane);
1577
+ const prefix = lineIdPrefix();
1578
+ const p = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, prefix, sticky, bundle.allowProportionalMirror);
1579
+ applyCodeToDocFlipPlanImpl(codePane, docPane, p);
1580
+ const docAfter = readPaneVerticalScroll(docPane);
1581
+ scrollSyncTrace("code→doc.apply", {
1582
+ driverDelta,
1583
+ lineIdPrefix: prefix,
1584
+ plan: formatCodeToDocPlanForLog(p),
1585
+ docScrollTopBefore: docBefore,
1586
+ docScrollTopAfter: docAfter,
1587
+ docDelta: docAfter - docBefore,
1588
+ codeScrollTop: codePane.scrollTop,
1589
+ });
1590
+ enforceScrollSyncMonotonic({
1591
+ driverDelta,
1592
+ partnerBefore: docBefore,
1593
+ partnerPane: docPane,
1594
+ axis: "code→doc",
1595
+ });
1596
+ }
1597
+ function blockAwareSyncFromDocToCode(bundle, driverDelta) {
1598
+ const { codePane, docPane, getLinks, lineIdPrefix, sticky } = bundle;
1599
+ const codeBefore = readPaneVerticalScroll(codePane);
1600
+ const prefix = lineIdPrefix();
1601
+ const p = buildDocToCodeFlipPlanBlockAware(docPane, codePane, getLinks, sticky, prefix, bundle.allowProportionalMirror);
1602
+ applyDocToCodeFlipPlanImpl(codePane, docPane, p, prefix);
1603
+ const codeAfter = readPaneVerticalScroll(codePane);
1604
+ scrollSyncTrace("doc→code.apply", {
1605
+ driverDelta,
1606
+ lineIdPrefix: prefix,
1607
+ plan: formatDocToCodePlanForLog(p),
1608
+ codeScrollTopBefore: codeBefore,
1609
+ codeScrollTopAfter: codeAfter,
1610
+ codeDelta: codeAfter - codeBefore,
1611
+ docScrollTop: docPane.scrollTop,
1612
+ });
1613
+ enforceScrollSyncMonotonic({
1614
+ driverDelta,
1615
+ partnerBefore: codeBefore,
1616
+ partnerPane: codePane,
1617
+ axis: "doc→code",
1618
+ });
1619
+ }
930
1620
  /** Index-backed scroll sync when `data-scroll-block-links-b64` is present; else see proportional fallback. */
931
- function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, shouldUseProportionalDocToCodeOnMobileFlip) {
1621
+ function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, shouldUseProportionalDocToCodeOnMobileFlip, options) {
1622
+ const allowProportionalMirror = options?.allowProportionalMirror !== false;
932
1623
  let pendingDocToCode = null;
933
1624
  let pendingCodeToDoc = null;
1625
+ const sticky = {
1626
+ sourceSticky: { lockedId: null },
1627
+ commentraySticky: { lockedId: null },
1628
+ linksKey: { current: "" },
1629
+ };
1630
+ const bundle = {
1631
+ codePane,
1632
+ docPane,
1633
+ getLinks,
1634
+ lineIdPrefix,
1635
+ sticky,
1636
+ allowProportionalMirror,
1637
+ };
1638
+ const syncFromCodeToDocInner = (d) => blockAwareSyncFromCodeToDoc(bundle, d);
1639
+ const syncFromDocToCodeInner = (d) => blockAwareSyncFromDocToCode(bundle, d);
934
1640
  const syncFromCodeToDoc = () => {
935
- applyCodeToDocFlipPlanImpl(codePane, docPane, buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix()));
1641
+ blockAwareSyncFromCodeToDoc(bundle, 0);
936
1642
  };
937
1643
  const syncFromDocToCode = () => {
938
- applyDocToCodeFlipPlanImpl(codePane, docPane, buildDocToCodeFlipPlanBlockAware(docPane, getLinks), lineIdPrefix());
1644
+ blockAwareSyncFromDocToCode(bundle, 0);
939
1645
  };
940
1646
  const prepareMobileFlipToCode = () => {
941
- if (shouldUseProportionalDocToCodeOnMobileFlip?.() === true) {
1647
+ if (allowProportionalMirror && shouldUseProportionalDocToCodeOnMobileFlip?.() === true) {
942
1648
  pendingDocToCode = { k: "mirrorW", ratio: windowScrollRatio() };
943
1649
  return;
944
1650
  }
945
- pendingDocToCode = buildDocToCodeFlipPlanBlockAware(docPane, getLinks);
1651
+ pendingDocToCode = buildDocToCodeFlipPlanBlockAware(docPane, codePane, getLinks, sticky, lineIdPrefix(), allowProportionalMirror);
946
1652
  };
947
1653
  const finishMobileFlipToCode = () => {
948
1654
  if (!pendingDocToCode)
949
1655
  return;
950
- const p = pendingDocToCode;
1656
+ let p = pendingDocToCode;
951
1657
  pendingDocToCode = null;
1658
+ if (p.k === "noop" && p.skipProportionalFallbackOnFlip !== true && allowProportionalMirror) {
1659
+ p = buildDocToCodeFlipPlanProportional(docPane);
1660
+ }
952
1661
  applyDocToCodeFlipPlanImpl(codePane, docPane, p, lineIdPrefix());
953
1662
  };
954
1663
  const prepareMobileFlipToDoc = () => {
955
- pendingCodeToDoc = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix());
1664
+ pendingCodeToDoc = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix(), sticky, allowProportionalMirror);
956
1665
  };
957
1666
  const finishMobileFlipToDoc = () => {
958
1667
  if (!pendingCodeToDoc)
959
1668
  return;
960
- const p = pendingCodeToDoc;
1669
+ let p = pendingCodeToDoc;
961
1670
  pendingCodeToDoc = null;
1671
+ if (p.k === "noop" && allowProportionalMirror) {
1672
+ p = buildCodeToDocFlipPlanProportional(codePane);
1673
+ }
962
1674
  applyCodeToDocFlipPlanImpl(codePane, docPane, p);
963
1675
  };
964
- wireBidirectionalScroll(codePane, docPane, syncFromCodeToDoc, syncFromDocToCode);
1676
+ wireBidirectionalScroll(codePane, docPane, syncFromCodeToDocInner, syncFromDocToCodeInner);
965
1677
  return {
966
1678
  syncFromCodeToDoc,
967
1679
  syncFromDocToCode,
@@ -975,11 +1687,49 @@ function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, sho
975
1687
  function wireProportionalScrollSync(codePane, docPane) {
976
1688
  let pendingDocToCode = null;
977
1689
  let pendingCodeToDoc = null;
1690
+ const syncFromCodeToDocInner = (driverDelta) => {
1691
+ const docBefore = readPaneVerticalScroll(docPane);
1692
+ const p = buildCodeToDocFlipPlanProportional(codePane);
1693
+ applyCodeToDocFlipPlanImpl(codePane, docPane, p);
1694
+ scrollSyncTrace("code→doc.apply", {
1695
+ mode: "proportional-shell",
1696
+ driverDelta,
1697
+ plan: formatCodeToDocPlanForLog(p),
1698
+ docScrollTopBefore: docBefore,
1699
+ docScrollTopAfter: readPaneVerticalScroll(docPane),
1700
+ codeScrollTop: codePane.scrollTop,
1701
+ });
1702
+ enforceScrollSyncMonotonic({
1703
+ driverDelta,
1704
+ partnerBefore: docBefore,
1705
+ partnerPane: docPane,
1706
+ axis: "code→doc",
1707
+ });
1708
+ };
1709
+ const syncFromDocToCodeInner = (driverDelta) => {
1710
+ const codeBefore = readPaneVerticalScroll(codePane);
1711
+ const p = buildDocToCodeFlipPlanProportional(docPane);
1712
+ applyDocToCodeFlipPlanImpl(codePane, docPane, p);
1713
+ scrollSyncTrace("doc→code.apply", {
1714
+ mode: "proportional-shell",
1715
+ driverDelta,
1716
+ plan: formatDocToCodePlanForLog(p),
1717
+ codeScrollTopBefore: codeBefore,
1718
+ codeScrollTopAfter: readPaneVerticalScroll(codePane),
1719
+ docScrollTop: docPane.scrollTop,
1720
+ });
1721
+ enforceScrollSyncMonotonic({
1722
+ driverDelta,
1723
+ partnerBefore: codeBefore,
1724
+ partnerPane: codePane,
1725
+ axis: "doc→code",
1726
+ });
1727
+ };
978
1728
  const syncFromCodeToDoc = () => {
979
- applyCodeToDocFlipPlanImpl(codePane, docPane, buildCodeToDocFlipPlanProportional(codePane));
1729
+ syncFromCodeToDocInner(0);
980
1730
  };
981
1731
  const syncFromDocToCode = () => {
982
- applyDocToCodeFlipPlanImpl(codePane, docPane, buildDocToCodeFlipPlanProportional(docPane));
1732
+ syncFromDocToCodeInner(0);
983
1733
  };
984
1734
  const prepareMobileFlipToCode = () => {
985
1735
  pendingDocToCode = buildDocToCodeFlipPlanProportional(docPane);
@@ -1001,7 +1751,7 @@ function wireProportionalScrollSync(codePane, docPane) {
1001
1751
  pendingCodeToDoc = null;
1002
1752
  applyCodeToDocFlipPlanImpl(codePane, docPane, p);
1003
1753
  };
1004
- wireBidirectionalScroll(codePane, docPane, syncFromCodeToDoc, syncFromDocToCode);
1754
+ wireBidirectionalScroll(codePane, docPane, syncFromCodeToDocInner, syncFromDocToCodeInner);
1005
1755
  return {
1006
1756
  syncFromCodeToDoc,
1007
1757
  syncFromDocToCode,
@@ -1032,9 +1782,7 @@ function commentaryBandEndYViewport(docScrollEl, next, docTop, clipThroughPageBr
1032
1782
  const nextTop = nextEl.getBoundingClientRect().top - 3;
1033
1783
  if (!clipThroughPageBreakGaps)
1034
1784
  return nextTop;
1035
- const docBandTop = docTop.getBoundingClientRect().top + 4;
1036
- const contentBottom = maxRenderableCommentaryContentBottomViewport(docScrollEl, docTop, nextEl);
1037
- return Math.min(nextTop, Math.max(docBandTop, contentBottom));
1785
+ return commentaryGutterDocBandBottomViewport(docScrollEl, docTop, nextEl);
1038
1786
  }
1039
1787
  const dr = docScrollEl.getBoundingClientRect();
1040
1788
  let bottom = dr.bottom - 4;
@@ -1107,6 +1855,15 @@ function subscribeBlockRayRedraw(gutter, codePane, docScrollEl, scheduleDraw) {
1107
1855
  if (shell)
1108
1856
  ro.observe(shell);
1109
1857
  }
1858
+ /** Doc-aligned active block matches visible commentary; code-only probe can lag in page gaps. */
1859
+ function activeBlockIdForGutterRays(links, docScrollEl, probeTopSourceLine1Based) {
1860
+ const mdLine0ForRay = probeCommentrayLine0FromDoc(docScrollEl);
1861
+ const haveAnchors = docScrollEl.querySelector(".commentray-block-anchor") !== null;
1862
+ if (haveAnchors && mdLine0ForRay !== null) {
1863
+ return activeBlockIdForCommentrayLine0(links, mdLine0ForRay);
1864
+ }
1865
+ return activeBlockIdForViewport(links, probeTopSourceLine1Based());
1866
+ }
1110
1867
  function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSourceLine1Based, lineIdPrefix) {
1111
1868
  const links = dedupeBlockScrollLinksById(getLinks());
1112
1869
  const sorted = sortBlockLinksBySource(links);
@@ -1117,10 +1874,7 @@ function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSource
1117
1874
  svg.replaceChildren();
1118
1875
  return;
1119
1876
  }
1120
- /** Doc-aligned active block matches visible commentary; code-only probe can lag in page gaps. */
1121
- const activeId = docScrollEl.querySelector(".commentray-block-anchor") !== null
1122
- ? activeBlockIdForCommentrayLine0(links, probeCommentrayLine0FromDoc(docScrollEl))
1123
- : activeBlockIdForViewport(links, probeTopSourceLine1Based());
1877
+ const activeId = activeBlockIdForGutterRays(links, docScrollEl, probeTopSourceLine1Based);
1124
1878
  const clipGutterRaysThroughPageBreakGaps = pageBreakPullEnabled();
1125
1879
  svg.setAttribute("viewBox", `0 0 ${String(w)} ${String(h)}`);
1126
1880
  svg.setAttribute("preserveAspectRatio", "none");
@@ -1569,12 +2323,52 @@ function wireDocumentedFilesTree() {
1569
2323
  sum.focus({ preventScroll: true });
1570
2324
  }
1571
2325
  document.addEventListener("keydown", onDocumentedFilesHubEscape, true);
2326
+ treeMount.addEventListener("keydown", (e) => {
2327
+ if (!detailsHub.open || e.isComposing)
2328
+ return;
2329
+ const t = e.target;
2330
+ if (!(t instanceof HTMLAnchorElement) || !t.classList.contains("tree-file-link"))
2331
+ return;
2332
+ const links = listDocumentedTreeFileLinks(treeMount);
2333
+ if (links.length === 0)
2334
+ return;
2335
+ const idx = links.indexOf(t);
2336
+ if (idx < 0)
2337
+ return;
2338
+ if (e.key === "ArrowDown") {
2339
+ if (idx < links.length - 1) {
2340
+ links[idx + 1].focus({ preventScroll: true });
2341
+ e.preventDefault();
2342
+ }
2343
+ return;
2344
+ }
2345
+ if (e.key === "ArrowUp") {
2346
+ if (idx > 0) {
2347
+ links[idx - 1].focus({ preventScroll: true });
2348
+ e.preventDefault();
2349
+ return;
2350
+ }
2351
+ if (filterInput instanceof HTMLInputElement) {
2352
+ filterInput.focus({ preventScroll: true });
2353
+ e.preventDefault();
2354
+ }
2355
+ }
2356
+ });
1572
2357
  if (filterInput instanceof HTMLInputElement) {
1573
2358
  filterInput.addEventListener("input", () => {
1574
2359
  if (!detailsHub.open || cachedPairs === null)
1575
2360
  return;
1576
2361
  applyFilterAndRender();
1577
2362
  });
2363
+ filterInput.addEventListener("keydown", (e) => {
2364
+ if (!detailsHub.open || e.isComposing || e.key !== "ArrowDown")
2365
+ return;
2366
+ const links = listDocumentedTreeFileLinks(treeMount);
2367
+ if (links.length === 0)
2368
+ return;
2369
+ links[0].focus({ preventScroll: true });
2370
+ e.preventDefault();
2371
+ });
1578
2372
  }
1579
2373
  }
1580
2374
  function wireSplitter(storageSplit, shell, codePane, gutter, initialPct) {
@@ -1620,6 +2414,13 @@ function normalizedDualMobilePane(v) {
1620
2414
  function isNarrowViewport() {
1621
2415
  return globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ).matches;
1622
2416
  }
2417
+ function syncSinglePaneShellState(shell, narrowSinglePane) {
2418
+ if (narrowSinglePane) {
2419
+ shell.setAttribute("data-mobile-single-pane", "true");
2420
+ return;
2421
+ }
2422
+ shell.removeAttribute("data-mobile-single-pane");
2423
+ }
1623
2424
  function wireWideModeIntroTrigger(shell) {
1624
2425
  const btn = document.getElementById("commentray-help-tour");
1625
2426
  if (!(btn instanceof HTMLButtonElement))
@@ -1677,7 +2478,7 @@ function scheduleMermaidWhenDualDocPaneVisible(shell, mq) {
1677
2478
  const docBody = document.getElementById("doc-pane-body");
1678
2479
  if (!(docBody instanceof HTMLElement))
1679
2480
  return;
1680
- runMermaidOnFreshDocNodes(docBody);
2481
+ void runMermaidOnFreshDocNodes(docBody);
1681
2482
  };
1682
2483
  queueMicrotask(() => {
1683
2484
  kick();
@@ -1716,7 +2517,7 @@ function wireDualMobilePaneFlipScrollAffordance(primaryFlip, scrollFlip, mq) {
1716
2517
  mq.addEventListener("change", tick);
1717
2518
  globalThis.requestAnimationFrame(tick);
1718
2519
  }
1719
- function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
2520
+ function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip, signal) {
1720
2521
  const hideScroll = () => {
1721
2522
  scrollFlip.hidden = true;
1722
2523
  scrollFlip.classList.remove("is-visible");
@@ -1726,6 +2527,10 @@ function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
1726
2527
  scrollFlip.classList.add("is-visible");
1727
2528
  };
1728
2529
  const tick = () => {
2530
+ if (primaryFlip.hidden || primaryFlip.getClientRects().length === 0) {
2531
+ hideScroll();
2532
+ return;
2533
+ }
1729
2534
  const r = primaryFlip.getBoundingClientRect();
1730
2535
  const vh = globalThis.innerHeight;
1731
2536
  const margin = 10;
@@ -1735,10 +2540,27 @@ function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
1735
2540
  else
1736
2541
  hideScroll();
1737
2542
  };
1738
- globalThis.addEventListener("scroll", tick, { passive: true });
1739
- globalThis.addEventListener("resize", tick, { passive: true });
2543
+ globalThis.addEventListener("scroll", tick, { passive: true, signal });
2544
+ globalThis.addEventListener("resize", tick, { passive: true, signal });
1740
2545
  globalThis.requestAnimationFrame(tick);
1741
2546
  }
2547
+ function syncSourceMarkdownToggleVisibility(shell, primaryFlip, scrollFlip) {
2548
+ const hidden = isNarrowViewport() &&
2549
+ normalizedDualMobilePane(shell.getAttribute("data-dual-mobile-pane")) !== "code";
2550
+ primaryFlip.hidden = hidden;
2551
+ if (hidden) {
2552
+ primaryFlip.setAttribute("aria-hidden", "true");
2553
+ primaryFlip.style.setProperty("display", "none", "important");
2554
+ }
2555
+ else {
2556
+ primaryFlip.removeAttribute("aria-hidden");
2557
+ primaryFlip.style.removeProperty("display");
2558
+ }
2559
+ if (!(scrollFlip instanceof HTMLButtonElement))
2560
+ return;
2561
+ scrollFlip.hidden = true;
2562
+ scrollFlip.classList.remove("is-visible");
2563
+ }
1742
2564
  function closestSourceLine0ForPaneTop(codePane, idPrefix) {
1743
2565
  const rows = codePane.querySelectorAll(`[id^="${idPrefix}"]`);
1744
2566
  if (rows.length === 0)
@@ -1763,13 +2585,17 @@ function closestSourceLine0ForPaneTop(codePane, idPrefix) {
1763
2585
  return null;
1764
2586
  return Number.parseInt(m[1], 10);
1765
2587
  }
1766
- function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, onAfterFlip) {
2588
+ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, signal, onAfterFlip) {
2589
+ function sourceMarkdownBodies() {
2590
+ return Array.from(codePane.querySelectorAll('[data-source-markdown-body="true"]'));
2591
+ }
1767
2592
  function syncSourceMarkdownFlipA11y() {
1768
2593
  const mode = sourcePaneModeForShell(shell);
1769
2594
  const renderedActive = mode === "rendered-markdown";
1770
2595
  const nextModeLabel = renderedActive ? "markdown source" : "rendered markdown";
2596
+ const currentModeLabel = renderedActive ? "rendered markdown" : "markdown source";
1771
2597
  const ariaLabel = `Switch source pane to ${nextModeLabel}`;
1772
- const title = `Source pane: ${renderedActive ? "rendered markdown" : "markdown source"} (click to switch)`;
2598
+ const title = `Switch source pane to ${nextModeLabel} (currently ${currentModeLabel})`;
1773
2599
  const apply = (btn) => {
1774
2600
  if (!(btn instanceof HTMLButtonElement))
1775
2601
  return;
@@ -1780,10 +2606,21 @@ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, onA
1780
2606
  apply(flipBtn);
1781
2607
  apply(flipScrollBtn);
1782
2608
  }
1783
- // Keep initial behavior deterministic: source pane starts in rendered markdown mode.
1784
- shell.setAttribute("data-source-pane-mode", "rendered-markdown");
1785
2609
  syncSourceMarkdownFlipA11y();
1786
2610
  syncWrapLinesVisibilityForSourcePaneMode(shell);
2611
+ syncSourceMarkdownToggleVisibility(shell, flipBtn, flipScrollBtn);
2612
+ const onViewportChange = () => {
2613
+ syncSourceMarkdownToggleVisibility(shell, flipBtn, flipScrollBtn);
2614
+ };
2615
+ globalThis.addEventListener("resize", onViewportChange, { passive: true, signal });
2616
+ const mobileMq = globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ);
2617
+ mobileMq.addEventListener("change", onViewportChange, { signal });
2618
+ const observer = new MutationObserver(onViewportChange);
2619
+ observer.observe(shell, {
2620
+ attributes: true,
2621
+ attributeFilter: ["data-dual-mobile-pane", "data-source-pane-mode"],
2622
+ });
2623
+ signal.addEventListener("abort", () => observer.disconnect(), { once: true });
1787
2624
  const runFlip = () => {
1788
2625
  const cur = sourcePaneModeForShell(shell);
1789
2626
  const currentPrefix = cur === "rendered-markdown" ? "code-md-line-" : "code-line-";
@@ -1792,27 +2629,34 @@ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, onA
1792
2629
  const nextPrefix = next === "rendered-markdown" ? "code-md-line-" : "code-line-";
1793
2630
  shell.setAttribute("data-source-pane-mode", next);
1794
2631
  writeWebStorageItem(localStorage, STORAGE_SOURCE_MARKDOWN_PANE_MODE, next);
2632
+ if (isNarrowViewport()) {
2633
+ const curPane = normalizedDualMobilePane(shell.getAttribute("data-dual-mobile-pane"));
2634
+ if (curPane === "doc") {
2635
+ shell.setAttribute("data-dual-mobile-pane", "code");
2636
+ writeWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE, "code");
2637
+ }
2638
+ }
1795
2639
  syncSourceMarkdownFlipA11y();
1796
2640
  syncWrapLinesVisibilityForSourcePaneMode(shell);
1797
- if (line0 !== null) {
2641
+ const shouldWriteRevealScroll = !isNarrowViewport() || paneUsesInternalYScroll(codePane);
2642
+ if (line0 !== null && shouldWriteRevealScroll) {
1798
2643
  const row = codePane.querySelector(`#${nextPrefix}${String(line0)}`);
1799
2644
  if (row instanceof HTMLElement) {
1800
- applyRevealChildInPane(codePane, row, 2);
2645
+ applyRevealChildInPane(codePane, row, 0);
1801
2646
  }
1802
2647
  }
1803
2648
  if (next === "rendered-markdown") {
1804
- const sourceMdBody = document.getElementById("code-pane-markdown-body");
1805
- if (sourceMdBody instanceof HTMLElement) {
1806
- runMermaidOnFreshDocNodes(sourceMdBody);
2649
+ for (const sourceMdBody of sourceMarkdownBodies()) {
2650
+ void runMermaidOnFreshDocNodes(sourceMdBody);
1807
2651
  rewriteHubRelativeBrowseAnchorsIn(sourceMdBody);
1808
2652
  }
1809
2653
  }
1810
2654
  onAfterFlip?.();
1811
2655
  };
1812
- flipBtn.addEventListener("click", runFlip);
2656
+ flipBtn.addEventListener("click", runFlip, { signal });
1813
2657
  if (flipScrollBtn) {
1814
- flipScrollBtn.addEventListener("click", runFlip);
1815
- wireSourceMarkdownPaneFlipAffordance(flipBtn, flipScrollBtn);
2658
+ flipScrollBtn.addEventListener("click", runFlip, { signal });
2659
+ wireSourceMarkdownPaneFlipAffordance(flipBtn, flipScrollBtn, signal);
1816
2660
  }
1817
2661
  }
1818
2662
  function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
@@ -1822,9 +2666,11 @@ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
1822
2666
  }
1823
2667
  function applyForViewport() {
1824
2668
  if (mq.matches) {
2669
+ syncSinglePaneShellState(shell, true);
1825
2670
  shell.setAttribute("data-dual-mobile-pane", readStoredPane());
1826
2671
  }
1827
2672
  else {
2673
+ syncSinglePaneShellState(shell, false);
1828
2674
  shell.removeAttribute("data-dual-mobile-pane");
1829
2675
  }
1830
2676
  }
@@ -1870,13 +2716,282 @@ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
1870
2716
  mq.addEventListener("change", applyForViewport);
1871
2717
  applyForViewport();
1872
2718
  }
1873
- function wireStretchLayoutChrome(codePane) {
2719
+ function wireStretchMobilePaneFlip(shell, codePane, flipBtn, flipScrollBtn, onAfterFlip) {
2720
+ void codePane;
2721
+ void onAfterFlip;
2722
+ const mq = globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ);
2723
+ function readStoredPane() {
2724
+ return normalizedDualMobilePane(readWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE));
2725
+ }
2726
+ function applyForViewport() {
2727
+ if (mq.matches) {
2728
+ syncSinglePaneShellState(shell, true);
2729
+ shell.setAttribute("data-dual-mobile-pane", readStoredPane());
2730
+ }
2731
+ else {
2732
+ syncSinglePaneShellState(shell, false);
2733
+ shell.removeAttribute("data-dual-mobile-pane");
2734
+ }
2735
+ }
2736
+ const runFlip = () => {
2737
+ if (!mq.matches)
2738
+ return;
2739
+ const cur = normalizedDualMobilePane(shell.getAttribute("data-dual-mobile-pane"));
2740
+ const next = cur === "code" ? "doc" : "code";
2741
+ shell.setAttribute("data-dual-mobile-pane", next);
2742
+ writeWebStorageItem(localStorage, STORAGE_DUAL_MOBILE_PANE, next);
2743
+ // When revealing the doc pane, (re)run Mermaid — diagrams inside display:none cells are
2744
+ // skipped on initial load, so they need a triggered pass once the cells become visible.
2745
+ if (next === "doc") {
2746
+ queueMicrotask(() => {
2747
+ requestAnimationFrame(() => {
2748
+ void runMermaidOnFreshDocNodes(shell);
2749
+ });
2750
+ });
2751
+ }
2752
+ };
2753
+ flipBtn.addEventListener("click", runFlip);
2754
+ if (flipScrollBtn) {
2755
+ flipScrollBtn.addEventListener("click", runFlip);
2756
+ wireDualMobilePaneFlipScrollAffordance(flipBtn, flipScrollBtn, mq);
2757
+ }
2758
+ mq.addEventListener("change", applyForViewport);
2759
+ applyForViewport();
2760
+ }
2761
+ /** Multi-angle stretch swaps `#shell` innerHTML; disconnect the previous table observer so listeners do not accumulate. */
2762
+ let stretchRowBufferSyncHandle = null;
2763
+ let stretchGutterConnectorHandle = null;
2764
+ function stretchFlowSynchronizerEnabled(shell) {
2765
+ return shell.getAttribute("data-stretch-buffer-sync") === "flow-synchronizer";
2766
+ }
2767
+ function stretchBlockRows(table) {
2768
+ return Array.from(table.querySelectorAll("tbody tr.stretch-row--block[data-commentray-stretch-sync-id]")).filter((row) => (row.dataset.commentrayStretchSyncId?.trim() ?? "").length > 0);
2769
+ }
2770
+ function stretchRowPairRects(row) {
2771
+ const id = row.dataset.commentrayStretchSyncId?.trim() ?? "";
2772
+ if (id.length === 0)
2773
+ return null;
2774
+ const codeTd = row.querySelector(":scope > td.stretch-code");
2775
+ const docTd = row.querySelector(":scope > td.stretch-doc");
2776
+ if (!(codeTd instanceof HTMLTableCellElement) || !(docTd instanceof HTMLTableCellElement))
2777
+ return null;
2778
+ return { id, codeRect: codeTd.getBoundingClientRect(), docRect: docTd.getBoundingClientRect() };
2779
+ }
2780
+ function visibleElementRect(el) {
2781
+ if (!(el instanceof HTMLElement))
2782
+ return null;
2783
+ const rect = el.getBoundingClientRect();
2784
+ if (rect.width <= 0 || rect.height <= 0)
2785
+ return null;
2786
+ const style = globalThis.getComputedStyle(el);
2787
+ if (style.display === "none" || style.visibility === "hidden")
2788
+ return null;
2789
+ return rect;
2790
+ }
2791
+ function stretchVisibleSourcePane(codeTd, shell) {
2792
+ const preferredSel = sourcePaneModeForShell(shell) === "rendered-markdown"
2793
+ ? ".source-pane--rendered-md"
2794
+ : ".source-pane--code";
2795
+ const preferred = codeTd.querySelector(preferredSel);
2796
+ if (visibleElementRect(preferred) !== null)
2797
+ return preferred;
2798
+ const fallbacks = codeTd.querySelectorAll(".source-pane--code, .source-pane--rendered-md");
2799
+ for (const el of fallbacks) {
2800
+ if (visibleElementRect(el) !== null)
2801
+ return el;
2802
+ }
2803
+ return null;
2804
+ }
2805
+ function stretchCodeContentBottomViewport(codeTd, shell) {
2806
+ const visiblePane = stretchVisibleSourcePane(codeTd, shell);
2807
+ const paneRect = visibleElementRect(visiblePane);
2808
+ if (paneRect !== null)
2809
+ return paneRect.bottom;
2810
+ return codeTd.getBoundingClientRect().bottom;
2811
+ }
2812
+ function stretchDocContentBottomViewport(docTd) {
2813
+ const inner = docTd.querySelector(":scope .stretch-doc-inner");
2814
+ const innerRect = visibleElementRect(inner);
2815
+ if (innerRect !== null)
2816
+ return innerRect.bottom;
2817
+ return docTd.getBoundingClientRect().bottom;
2818
+ }
2819
+ function activeStretchSyncId(shell, rows) {
2820
+ const shellRect = shell.getBoundingClientRect();
2821
+ const probeY = shellRect.top + Math.min(Math.max(shellRect.height * 0.18, 24), 120);
2822
+ for (const row of rows) {
2823
+ const rect = row.getBoundingClientRect();
2824
+ if (rect.top <= probeY && rect.bottom >= probeY) {
2825
+ return row.dataset.commentrayStretchSyncId?.trim() ?? null;
2826
+ }
2827
+ }
2828
+ return rows[0]?.dataset.commentrayStretchSyncId?.trim() ?? null;
2829
+ }
2830
+ function drawStretchGutterConnectorsIntoSvg(svg, shell, table, gutter) {
2831
+ const rows = stretchBlockRows(table);
2832
+ const gutterRect = gutter.getBoundingClientRect();
2833
+ const w = gutterRect.width;
2834
+ const h = gutterRect.height;
2835
+ if (w <= 0 || h <= 0 || rows.length === 0) {
2836
+ svg.replaceChildren();
2837
+ return;
2838
+ }
2839
+ svg.setAttribute("viewBox", `0 0 ${String(w)} ${String(h)}`);
2840
+ svg.setAttribute("preserveAspectRatio", "none");
2841
+ const activeId = activeStretchSyncId(shell, rows);
2842
+ const parts = [];
2843
+ for (const row of rows) {
2844
+ const pair = stretchRowPairRects(row);
2845
+ if (pair === null)
2846
+ continue;
2847
+ const codeTd = row.querySelector(":scope > td.stretch-code");
2848
+ const docTd = row.querySelector(":scope > td.stretch-doc");
2849
+ if (!(codeTd instanceof HTMLTableCellElement) || !(docTd instanceof HTMLTableCellElement))
2850
+ continue;
2851
+ const codeContentBottom = stretchCodeContentBottomViewport(codeTd, shell);
2852
+ const docContentBottom = stretchDocContentBottomViewport(docTd);
2853
+ const codeTop = clampViewportYToGutterLocal(pair.codeRect.top + 1, gutterRect.top, h);
2854
+ const docTop = clampViewportYToGutterLocal(pair.docRect.top + 1, gutterRect.top, h);
2855
+ const codeBottom = clampViewportYToGutterLocal(codeContentBottom - 1, gutterRect.top, h);
2856
+ const docBottom = clampViewportYToGutterLocal(docContentBottom - 1, gutterRect.top, h);
2857
+ const strokeClass = pair.id === activeId ? "gutter__rays-path gutter__rays-path--active" : "gutter__rays-path";
2858
+ const trailClass = `${strokeClass} gutter__rays-path--trail`;
2859
+ const topPaths = gutterRayBezierPaths(0, codeTop.y, w, docTop.y, {
2860
+ tension: 0.38,
2861
+ clipStart: codeTop.clipped,
2862
+ clipEnd: docTop.clipped,
2863
+ });
2864
+ const bottomPaths = gutterRayBezierPaths(0, codeBottom.y, w, docBottom.y, {
2865
+ tension: 0.38,
2866
+ clipStart: codeBottom.clipped,
2867
+ clipEnd: docBottom.clipped,
2868
+ });
2869
+ const topExtra = topPaths.dotted ? `<path class="${trailClass}" d="${topPaths.dotted}" />` : "";
2870
+ const bottomExtra = bottomPaths.dotted
2871
+ ? `<path class="${trailClass}" d="${bottomPaths.dotted}" />`
2872
+ : "";
2873
+ parts.push(`<g class="gutter__rays-block" data-commentray-block="${escapeHtmlText(pair.id)}">` +
2874
+ `<path class="${strokeClass}" d="${topPaths.solid}" />` +
2875
+ topExtra +
2876
+ `<path class="${strokeClass}" d="${bottomPaths.solid}" />` +
2877
+ bottomExtra +
2878
+ `</g>`);
2879
+ }
2880
+ svg.innerHTML = parts.join("");
2881
+ }
2882
+ function wireStretchGutterConnectors(shell, table, gutter) {
2883
+ gutter.querySelector(":scope > .gutter__rays")?.remove();
2884
+ const svgNs = "http://www.w3.org/2000/svg";
2885
+ const host = document.createElement("div");
2886
+ host.className = "gutter__rays";
2887
+ host.setAttribute("aria-hidden", "true");
2888
+ const svg = document.createElementNS(svgNs, "svg");
2889
+ host.appendChild(svg);
2890
+ gutter.appendChild(host);
2891
+ let raf = 0;
2892
+ const scheduleDraw = () => {
2893
+ if (raf !== 0)
2894
+ return;
2895
+ raf = globalThis.requestAnimationFrame(() => {
2896
+ raf = 0;
2897
+ drawStretchGutterConnectorsIntoSvg(svg, shell, table, gutter);
2898
+ });
2899
+ };
2900
+ const onScrollOrResize = () => scheduleDraw();
2901
+ shell.addEventListener("scroll", onScrollOrResize, { passive: true });
2902
+ globalThis.addEventListener("resize", onScrollOrResize);
2903
+ const ro = new ResizeObserver(onScrollOrResize);
2904
+ ro.observe(shell);
2905
+ ro.observe(table);
2906
+ ro.observe(gutter);
2907
+ scheduleDraw();
2908
+ globalThis.requestAnimationFrame(() => {
2909
+ scheduleDraw();
2910
+ globalThis.requestAnimationFrame(scheduleDraw);
2911
+ });
2912
+ return {
2913
+ disconnect: () => {
2914
+ if (raf !== 0)
2915
+ globalThis.cancelAnimationFrame(raf);
2916
+ shell.removeEventListener("scroll", onScrollOrResize);
2917
+ globalThis.removeEventListener("resize", onScrollOrResize);
2918
+ ro.disconnect();
2919
+ host.remove();
2920
+ },
2921
+ request: scheduleDraw,
2922
+ };
2923
+ }
2924
+ function wireStretchSplitter(shell, codePane) {
2925
+ const gutter = document.getElementById("stretch-gutter");
2926
+ if (!(gutter instanceof HTMLElement))
2927
+ return;
2928
+ const stored = Number.parseFloat(readWebStorageItem(localStorage, STORAGE_SPLIT_PCT) ?? "");
2929
+ const initial = clamp(Number.isFinite(stored) ? stored : 50, 15, 85);
2930
+ shell.style.setProperty("--stretch-code-pct", `${String(initial)}%`);
2931
+ let dragging = false;
2932
+ const onMove = (ev) => {
2933
+ if (!dragging)
2934
+ return;
2935
+ const rect = codePane.getBoundingClientRect();
2936
+ if (rect.width <= 0)
2937
+ return;
2938
+ const x = ev.clientX - rect.left;
2939
+ const pct = clamp((x / rect.width) * 100, 15, 85);
2940
+ shell.style.setProperty("--stretch-code-pct", `${String(pct)}%`);
2941
+ writeWebStorageItem(localStorage, STORAGE_SPLIT_PCT, String(pct));
2942
+ stretchGutterConnectorHandle?.request();
2943
+ };
2944
+ const onUp = () => {
2945
+ if (!dragging)
2946
+ return;
2947
+ dragging = false;
2948
+ document.body.style.cursor = "";
2949
+ document.body.style.userSelect = "";
2950
+ globalThis.removeEventListener("mousemove", onMove);
2951
+ globalThis.removeEventListener("mouseup", onUp);
2952
+ };
2953
+ gutter.addEventListener("mousedown", (ev) => {
2954
+ ev.preventDefault();
2955
+ dragging = true;
2956
+ document.body.style.cursor = "col-resize";
2957
+ document.body.style.userSelect = "none";
2958
+ globalThis.addEventListener("mousemove", onMove);
2959
+ globalThis.addEventListener("mouseup", onUp);
2960
+ });
2961
+ }
2962
+ /** Wrap toggle always; `BufferingFlowSynchronizer` padding pass only when `#shell` has `data-stretch-buffer-sync="flow-synchronizer"`. */
2963
+ function wireStretchLayoutChrome(shell, codePane) {
2964
+ if (codePane instanceof HTMLTableElement && stretchFlowSynchronizerEnabled(shell)) {
2965
+ stretchRowBufferSyncHandle?.disconnect();
2966
+ stretchRowBufferSyncHandle = wireBlockStretchBufferSync(codePane);
2967
+ }
2968
+ else {
2969
+ stretchRowBufferSyncHandle?.disconnect();
2970
+ stretchRowBufferSyncHandle = null;
2971
+ }
1874
2972
  const wrapCb = document.getElementById("wrap-lines");
1875
2973
  if (wrapCb) {
1876
2974
  wireWrapToggle(STORAGE_WRAP_LINES, codePane, wrapCb, () => {
1877
2975
  globalThis.dispatchEvent(new Event("resize"));
1878
2976
  });
1879
2977
  }
2978
+ const gutter = document.getElementById("stretch-gutter");
2979
+ stretchGutterConnectorHandle?.disconnect();
2980
+ stretchGutterConnectorHandle =
2981
+ codePane instanceof HTMLElement && gutter instanceof HTMLElement
2982
+ ? wireStretchGutterConnectors(shell, codePane, gutter)
2983
+ : null;
2984
+ wireStretchSplitter(shell, codePane);
2985
+ }
2986
+ function multiAngleAngleRowLooksValid(row, layoutMode) {
2987
+ if (row === null || typeof row !== "object")
2988
+ return false;
2989
+ const a = row;
2990
+ if (typeof a.id !== "string" || typeof a.docInnerHtmlB64 !== "string")
2991
+ return false;
2992
+ if (layoutMode !== "stretch")
2993
+ return true;
2994
+ return typeof a.stretchSwapInnerB64 === "string" && a.stretchSwapInnerB64.trim().length > 0;
1880
2995
  }
1881
2996
  function parseMultiAnglePayload(script) {
1882
2997
  const t = script?.textContent?.trim() ?? "";
@@ -1886,29 +3001,133 @@ function parseMultiAnglePayload(script) {
1886
3001
  const raw = JSON.parse(decodeBase64Utf8(t));
1887
3002
  if (!raw || !Array.isArray(raw.angles) || raw.angles.length < 2)
1888
3003
  return null;
1889
- for (const a of raw.angles) {
1890
- if (typeof a.id !== "string" || typeof a.docInnerHtmlB64 !== "string")
1891
- return null;
1892
- }
1893
- return raw;
3004
+ const layoutMode = raw.layoutMode === "stretch" ? "stretch" : "dual";
3005
+ if (!raw.angles.every((row) => multiAngleAngleRowLooksValid(row, layoutMode)))
3006
+ return null;
3007
+ return { ...raw, layoutMode };
1894
3008
  }
1895
3009
  catch {
1896
3010
  return null;
1897
3011
  }
1898
3012
  }
1899
- function readDualPaneDomBundle() {
3013
+ function applyMultiAngleStretchAngleToShell(shell, angle) {
3014
+ const innerB64 = angle.stretchSwapInnerB64;
3015
+ if (innerB64 === undefined || innerB64.trim().length === 0)
3016
+ return;
3017
+ shell.innerHTML = decodeBase64Utf8(innerB64);
3018
+ const nextCodePane = document.getElementById("code-pane");
3019
+ if (nextCodePane instanceof HTMLElement) {
3020
+ wireStretchLayoutChrome(shell, nextCodePane);
3021
+ wireSourceMarkdownControls(shell, nextCodePane, () => {
3022
+ globalThis.dispatchEvent(new Event("resize"));
3023
+ });
3024
+ const flipBtn = document.getElementById("mobile-pane-flip");
3025
+ const flipScrollBtn = document.getElementById("mobile-pane-flip-scroll");
3026
+ if (flipBtn instanceof HTMLButtonElement) {
3027
+ wireStretchMobilePaneFlip(shell, nextCodePane, flipBtn, flipScrollBtn instanceof HTMLButtonElement ? flipScrollBtn : null, () => {
3028
+ globalThis.dispatchEvent(new Event("resize"));
3029
+ });
3030
+ }
3031
+ }
3032
+ shell.setAttribute("data-scroll-block-links-b64", angle.scrollBlockLinksB64);
3033
+ shell.setAttribute("data-raw-md-b64", angle.rawMdB64);
3034
+ shell.setAttribute("data-search-commentray-path", angle.commentrayPathForSearch);
3035
+ const crIdentity = normPosixPath(angle.commentrayPathForSearch);
3036
+ if (crIdentity.length > 0)
3037
+ shell.setAttribute("data-commentray-pair-commentray-path", crIdentity);
3038
+ else
3039
+ shell.removeAttribute("data-commentray-pair-commentray-path");
3040
+ const docPathEl = document.getElementById("nav-rail-doc-path");
3041
+ if (docPathEl) {
3042
+ const path = angle.commentrayPathForSearch.trim();
3043
+ docPathEl.textContent = path.length > 0 ? path : "—";
3044
+ if (path.length > 0)
3045
+ docPathEl.setAttribute("title", path);
3046
+ else
3047
+ docPathEl.removeAttribute("title");
3048
+ }
3049
+ const browse = angle.staticBrowseUrl?.trim() ?? "";
3050
+ if (browse.length > 0) {
3051
+ const resolved = staticBrowseHrefForShellDataAttribute(browse, globalThis.location.pathname, globalThis.location.origin);
3052
+ shell.setAttribute("data-commentray-pair-browse-href", resolved);
3053
+ }
3054
+ else {
3055
+ const ghu = angle.commentrayOnGithubUrl?.trim();
3056
+ if (ghu)
3057
+ shell.setAttribute("data-commentray-pair-browse-href", ghu);
3058
+ else
3059
+ shell.removeAttribute("data-commentray-pair-browse-href");
3060
+ }
3061
+ applyDocumentedTreeCurrentPairHighlight();
3062
+ assignLocationToCanonicalBrowsePermalinkIfNeeded(shell);
3063
+ void runMermaidOnFreshDocNodes(shell);
3064
+ rewriteHubRelativeBrowseAnchorsIn(shell);
3065
+ rootScrollingElement().scrollTop = 0;
3066
+ }
3067
+ function wireMultiAngleStretchAngleSelect(shell, payload, onAngleApplied) {
3068
+ if (payload.layoutMode !== "stretch")
3069
+ return;
3070
+ const angleSel = document.getElementById("angle-select");
3071
+ if (!angleSel)
3072
+ return;
3073
+ angleSel.addEventListener("change", () => {
3074
+ const a = payload.angles.find((x) => x.id === angleSel.value);
3075
+ if (!a)
3076
+ return;
3077
+ applyMultiAngleStretchAngleToShell(shell, a);
3078
+ onAngleApplied?.(a);
3079
+ });
3080
+ }
3081
+ function wireStretchSearchUi(shell, codePane) {
3082
+ const searchDom = readDualPaneSearchDom();
3083
+ if (!searchDom)
3084
+ return;
3085
+ const bundle = buildDualPaneSearcherBundle(shell, codePane);
3086
+ const { searchInput, searchClear, searchResults } = searchDom;
3087
+ wireSearchUi({
3088
+ scope: bundle.scope,
3089
+ filePathLabel: bundle.filePathLabel,
3090
+ mutable: bundle.mutable,
3091
+ rawCode: bundle.rawCode,
3092
+ searchInput,
3093
+ searchClear,
3094
+ searchResults,
3095
+ docScrollEl: shell,
3096
+ });
3097
+ wireDualPaneNavSearchFetch(shell, bundle.pathInit.documentedPairs, bundle.indexState, bundle.mutable, bundle.rebuildSearcher, searchInput);
3098
+ const multiScript = document.getElementById("commentray-multi-angle-b64");
3099
+ const multiPayload = parseMultiAnglePayload(multiScript);
3100
+ if (multiPayload?.layoutMode !== "stretch")
3101
+ return;
3102
+ wireMultiAngleStretchAngleSelect(shell, multiPayload, (angle) => {
3103
+ bundle.mutable.rawMd = decodeBase64Utf8(angle.rawMdB64);
3104
+ bundle.mutable.mdLines = bundle.mutable.rawMd.split("\n");
3105
+ bundle.mutable.commentrayPathLabel = angle.commentrayPathForSearch;
3106
+ bundle.rebuildSearcher();
3107
+ searchInput.value = "";
3108
+ searchResults.innerHTML = "";
3109
+ searchResults.hidden = true;
3110
+ });
3111
+ }
3112
+ function readDualPaneCoreDom() {
1900
3113
  const docPane = document.getElementById("doc-pane");
1901
3114
  const gutter = document.getElementById("gutter");
1902
3115
  const wrapCb = document.getElementById("wrap-lines");
3116
+ if (!docPane || !gutter || !wrapCb) {
3117
+ return null;
3118
+ }
3119
+ const docBody = document.getElementById("doc-pane-body");
3120
+ const docScrollEl = docBody instanceof HTMLElement ? docBody : docPane;
3121
+ return { docBody, docScrollEl, gutter, wrapCb };
3122
+ }
3123
+ function readDualPaneSearchDom() {
1903
3124
  const searchInput = document.getElementById("search-q");
1904
3125
  const searchClear = document.getElementById("search-clear");
1905
3126
  const searchResults = document.getElementById("search-results");
1906
- if (!docPane || !gutter || !wrapCb || !searchInput || !searchClear || !searchResults) {
3127
+ if (!searchInput || !searchClear || !searchResults) {
1907
3128
  return null;
1908
3129
  }
1909
- const docBody = document.getElementById("doc-pane-body");
1910
- const docScrollEl = docBody instanceof HTMLElement ? docBody : docPane;
1911
- return { docBody, docScrollEl, gutter, wrapCb, searchInput, searchClear, searchResults };
3130
+ return { searchInput, searchClear, searchResults };
1912
3131
  }
1913
3132
  function hubSearcherRowsForDualPane(args) {
1914
3133
  const { scope, rawCode, filePathLabel, hubNavRows, pathRowsForOrdering, rawMd, commentrayPathLabel, } = args;
@@ -2014,7 +3233,7 @@ function wireDualPaneNavSearchFetch(shell, embeddedPairs, indexState, mutable, r
2014
3233
  function applySelectedMultiAngle(args) {
2015
3234
  const { angle, docBody, mutable, rebuildSearcher, scrollLinksRef, shell, searchInput, searchResults, requestBlockRayRedraw, } = args;
2016
3235
  docBody.innerHTML = decodeBase64Utf8(angle.docInnerHtmlB64);
2017
- runMermaidOnFreshDocNodes(docBody);
3236
+ void runMermaidOnFreshDocNodes(docBody);
2018
3237
  rewriteHubRelativeBrowseAnchorsIn(docBody);
2019
3238
  mutable.rawMd = decodeBase64Utf8(angle.rawMdB64);
2020
3239
  mutable.mdLines = mutable.rawMd.split("\n");
@@ -2050,9 +3269,12 @@ function applySelectedMultiAngle(args) {
2050
3269
  else
2051
3270
  shell.removeAttribute("data-commentray-pair-browse-href");
2052
3271
  }
2053
- searchInput.value = "";
2054
- searchResults.innerHTML = "";
2055
- searchResults.hidden = true;
3272
+ if (searchInput && searchResults) {
3273
+ searchInput.value = "";
3274
+ searchResults.innerHTML = "";
3275
+ searchResults.hidden = true;
3276
+ }
3277
+ assignLocationToCanonicalBrowsePermalinkIfNeeded(shell);
2056
3278
  requestBlockRayRedraw?.();
2057
3279
  globalThis.requestAnimationFrame(() => {
2058
3280
  requestBlockRayRedraw?.();
@@ -2061,10 +3283,14 @@ function applySelectedMultiAngle(args) {
2061
3283
  });
2062
3284
  });
2063
3285
  }
2064
- function wireDualPaneMultiAngleAndScroll(args) {
3286
+ /**
3287
+ * Indexed / multi-angle wiring plus proportional fallback when there is no block map.
3288
+ * `allowProportionalMirror: false` implements **block-snap-only** (partner holds in gaps / no index).
3289
+ */
3290
+ function wireDualPaneScrollIndexedOrProportional(args, blockAwareOpts) {
2065
3291
  const { codePane, docScrollEl, docBody, shell, scrollLinksRef, multiPayload, mutable, rebuildSearcher, searchInput, searchResults, requestBlockRayRedraw, } = args;
2066
3292
  if (multiPayload) {
2067
- const runners = wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current, () => sourceLineIdPrefixForShell(shell), () => sourcePaneModeForShell(shell) === "rendered-markdown");
3293
+ const runners = wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current, () => sourceLineIdPrefixForShell(shell), () => sourcePaneModeForShell(shell) === "rendered-markdown", blockAwareOpts);
2068
3294
  const angleSel = document.getElementById("angle-select");
2069
3295
  if (angleSel && docBody) {
2070
3296
  angleSel.addEventListener("change", () => {
@@ -2087,9 +3313,19 @@ function wireDualPaneMultiAngleAndScroll(args) {
2087
3313
  return runners;
2088
3314
  }
2089
3315
  if (scrollLinksRef.current.length > 0) {
2090
- return wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current, () => sourceLineIdPrefixForShell(shell), () => sourcePaneModeForShell(shell) === "rendered-markdown");
3316
+ return wireBlockAwareScrollSync(codePane, docScrollEl, () => scrollLinksRef.current, () => sourceLineIdPrefixForShell(shell), () => sourcePaneModeForShell(shell) === "rendered-markdown", blockAwareOpts);
2091
3317
  }
2092
- return wireProportionalScrollSync(codePane, docScrollEl);
3318
+ if (blockAwareOpts.allowProportionalMirror) {
3319
+ return wireProportionalScrollSync(codePane, docScrollEl);
3320
+ }
3321
+ return wireBlockAwareScrollSync(codePane, docScrollEl, () => [], () => sourceLineIdPrefixForShell(shell), () => sourcePaneModeForShell(shell) === "rendered-markdown", blockAwareOpts);
3322
+ }
3323
+ function wireDualPaneMultiAngleAndScroll(args) {
3324
+ const strategy = parseDualPaneScrollSyncStrategy(args.shell.getAttribute("data-scroll-sync-strategy"));
3325
+ const blockAwareOpts = strategy === "block-snap-only"
3326
+ ? { allowProportionalMirror: false }
3327
+ : { allowProportionalMirror: true };
3328
+ return wireDualPaneScrollIndexedOrProportional(args, blockAwareOpts);
2093
3329
  }
2094
3330
  function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
2095
3331
  function commentrayMdLineFromLocationHash(rawHash) {
@@ -2124,18 +3360,23 @@ function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
2124
3360
  function initializeSourceMarkdownPane(shell) {
2125
3361
  if (sourcePaneModeForShell(shell) !== "rendered-markdown")
2126
3362
  return;
2127
- const sourceMdBody = document.getElementById("code-pane-markdown-body");
2128
- if (!(sourceMdBody instanceof HTMLElement))
3363
+ const codePane = document.getElementById("code-pane");
3364
+ if (!(codePane instanceof HTMLElement))
2129
3365
  return;
2130
- runMermaidOnFreshDocNodes(sourceMdBody);
2131
- rewriteHubRelativeBrowseAnchorsIn(sourceMdBody);
3366
+ for (const sourceMdBody of codePane.querySelectorAll('[data-source-markdown-body="true"]')) {
3367
+ void runMermaidOnFreshDocNodes(sourceMdBody);
3368
+ rewriteHubRelativeBrowseAnchorsIn(sourceMdBody);
3369
+ }
2132
3370
  }
3371
+ let sourceMarkdownControlsAbort = null;
2133
3372
  function wireSourceMarkdownControls(shell, codePane, onAfterFlip) {
2134
3373
  const sourceMdFlip = document.getElementById("source-markdown-pane-flip");
2135
3374
  const sourceMdFlipScroll = document.getElementById("source-markdown-pane-flip-scroll");
2136
3375
  if (!(sourceMdFlip instanceof HTMLButtonElement))
2137
3376
  return;
2138
- wireSourceMarkdownPaneFlip(shell, codePane, sourceMdFlip, sourceMdFlipScroll instanceof HTMLButtonElement ? sourceMdFlipScroll : null, onAfterFlip);
3377
+ sourceMarkdownControlsAbort?.abort();
3378
+ sourceMarkdownControlsAbort = new AbortController();
3379
+ wireSourceMarkdownPaneFlip(shell, codePane, sourceMdFlip, sourceMdFlipScroll instanceof HTMLButtonElement ? sourceMdFlipScroll : null, sourceMarkdownControlsAbort.signal, onAfterFlip);
2139
3380
  initializeSourceMarkdownPane(shell);
2140
3381
  }
2141
3382
  function buildDualPaneSearcherBundle(shell, codePane) {
@@ -2186,23 +3427,28 @@ function buildDualPaneSearcherBundle(shell, codePane) {
2186
3427
  };
2187
3428
  }
2188
3429
  function wireDualPaneCodeBrowser(shell, codePane) {
2189
- const dom = readDualPaneDomBundle();
2190
- if (!dom)
3430
+ const coreDom = readDualPaneCoreDom();
3431
+ if (!coreDom) {
2191
3432
  return;
2192
- const { docBody, docScrollEl, gutter, wrapCb, searchInput, searchClear, searchResults } = dom;
3433
+ }
3434
+ const { docBody, docScrollEl, gutter, wrapCb } = coreDom;
3435
+ const searchDom = readDualPaneSearchDom();
2193
3436
  const bundle = buildDualPaneSearcherBundle(shell, codePane);
2194
3437
  rewriteHubRelativeBrowseAnchorsIn(document);
2195
- wireSearchUi({
2196
- scope: bundle.scope,
2197
- filePathLabel: bundle.filePathLabel,
2198
- mutable: bundle.mutable,
2199
- rawCode: bundle.rawCode,
2200
- searchInput,
2201
- searchClear,
2202
- searchResults,
2203
- docScrollEl,
2204
- });
2205
- wireDualPaneNavSearchFetch(shell, bundle.pathInit.documentedPairs, bundle.indexState, bundle.mutable, bundle.rebuildSearcher, searchInput);
3438
+ if (searchDom) {
3439
+ const { searchInput, searchClear, searchResults } = searchDom;
3440
+ wireSearchUi({
3441
+ scope: bundle.scope,
3442
+ filePathLabel: bundle.filePathLabel,
3443
+ mutable: bundle.mutable,
3444
+ rawCode: bundle.rawCode,
3445
+ searchInput,
3446
+ searchClear,
3447
+ searchResults,
3448
+ docScrollEl,
3449
+ });
3450
+ wireDualPaneNavSearchFetch(shell, bundle.pathInit.documentedPairs, bundle.indexState, bundle.mutable, bundle.rebuildSearcher, searchInput);
3451
+ }
2206
3452
  const pct0 = parseFloat(readWebStorageItem(localStorage, STORAGE_SPLIT_PCT) || "46");
2207
3453
  const pct = clamp(Number.isFinite(pct0) ? pct0 : 46, 15, 85);
2208
3454
  codePane.style.flex = `0 0 ${pct}%`;
@@ -2238,8 +3484,8 @@ function wireDualPaneCodeBrowser(shell, codePane) {
2238
3484
  multiPayload,
2239
3485
  mutable: bundle.mutable,
2240
3486
  rebuildSearcher: bundle.rebuildSearcher,
2241
- searchInput,
2242
- searchResults,
3487
+ searchInput: searchDom?.searchInput,
3488
+ searchResults: searchDom?.searchResults,
2243
3489
  requestBlockRayRedraw,
2244
3490
  });
2245
3491
  const flipBtn = document.getElementById("mobile-pane-flip");
@@ -2365,46 +3611,9 @@ function safePermalinkHref(raw) {
2365
3611
  return null;
2366
3612
  }
2367
3613
  }
2368
- function humaneBrowseAliasPathForSource(sourcePath) {
2369
- return sourcePath
2370
- .split("/")
2371
- .filter((seg) => seg.length > 0)
2372
- .map((seg) => seg.startsWith(".") ? `%2E${encodeURIComponent(seg.slice(1))}` : encodeURIComponent(seg))
2373
- .join("/");
2374
- }
2375
- function companionStemFromCommentrayPath(commentrayPath) {
2376
- const norm = normPosixPath(commentrayPath);
2377
- const last = norm.split("/").filter(Boolean).at(-1) ?? "";
2378
- return last.replace(/\.md$/i, "").trim();
2379
- }
2380
3614
  function makeAbsoluteUrlAgainst(raw, baseHref) {
2381
3615
  return new URL(raw, baseHref).toString();
2382
3616
  }
2383
- function shellEligibleForHumaneBackfill(shell, pathname) {
2384
- if ((shell.getAttribute("data-layout") ?? "dual") !== "dual")
2385
- return false;
2386
- if (pathname.includes("/browse/"))
2387
- return false;
2388
- return pathname.endsWith("/") || pathname.endsWith("/index.html");
2389
- }
2390
- function nextHumaneBrowsePathForShell(shell, pathname) {
2391
- const sourcePath = normPosixPath(shell.getAttribute("data-commentray-pair-source-path") ?? "");
2392
- if (sourcePath.length === 0)
2393
- return null;
2394
- const alias = humaneBrowseAliasPathForSource(sourcePath);
2395
- if (alias.length === 0)
2396
- return null;
2397
- const angleSel = document.getElementById("angle-select");
2398
- const selectedAngle = angleSel instanceof HTMLSelectElement ? angleSel.value.trim() : "";
2399
- const commentrayPath = shell.getAttribute("data-commentray-pair-commentray-path") ?? "";
2400
- const stem = companionStemFromCommentrayPath(commentrayPath);
2401
- const angleName = selectedAngle.length > 0 ? selectedAngle : stem;
2402
- if (angleName.length === 0)
2403
- return null;
2404
- const angleAlias = `${alias}@${encodeURIComponent(angleName)}.html`;
2405
- const siteRoot = siteRootPathnameFromPathname(pathname);
2406
- return siteRoot === "/" ? `/browse/${angleAlias}` : `${siteRoot}/browse/${angleAlias}`;
2407
- }
2408
3617
  function absolutizeNavJsonUrls(shell, beforeHref) {
2409
3618
  const navSearchRaw = shell.getAttribute("data-nav-search-json-url")?.trim() ?? "";
2410
3619
  if (navSearchRaw.length > 0) {
@@ -2424,14 +3633,6 @@ function normalizePairBrowseHrefForCurrentPath(shell, pathname) {
2424
3633
  shell.setAttribute("data-commentray-pair-browse-href", resolveStaticBrowseHref(pairBrowseRaw, pathname, globalThis.location.origin));
2425
3634
  }
2426
3635
  }
2427
- function normalizeDocumentationHomeHrefForCurrentPath() {
2428
- const home = document.querySelector('a[aria-label="Documentation home"]');
2429
- if (!(home instanceof HTMLAnchorElement))
2430
- return;
2431
- const siteRoot = siteRootPathnameFromPathname(globalThis.location.pathname);
2432
- const normalized = siteRoot === "/" ? "/" : `${siteRoot}/`;
2433
- home.setAttribute("href", normalized);
2434
- }
2435
3636
  function activeCommentrayHashTokenFromViewport() {
2436
3637
  const docPane = document.getElementById("doc-pane");
2437
3638
  if (!(docPane instanceof HTMLElement))
@@ -2442,42 +3643,33 @@ function activeCommentrayHashTokenFromViewport() {
2442
3643
  if (anchors.length === 0)
2443
3644
  return null;
2444
3645
  const mdLine0 = probeCommentrayLine0FromDoc(docScrollEl);
2445
- if (!Number.isFinite(mdLine0) || mdLine0 < 0)
3646
+ if (mdLine0 === null || !Number.isFinite(mdLine0) || mdLine0 < 0)
2446
3647
  return null;
2447
3648
  return `commentray-md-line-${String(mdLine0)}`;
2448
3649
  }
2449
- function maybeBackfillAddressBarWithHumanePairLink() {
2450
- const shell = document.getElementById("shell");
2451
- if (!(shell instanceof HTMLElement))
3650
+ /**
3651
+ * Resolves hub-relative `data-nav-*` and pair-browse attributes against the current document URL so
3652
+ * `fetch()` and toolbar targets work from deep `/browse/…/index.html` pages (static HTML stays portable).
3653
+ */
3654
+ function resolveEmbeddedStaticNavUrlsForCurrentPage(shell) {
3655
+ if ((shell.getAttribute("data-layout") ?? "dual") !== "dual")
2452
3656
  return;
2453
3657
  const pathname = globalThis.location.pathname;
2454
- normalizeDocumentationHomeHrefForCurrentPath();
2455
- if (!shellEligibleForHumaneBackfill(shell, pathname))
2456
- return;
2457
3658
  const beforeHref = globalThis.location.href;
2458
3659
  absolutizeNavJsonUrls(shell, beforeHref);
2459
3660
  normalizePairBrowseHrefForCurrentPath(shell, pathname);
2460
- /** Prefer the same `staticBrowseUrl` the static build put on `#shell` (slug or `…/index.html` shims). */
2461
- const canonicalBrowsePathname = (() => {
2462
- const raw = shell.getAttribute("data-commentray-pair-browse-href")?.trim() ?? "";
2463
- if (raw.length === 0)
2464
- return null;
2465
- try {
2466
- const u = new URL(raw, globalThis.location.href);
2467
- if (u.origin !== globalThis.location.origin)
2468
- return null;
2469
- if (!u.pathname.includes("/browse/"))
2470
- return null;
2471
- return u.pathname;
2472
- }
2473
- catch {
2474
- return null;
2475
- }
2476
- })();
2477
- const nextPath = canonicalBrowsePathname ?? nextHumaneBrowsePathForShell(shell, pathname);
2478
- if (nextPath === null)
2479
- return;
2480
- globalThis.history.replaceState(null, "", `${nextPath}${globalThis.location.search}${globalThis.location.hash}`);
3661
+ }
3662
+ /**
3663
+ * Absolute base URL for the pair browse / permalink target from `#shell` `data-commentray-pair-browse-href`
3664
+ * (hub-relative `./browse/…` resolved via {@link resolveStaticBrowseHref}; same contract as static build).
3665
+ */
3666
+ function pairBrowsePermalinkBaseHrefFromShell(shell) {
3667
+ const raw = shell.getAttribute("data-commentray-pair-browse-href") ?? "";
3668
+ const t = raw.trim();
3669
+ const canonical = isHubRelativeStaticBrowseHref(t) && t.length > 0
3670
+ ? resolveStaticBrowseHref(t, globalThis.location.pathname, globalThis.location.origin)
3671
+ : safePermalinkHref(t);
3672
+ return canonical ?? globalThis.location.href;
2481
3673
  }
2482
3674
  function permalinkHashSuffixFromUi() {
2483
3675
  const tokens = [];
@@ -2501,16 +3693,31 @@ function permalinkHashSuffixFromUi() {
2501
3693
  return tokens.length > 0 ? `#${tokens.join("&")}` : "";
2502
3694
  }
2503
3695
  function sharePermalinkFromShell(shell) {
2504
- const raw = shell.getAttribute("data-commentray-pair-browse-href") ?? "";
2505
- const canonical = isHubRelativeStaticBrowseHref(raw.trim()) && raw.trim().length > 0
2506
- ? resolveStaticBrowseHref(raw.trim(), globalThis.location.pathname, globalThis.location.origin)
2507
- : safePermalinkHref(raw);
2508
- const base = canonical ?? globalThis.location.href;
2509
- const u = new URL(base, globalThis.location.href);
3696
+ const u = new URL(pairBrowsePermalinkBaseHrefFromShell(shell), globalThis.location.href);
2510
3697
  const hash = permalinkHashSuffixFromUi();
2511
3698
  u.hash = hash.length > 0 ? hash.slice(1) : "";
2512
3699
  return u.toString();
2513
3700
  }
3701
+ /**
3702
+ * Static multi-angle: each angle has its own `/browse/…/<angle>/index.html`. After `#angle-select`
3703
+ * changes, load that canonical page without a `#angle-…` fragment (path is the permalink).
3704
+ */
3705
+ function assignLocationToCanonicalBrowsePermalinkIfNeeded(shell) {
3706
+ let target;
3707
+ try {
3708
+ target = new URL(pairBrowsePermalinkBaseHrefFromShell(shell), globalThis.location.href);
3709
+ }
3710
+ catch {
3711
+ return;
3712
+ }
3713
+ if (target.origin !== globalThis.location.origin)
3714
+ return;
3715
+ target.hash = "";
3716
+ const here = new URL(globalThis.location.href);
3717
+ if (here.pathname === target.pathname && here.search === target.search)
3718
+ return;
3719
+ globalThis.location.assign(target.toString());
3720
+ }
2514
3721
  async function writeTextToClipboard(text) {
2515
3722
  try {
2516
3723
  await navigator.clipboard.writeText(text);
@@ -2563,6 +3770,7 @@ function wireSharePermalinkButton() {
2563
3770
  });
2564
3771
  }
2565
3772
  function main() {
3773
+ announceScrollSyncTraceOnBoot();
2566
3774
  wireSharePermalinkButton();
2567
3775
  wireColorThemeToolbar();
2568
3776
  wireDocumentedFilesTree();
@@ -2576,11 +3784,22 @@ function main() {
2576
3784
  wireWideModeIntroTrigger(shell);
2577
3785
  const layout = shell.getAttribute("data-layout") || "dual";
2578
3786
  if (layout === "stretch") {
2579
- wireStretchLayoutChrome(codePane);
3787
+ wireStretchLayoutChrome(shell, codePane);
3788
+ wireStretchSearchUi(shell, codePane);
3789
+ wireSourceMarkdownControls(shell, codePane, () => {
3790
+ globalThis.dispatchEvent(new Event("resize"));
3791
+ });
3792
+ const flipBtn = document.getElementById("mobile-pane-flip");
3793
+ const flipScrollBtn = document.getElementById("mobile-pane-flip-scroll");
3794
+ if (flipBtn instanceof HTMLButtonElement) {
3795
+ wireStretchMobilePaneFlip(shell, codePane, flipBtn, flipScrollBtn instanceof HTMLButtonElement ? flipScrollBtn : null, () => {
3796
+ globalThis.dispatchEvent(new Event("resize"));
3797
+ });
3798
+ }
2580
3799
  return;
2581
3800
  }
2582
3801
  wireDualPaneCodeBrowser(shell, codePane);
2583
- maybeBackfillAddressBarWithHumanePairLink();
3802
+ resolveEmbeddedStaticNavUrlsForCurrentPage(shell);
2584
3803
  }
2585
3804
  if (document.readyState === "loading") {
2586
3805
  document.addEventListener("DOMContentLoaded", main);