@commentray/render 0.2.0 → 0.3.1

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 (68) 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 +119 -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.js +1 -1
  15. package/dist/code-browser-block-rays.d.ts +11 -0
  16. package/dist/code-browser-block-rays.d.ts.map +1 -1
  17. package/dist/code-browser-block-rays.js +25 -5
  18. package/dist/code-browser-block-rays.js.map +1 -1
  19. package/dist/code-browser-client.bundle.js +12 -11
  20. package/dist/code-browser-client.js +1366 -257
  21. package/dist/code-browser-client.js.map +1 -1
  22. package/dist/code-browser-pair-nav.d.ts +9 -2
  23. package/dist/code-browser-pair-nav.d.ts.map +1 -1
  24. package/dist/code-browser-pair-nav.js +53 -14
  25. package/dist/code-browser-pair-nav.js.map +1 -1
  26. package/dist/code-browser-scroll-sync-monotonic.d.ts +17 -0
  27. package/dist/code-browser-scroll-sync-monotonic.d.ts.map +1 -0
  28. package/dist/code-browser-scroll-sync-monotonic.js +22 -0
  29. package/dist/code-browser-scroll-sync-monotonic.js.map +1 -0
  30. package/dist/code-browser-scroll-sync-strategy.d.ts +12 -0
  31. package/dist/code-browser-scroll-sync-strategy.d.ts.map +1 -0
  32. package/dist/code-browser-scroll-sync-strategy.js +28 -0
  33. package/dist/code-browser-scroll-sync-strategy.js.map +1 -0
  34. package/dist/code-browser-scroll-sync.d.ts +2 -2
  35. package/dist/code-browser-scroll-sync.d.ts.map +1 -1
  36. package/dist/code-browser-scroll-sync.js +1 -1
  37. package/dist/code-browser-scroll-sync.js.map +1 -1
  38. package/dist/code-browser-smooth-reveal-dedup.d.ts +25 -0
  39. package/dist/code-browser-smooth-reveal-dedup.d.ts.map +1 -0
  40. package/dist/code-browser-smooth-reveal-dedup.js +25 -0
  41. package/dist/code-browser-smooth-reveal-dedup.js.map +1 -0
  42. package/dist/code-browser.d.ts +25 -8
  43. package/dist/code-browser.d.ts.map +1 -1
  44. package/dist/code-browser.js +382 -93
  45. package/dist/code-browser.js.map +1 -1
  46. package/dist/commentray-anchor-viewport-probe.d.ts +5 -1
  47. package/dist/commentray-anchor-viewport-probe.d.ts.map +1 -1
  48. package/dist/commentray-anchor-viewport-probe.js +8 -2
  49. package/dist/commentray-anchor-viewport-probe.js.map +1 -1
  50. package/dist/index.d.ts +2 -1
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +1 -0
  53. package/dist/index.js.map +1 -1
  54. package/dist/inject-md-line-anchors.d.ts +1 -1
  55. package/dist/inject-md-line-anchors.d.ts.map +1 -1
  56. package/dist/inject-md-line-anchors.js +9 -5
  57. package/dist/inject-md-line-anchors.js.map +1 -1
  58. package/dist/markdown-pipeline.d.ts.map +1 -1
  59. package/dist/markdown-pipeline.js +10 -3
  60. package/dist/markdown-pipeline.js.map +1 -1
  61. package/dist/mermaid-runtime-html.d.ts.map +1 -1
  62. package/dist/mermaid-runtime-html.js +4 -1
  63. package/dist/mermaid-runtime-html.js.map +1 -1
  64. package/dist/reading-viewport-comfort.d.ts +12 -0
  65. package/dist/reading-viewport-comfort.d.ts.map +1 -0
  66. package/dist/reading-viewport-comfort.js +14 -0
  67. package/dist/reading-viewport-comfort.js.map +1 -0
  68. 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;
@@ -796,13 +1267,13 @@ function parseScrollBlockLinksFromShell(b64) {
796
1267
  return [];
797
1268
  }
798
1269
  }
799
- function rootScrollNearDocumentEnd(edgePx = 56) {
1270
+ function rootScrollNearDocumentEnd(edgePx = READING_VIEWPORT_BOTTOM_EDGE_CSS_PX) {
800
1271
  const root = rootScrollingElement();
801
1272
  const maxY = Math.max(0, root.scrollHeight - root.clientHeight);
802
1273
  return maxY > 0 && root.scrollTop >= maxY - edgePx;
803
1274
  }
804
1275
  /** When the pane itself is the scrollport (dual desktop), mirror root “near end” behavior. */
805
- function paneScrollNearEnd(pane, edgePx = 56) {
1276
+ function paneScrollNearEnd(pane, edgePx = READING_VIEWPORT_BOTTOM_EDGE_CSS_PX) {
806
1277
  const maxY = Math.max(0, pane.scrollHeight - pane.clientHeight);
807
1278
  return maxY > 0 && pane.scrollTop >= maxY - edgePx;
808
1279
  }
@@ -823,10 +1294,15 @@ function bestCommentrayAnchorLine0AtOrAboveY(anchors, y) {
823
1294
  return maxCommentrayAnchorLine0AtOrAboveViewportY(readings, y);
824
1295
  }
825
1296
  function lastCommentrayAnchorLine0(anchors) {
826
- const last = anchors[anchors.length - 1];
827
- if (!last)
828
- return 0;
829
- 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;
830
1306
  }
831
1307
  function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
832
1308
  const rows = codePane.querySelectorAll(`[id^="${lineIdPrefix}"]`);
@@ -844,7 +1320,7 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
844
1320
  const vh = globalThis.innerHeight;
845
1321
  const clipT = Math.max(0, sr.top);
846
1322
  const clipB = Math.min(vh, sr.bottom);
847
- const y = clipT + Math.max(2, Math.min(40, (clipB - clipT) * 0.15));
1323
+ const y = clipT + readingViewportTopInsetCssPx(clipB - clipT);
848
1324
  for (const el of rows) {
849
1325
  const r = el.getBoundingClientRect();
850
1326
  if (r.bottom > y - 1e-3) {
@@ -864,7 +1340,7 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
864
1340
  return rows.length;
865
1341
  }
866
1342
  const sr = codePane.getBoundingClientRect();
867
- const y = sr.top + codePane.clientTop + 2;
1343
+ const y = sr.top + codePane.clientTop + readingViewportTopInsetCssPx(codePane.clientHeight);
868
1344
  for (const el of rows) {
869
1345
  const r = el.getBoundingClientRect();
870
1346
  if (r.bottom > y - 1e-3) {
@@ -879,22 +1355,28 @@ function probeCodeLine1FromViewport(codePane, lineIdPrefix = "code-line-") {
879
1355
  function probeCommentrayLine0FromDoc(docPane) {
880
1356
  const anchors = docPane.querySelectorAll(".commentray-block-anchor");
881
1357
  if (anchors.length === 0)
882
- return 0;
1358
+ return null;
883
1359
  if (!paneUsesInternalYScroll(docPane)) {
884
- if (rootScrollNearDocumentEnd())
885
- return lastCommentrayAnchorLine0(anchors);
1360
+ if (rootScrollNearDocumentEnd()) {
1361
+ const tail = lastCommentrayAnchorLine0(anchors);
1362
+ if (tail !== null)
1363
+ return tail;
1364
+ }
886
1365
  const dr = docPane.getBoundingClientRect();
887
1366
  const vh = globalThis.innerHeight;
888
1367
  const clipT = Math.max(0, dr.top);
889
1368
  const clipB = Math.min(vh, dr.bottom);
890
- const y = clipT + Math.max(2, Math.min(40, (clipB - clipT) * 0.15));
1369
+ const y = clipT + readingViewportTopInsetCssPx(clipB - clipT);
891
1370
  return bestCommentrayAnchorLine0AtOrAboveY(anchors, y);
892
1371
  }
893
- if (paneScrollNearEnd(docPane))
894
- return lastCommentrayAnchorLine0(anchors);
1372
+ if (paneScrollNearEnd(docPane)) {
1373
+ const tail = lastCommentrayAnchorLine0(anchors);
1374
+ if (tail !== null)
1375
+ return tail;
1376
+ }
895
1377
  const dr = docPane.getBoundingClientRect();
896
- /** 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. */
897
- 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);
898
1380
  return bestCommentrayAnchorLine0AtOrAboveY(anchors, y);
899
1381
  }
900
1382
  function pageBreakPullEnabled() {
@@ -909,10 +1391,10 @@ function docProbeTopY(docPane) {
909
1391
  const vh = globalThis.innerHeight;
910
1392
  const clipT = Math.max(0, dr.top);
911
1393
  const clipB = Math.min(vh, dr.bottom);
912
- return clipT + Math.max(2, Math.min(40, (clipB - clipT) * 0.15));
1394
+ return clipT + readingViewportTopInsetCssPx(clipB - clipT);
913
1395
  }
914
1396
  const dr = docPane.getBoundingClientRect();
915
- return dr.top + docPane.clientTop + 2;
1397
+ return dr.top + docPane.clientTop + readingViewportTopInsetCssPx(docPane.clientHeight);
916
1398
  }
917
1399
  /**
918
1400
  * In long synthetic page-break gaps, shift source toward the next block once
@@ -922,13 +1404,13 @@ function pulledSourceLine0FromPageBreak(docPane) {
922
1404
  if (!pageBreakPullEnabled())
923
1405
  return null;
924
1406
  const topY = docProbeTopY(docPane);
925
- 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]"));
926
1408
  for (const pageBreak of breaks) {
927
- const nextSourceStartRaw = pageBreak.getAttribute("data-next-source-start");
928
- if (!nextSourceStartRaw)
1409
+ const nextViewportLineRaw = pageBreak.getAttribute("data-next-source-viewport-line");
1410
+ if (!nextViewportLineRaw)
929
1411
  continue;
930
- const nextSourceStart = Number.parseInt(nextSourceStartRaw, 10);
931
- if (!Number.isFinite(nextSourceStart) || nextSourceStart <= 0)
1412
+ const nextViewportLine1Based = Number.parseInt(nextViewportLineRaw, 10);
1413
+ if (!Number.isFinite(nextViewportLine1Based) || nextViewportLine1Based <= 0)
932
1414
  continue;
933
1415
  const breakTop = pageBreak.getBoundingClientRect().top;
934
1416
  const nextLineRaw = pageBreak.getAttribute("data-next-commentray-line");
@@ -947,91 +1429,251 @@ function pulledSourceLine0FromPageBreak(docPane) {
947
1429
  const pullThreshold = narrow ? 0.2 : 0.35;
948
1430
  if (progress < pullThreshold)
949
1431
  return null;
950
- return nextSourceStart - 1;
1432
+ return nextViewportLine1Based - 1;
951
1433
  }
952
1434
  return null;
953
1435
  }
954
1436
  /**
955
- * Programmatic `scrollTop` on the partner pane can emit a `scroll` event after the
956
- * synchronous `syncing` guard is cleared; that late event would mirror back and
957
- * jerk the pane the user is scrolling. We arm a short-lived skip on the partner
958
- * before each sync-driven update, and release one skip after two rAFs if no event
959
- * 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.
960
1442
  */
961
- function armIgnoreNextPaneScrollReaction(armed) {
962
- armed.n++;
963
- queueMicrotask(() => {
964
- requestAnimationFrame(() => {
965
- requestAnimationFrame(() => {
966
- armed.n = Math.max(0, armed.n - 1);
967
- });
968
- });
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,
969
1470
  });
970
1471
  }
971
- function wireBidirectionalScroll(codePane, docPane, syncFromCode, syncFromDoc) {
972
- let syncing = "none";
973
- const ignoreCodeScrollFromPartnerSync = { n: 0 };
974
- const ignoreDocScrollFromPartnerSync = { n: 0 };
975
- codePane.addEventListener("scroll", () => {
976
- if (ignoreCodeScrollFromPartnerSync.n > 0) {
977
- 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;
978
1480
  return;
979
1481
  }
980
- if (syncing === "doc")
1482
+ if (syncingRef.current !== "none") {
1483
+ scrollSyncTrace(`wire.${axis}.flush-skipped`, {
1484
+ reason: "sync-in-progress",
1485
+ syncing: syncingRef.current,
1486
+ });
981
1487
  return;
982
- syncing = "code";
983
- armIgnoreNextPaneScrollReaction(ignoreDocScrollFromPartnerSync);
984
- syncFromCode();
985
- syncing = "none";
986
- }, { passive: true });
987
- docPane.addEventListener("scroll", () => {
988
- if (ignoreDocScrollFromPartnerSync.n > 0) {
989
- 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
+ }
990
1509
  return;
991
1510
  }
992
- if (syncing === "code")
1511
+ if (syncingRef.current !== "none") {
1512
+ state.lastSeenTop = driverPane.scrollTop;
993
1513
  return;
994
- syncing = "doc";
995
- armIgnoreNextPaneScrollReaction(ignoreCodeScrollFromPartnerSync);
996
- syncFromDoc();
997
- syncing = "none";
1514
+ }
1515
+ if (state.pendingRaf !== 0) {
1516
+ cancelAnimationFrame(state.pendingRaf);
1517
+ }
1518
+ state.pendingRaf = requestAnimationFrame(flush);
998
1519
  }, { passive: true });
999
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
+ }
1000
1620
  /** Index-backed scroll sync when `data-scroll-block-links-b64` is present; else see proportional fallback. */
1001
- function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, shouldUseProportionalDocToCodeOnMobileFlip) {
1621
+ function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, shouldUseProportionalDocToCodeOnMobileFlip, options) {
1622
+ const allowProportionalMirror = options?.allowProportionalMirror !== false;
1002
1623
  let pendingDocToCode = null;
1003
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);
1004
1640
  const syncFromCodeToDoc = () => {
1005
- applyCodeToDocFlipPlanImpl(codePane, docPane, buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix()));
1641
+ blockAwareSyncFromCodeToDoc(bundle, 0);
1006
1642
  };
1007
1643
  const syncFromDocToCode = () => {
1008
- applyDocToCodeFlipPlanImpl(codePane, docPane, buildDocToCodeFlipPlanBlockAware(docPane, getLinks), lineIdPrefix());
1644
+ blockAwareSyncFromDocToCode(bundle, 0);
1009
1645
  };
1010
1646
  const prepareMobileFlipToCode = () => {
1011
- if (shouldUseProportionalDocToCodeOnMobileFlip?.() === true) {
1647
+ if (allowProportionalMirror && shouldUseProportionalDocToCodeOnMobileFlip?.() === true) {
1012
1648
  pendingDocToCode = { k: "mirrorW", ratio: windowScrollRatio() };
1013
1649
  return;
1014
1650
  }
1015
- pendingDocToCode = buildDocToCodeFlipPlanBlockAware(docPane, getLinks);
1651
+ pendingDocToCode = buildDocToCodeFlipPlanBlockAware(docPane, codePane, getLinks, sticky, lineIdPrefix(), allowProportionalMirror);
1016
1652
  };
1017
1653
  const finishMobileFlipToCode = () => {
1018
1654
  if (!pendingDocToCode)
1019
1655
  return;
1020
- const p = pendingDocToCode;
1656
+ let p = pendingDocToCode;
1021
1657
  pendingDocToCode = null;
1658
+ if (p.k === "noop" && p.skipProportionalFallbackOnFlip !== true && allowProportionalMirror) {
1659
+ p = buildDocToCodeFlipPlanProportional(docPane);
1660
+ }
1022
1661
  applyDocToCodeFlipPlanImpl(codePane, docPane, p, lineIdPrefix());
1023
1662
  };
1024
1663
  const prepareMobileFlipToDoc = () => {
1025
- pendingCodeToDoc = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix());
1664
+ pendingCodeToDoc = buildCodeToDocFlipPlanBlockAware(codePane, docPane, getLinks, lineIdPrefix(), sticky, allowProportionalMirror);
1026
1665
  };
1027
1666
  const finishMobileFlipToDoc = () => {
1028
1667
  if (!pendingCodeToDoc)
1029
1668
  return;
1030
- const p = pendingCodeToDoc;
1669
+ let p = pendingCodeToDoc;
1031
1670
  pendingCodeToDoc = null;
1671
+ if (p.k === "noop" && allowProportionalMirror) {
1672
+ p = buildCodeToDocFlipPlanProportional(codePane);
1673
+ }
1032
1674
  applyCodeToDocFlipPlanImpl(codePane, docPane, p);
1033
1675
  };
1034
- wireBidirectionalScroll(codePane, docPane, syncFromCodeToDoc, syncFromDocToCode);
1676
+ wireBidirectionalScroll(codePane, docPane, syncFromCodeToDocInner, syncFromDocToCodeInner);
1035
1677
  return {
1036
1678
  syncFromCodeToDoc,
1037
1679
  syncFromDocToCode,
@@ -1045,11 +1687,49 @@ function wireBlockAwareScrollSync(codePane, docPane, getLinks, lineIdPrefix, sho
1045
1687
  function wireProportionalScrollSync(codePane, docPane) {
1046
1688
  let pendingDocToCode = null;
1047
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
+ };
1048
1728
  const syncFromCodeToDoc = () => {
1049
- applyCodeToDocFlipPlanImpl(codePane, docPane, buildCodeToDocFlipPlanProportional(codePane));
1729
+ syncFromCodeToDocInner(0);
1050
1730
  };
1051
1731
  const syncFromDocToCode = () => {
1052
- applyDocToCodeFlipPlanImpl(codePane, docPane, buildDocToCodeFlipPlanProportional(docPane));
1732
+ syncFromDocToCodeInner(0);
1053
1733
  };
1054
1734
  const prepareMobileFlipToCode = () => {
1055
1735
  pendingDocToCode = buildDocToCodeFlipPlanProportional(docPane);
@@ -1071,7 +1751,7 @@ function wireProportionalScrollSync(codePane, docPane) {
1071
1751
  pendingCodeToDoc = null;
1072
1752
  applyCodeToDocFlipPlanImpl(codePane, docPane, p);
1073
1753
  };
1074
- wireBidirectionalScroll(codePane, docPane, syncFromCodeToDoc, syncFromDocToCode);
1754
+ wireBidirectionalScroll(codePane, docPane, syncFromCodeToDocInner, syncFromDocToCodeInner);
1075
1755
  return {
1076
1756
  syncFromCodeToDoc,
1077
1757
  syncFromDocToCode,
@@ -1102,9 +1782,7 @@ function commentaryBandEndYViewport(docScrollEl, next, docTop, clipThroughPageBr
1102
1782
  const nextTop = nextEl.getBoundingClientRect().top - 3;
1103
1783
  if (!clipThroughPageBreakGaps)
1104
1784
  return nextTop;
1105
- const docBandTop = docTop.getBoundingClientRect().top + 4;
1106
- const contentBottom = maxRenderableCommentaryContentBottomViewport(docScrollEl, docTop, nextEl);
1107
- return Math.min(nextTop, Math.max(docBandTop, contentBottom));
1785
+ return commentaryGutterDocBandBottomViewport(docScrollEl, docTop, nextEl);
1108
1786
  }
1109
1787
  const dr = docScrollEl.getBoundingClientRect();
1110
1788
  let bottom = dr.bottom - 4;
@@ -1177,6 +1855,15 @@ function subscribeBlockRayRedraw(gutter, codePane, docScrollEl, scheduleDraw) {
1177
1855
  if (shell)
1178
1856
  ro.observe(shell);
1179
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
+ }
1180
1867
  function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSourceLine1Based, lineIdPrefix) {
1181
1868
  const links = dedupeBlockScrollLinksById(getLinks());
1182
1869
  const sorted = sortBlockLinksBySource(links);
@@ -1187,10 +1874,7 @@ function drawBlockRaysIntoSvg(svg, gutter, docScrollEl, getLinks, probeTopSource
1187
1874
  svg.replaceChildren();
1188
1875
  return;
1189
1876
  }
1190
- /** Doc-aligned active block matches visible commentary; code-only probe can lag in page gaps. */
1191
- const activeId = docScrollEl.querySelector(".commentray-block-anchor") !== null
1192
- ? activeBlockIdForCommentrayLine0(links, probeCommentrayLine0FromDoc(docScrollEl))
1193
- : activeBlockIdForViewport(links, probeTopSourceLine1Based());
1877
+ const activeId = activeBlockIdForGutterRays(links, docScrollEl, probeTopSourceLine1Based);
1194
1878
  const clipGutterRaysThroughPageBreakGaps = pageBreakPullEnabled();
1195
1879
  svg.setAttribute("viewBox", `0 0 ${String(w)} ${String(h)}`);
1196
1880
  svg.setAttribute("preserveAspectRatio", "none");
@@ -1730,6 +2414,13 @@ function normalizedDualMobilePane(v) {
1730
2414
  function isNarrowViewport() {
1731
2415
  return globalThis.matchMedia(DUAL_MOBILE_SINGLE_PANE_MQ).matches;
1732
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
+ }
1733
2424
  function wireWideModeIntroTrigger(shell) {
1734
2425
  const btn = document.getElementById("commentray-help-tour");
1735
2426
  if (!(btn instanceof HTMLButtonElement))
@@ -1787,7 +2478,7 @@ function scheduleMermaidWhenDualDocPaneVisible(shell, mq) {
1787
2478
  const docBody = document.getElementById("doc-pane-body");
1788
2479
  if (!(docBody instanceof HTMLElement))
1789
2480
  return;
1790
- runMermaidOnFreshDocNodes(docBody);
2481
+ void runMermaidOnFreshDocNodes(docBody);
1791
2482
  };
1792
2483
  queueMicrotask(() => {
1793
2484
  kick();
@@ -1826,7 +2517,7 @@ function wireDualMobilePaneFlipScrollAffordance(primaryFlip, scrollFlip, mq) {
1826
2517
  mq.addEventListener("change", tick);
1827
2518
  globalThis.requestAnimationFrame(tick);
1828
2519
  }
1829
- function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
2520
+ function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip, signal) {
1830
2521
  const hideScroll = () => {
1831
2522
  scrollFlip.hidden = true;
1832
2523
  scrollFlip.classList.remove("is-visible");
@@ -1836,6 +2527,10 @@ function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
1836
2527
  scrollFlip.classList.add("is-visible");
1837
2528
  };
1838
2529
  const tick = () => {
2530
+ if (primaryFlip.hidden || primaryFlip.getClientRects().length === 0) {
2531
+ hideScroll();
2532
+ return;
2533
+ }
1839
2534
  const r = primaryFlip.getBoundingClientRect();
1840
2535
  const vh = globalThis.innerHeight;
1841
2536
  const margin = 10;
@@ -1845,10 +2540,27 @@ function wireSourceMarkdownPaneFlipAffordance(primaryFlip, scrollFlip) {
1845
2540
  else
1846
2541
  hideScroll();
1847
2542
  };
1848
- globalThis.addEventListener("scroll", tick, { passive: true });
1849
- globalThis.addEventListener("resize", tick, { passive: true });
2543
+ globalThis.addEventListener("scroll", tick, { passive: true, signal });
2544
+ globalThis.addEventListener("resize", tick, { passive: true, signal });
1850
2545
  globalThis.requestAnimationFrame(tick);
1851
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
+ }
1852
2564
  function closestSourceLine0ForPaneTop(codePane, idPrefix) {
1853
2565
  const rows = codePane.querySelectorAll(`[id^="${idPrefix}"]`);
1854
2566
  if (rows.length === 0)
@@ -1873,13 +2585,17 @@ function closestSourceLine0ForPaneTop(codePane, idPrefix) {
1873
2585
  return null;
1874
2586
  return Number.parseInt(m[1], 10);
1875
2587
  }
1876
- 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
+ }
1877
2592
  function syncSourceMarkdownFlipA11y() {
1878
2593
  const mode = sourcePaneModeForShell(shell);
1879
2594
  const renderedActive = mode === "rendered-markdown";
1880
2595
  const nextModeLabel = renderedActive ? "markdown source" : "rendered markdown";
2596
+ const currentModeLabel = renderedActive ? "rendered markdown" : "markdown source";
1881
2597
  const ariaLabel = `Switch source pane to ${nextModeLabel}`;
1882
- const title = `Source pane: ${renderedActive ? "rendered markdown" : "markdown source"} (click to switch)`;
2598
+ const title = `Switch source pane to ${nextModeLabel} (currently ${currentModeLabel})`;
1883
2599
  const apply = (btn) => {
1884
2600
  if (!(btn instanceof HTMLButtonElement))
1885
2601
  return;
@@ -1890,10 +2606,21 @@ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, onA
1890
2606
  apply(flipBtn);
1891
2607
  apply(flipScrollBtn);
1892
2608
  }
1893
- // Keep initial behavior deterministic: source pane starts in rendered markdown mode.
1894
- shell.setAttribute("data-source-pane-mode", "rendered-markdown");
1895
2609
  syncSourceMarkdownFlipA11y();
1896
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 });
1897
2624
  const runFlip = () => {
1898
2625
  const cur = sourcePaneModeForShell(shell);
1899
2626
  const currentPrefix = cur === "rendered-markdown" ? "code-md-line-" : "code-line-";
@@ -1902,27 +2629,34 @@ function wireSourceMarkdownPaneFlip(shell, codePane, flipBtn, flipScrollBtn, onA
1902
2629
  const nextPrefix = next === "rendered-markdown" ? "code-md-line-" : "code-line-";
1903
2630
  shell.setAttribute("data-source-pane-mode", next);
1904
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
+ }
1905
2639
  syncSourceMarkdownFlipA11y();
1906
2640
  syncWrapLinesVisibilityForSourcePaneMode(shell);
1907
- if (line0 !== null) {
2641
+ const shouldWriteRevealScroll = !isNarrowViewport() || paneUsesInternalYScroll(codePane);
2642
+ if (line0 !== null && shouldWriteRevealScroll) {
1908
2643
  const row = codePane.querySelector(`#${nextPrefix}${String(line0)}`);
1909
2644
  if (row instanceof HTMLElement) {
1910
- applyRevealChildInPane(codePane, row, 2);
2645
+ applyRevealChildInPane(codePane, row, 0);
1911
2646
  }
1912
2647
  }
1913
2648
  if (next === "rendered-markdown") {
1914
- const sourceMdBody = document.getElementById("code-pane-markdown-body");
1915
- if (sourceMdBody instanceof HTMLElement) {
1916
- runMermaidOnFreshDocNodes(sourceMdBody);
2649
+ for (const sourceMdBody of sourceMarkdownBodies()) {
2650
+ void runMermaidOnFreshDocNodes(sourceMdBody);
1917
2651
  rewriteHubRelativeBrowseAnchorsIn(sourceMdBody);
1918
2652
  }
1919
2653
  }
1920
2654
  onAfterFlip?.();
1921
2655
  };
1922
- flipBtn.addEventListener("click", runFlip);
2656
+ flipBtn.addEventListener("click", runFlip, { signal });
1923
2657
  if (flipScrollBtn) {
1924
- flipScrollBtn.addEventListener("click", runFlip);
1925
- wireSourceMarkdownPaneFlipAffordance(flipBtn, flipScrollBtn);
2658
+ flipScrollBtn.addEventListener("click", runFlip, { signal });
2659
+ wireSourceMarkdownPaneFlipAffordance(flipBtn, flipScrollBtn, signal);
1926
2660
  }
1927
2661
  }
1928
2662
  function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
@@ -1932,9 +2666,11 @@ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
1932
2666
  }
1933
2667
  function applyForViewport() {
1934
2668
  if (mq.matches) {
2669
+ syncSinglePaneShellState(shell, true);
1935
2670
  shell.setAttribute("data-dual-mobile-pane", readStoredPane());
1936
2671
  }
1937
2672
  else {
2673
+ syncSinglePaneShellState(shell, false);
1938
2674
  shell.removeAttribute("data-dual-mobile-pane");
1939
2675
  }
1940
2676
  }
@@ -1980,13 +2716,282 @@ function wireDualMobilePaneFlip(shell, flipBtn, scrollRunners, flipScrollBtn) {
1980
2716
  mq.addEventListener("change", applyForViewport);
1981
2717
  applyForViewport();
1982
2718
  }
1983
- 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
+ }
1984
2972
  const wrapCb = document.getElementById("wrap-lines");
1985
2973
  if (wrapCb) {
1986
2974
  wireWrapToggle(STORAGE_WRAP_LINES, codePane, wrapCb, () => {
1987
2975
  globalThis.dispatchEvent(new Event("resize"));
1988
2976
  });
1989
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;
1990
2995
  }
1991
2996
  function parseMultiAnglePayload(script) {
1992
2997
  const t = script?.textContent?.trim() ?? "";
@@ -1996,29 +3001,133 @@ function parseMultiAnglePayload(script) {
1996
3001
  const raw = JSON.parse(decodeBase64Utf8(t));
1997
3002
  if (!raw || !Array.isArray(raw.angles) || raw.angles.length < 2)
1998
3003
  return null;
1999
- for (const a of raw.angles) {
2000
- if (typeof a.id !== "string" || typeof a.docInnerHtmlB64 !== "string")
2001
- return null;
2002
- }
2003
- 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 };
2004
3008
  }
2005
3009
  catch {
2006
3010
  return null;
2007
3011
  }
2008
3012
  }
2009
- 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() {
2010
3113
  const docPane = document.getElementById("doc-pane");
2011
3114
  const gutter = document.getElementById("gutter");
2012
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() {
2013
3124
  const searchInput = document.getElementById("search-q");
2014
3125
  const searchClear = document.getElementById("search-clear");
2015
3126
  const searchResults = document.getElementById("search-results");
2016
- if (!docPane || !gutter || !wrapCb || !searchInput || !searchClear || !searchResults) {
3127
+ if (!searchInput || !searchClear || !searchResults) {
2017
3128
  return null;
2018
3129
  }
2019
- const docBody = document.getElementById("doc-pane-body");
2020
- const docScrollEl = docBody instanceof HTMLElement ? docBody : docPane;
2021
- return { docBody, docScrollEl, gutter, wrapCb, searchInput, searchClear, searchResults };
3130
+ return { searchInput, searchClear, searchResults };
2022
3131
  }
2023
3132
  function hubSearcherRowsForDualPane(args) {
2024
3133
  const { scope, rawCode, filePathLabel, hubNavRows, pathRowsForOrdering, rawMd, commentrayPathLabel, } = args;
@@ -2124,7 +3233,7 @@ function wireDualPaneNavSearchFetch(shell, embeddedPairs, indexState, mutable, r
2124
3233
  function applySelectedMultiAngle(args) {
2125
3234
  const { angle, docBody, mutable, rebuildSearcher, scrollLinksRef, shell, searchInput, searchResults, requestBlockRayRedraw, } = args;
2126
3235
  docBody.innerHTML = decodeBase64Utf8(angle.docInnerHtmlB64);
2127
- runMermaidOnFreshDocNodes(docBody);
3236
+ void runMermaidOnFreshDocNodes(docBody);
2128
3237
  rewriteHubRelativeBrowseAnchorsIn(docBody);
2129
3238
  mutable.rawMd = decodeBase64Utf8(angle.rawMdB64);
2130
3239
  mutable.mdLines = mutable.rawMd.split("\n");
@@ -2160,9 +3269,12 @@ function applySelectedMultiAngle(args) {
2160
3269
  else
2161
3270
  shell.removeAttribute("data-commentray-pair-browse-href");
2162
3271
  }
2163
- searchInput.value = "";
2164
- searchResults.innerHTML = "";
2165
- searchResults.hidden = true;
3272
+ if (searchInput && searchResults) {
3273
+ searchInput.value = "";
3274
+ searchResults.innerHTML = "";
3275
+ searchResults.hidden = true;
3276
+ }
3277
+ assignLocationToCanonicalBrowsePermalinkIfNeeded(shell);
2166
3278
  requestBlockRayRedraw?.();
2167
3279
  globalThis.requestAnimationFrame(() => {
2168
3280
  requestBlockRayRedraw?.();
@@ -2171,10 +3283,14 @@ function applySelectedMultiAngle(args) {
2171
3283
  });
2172
3284
  });
2173
3285
  }
2174
- 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) {
2175
3291
  const { codePane, docScrollEl, docBody, shell, scrollLinksRef, multiPayload, mutable, rebuildSearcher, searchInput, searchResults, requestBlockRayRedraw, } = args;
2176
3292
  if (multiPayload) {
2177
- 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);
2178
3294
  const angleSel = document.getElementById("angle-select");
2179
3295
  if (angleSel && docBody) {
2180
3296
  angleSel.addEventListener("change", () => {
@@ -2197,9 +3313,19 @@ function wireDualPaneMultiAngleAndScroll(args) {
2197
3313
  return runners;
2198
3314
  }
2199
3315
  if (scrollLinksRef.current.length > 0) {
2200
- 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);
2201
3317
  }
2202
- 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);
2203
3329
  }
2204
3330
  function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
2205
3331
  function commentrayMdLineFromLocationHash(rawHash) {
@@ -2234,18 +3360,23 @@ function wireDualPaneCommentrayLocationHash(docScrollEl, mdLineCount) {
2234
3360
  function initializeSourceMarkdownPane(shell) {
2235
3361
  if (sourcePaneModeForShell(shell) !== "rendered-markdown")
2236
3362
  return;
2237
- const sourceMdBody = document.getElementById("code-pane-markdown-body");
2238
- if (!(sourceMdBody instanceof HTMLElement))
3363
+ const codePane = document.getElementById("code-pane");
3364
+ if (!(codePane instanceof HTMLElement))
2239
3365
  return;
2240
- runMermaidOnFreshDocNodes(sourceMdBody);
2241
- rewriteHubRelativeBrowseAnchorsIn(sourceMdBody);
3366
+ for (const sourceMdBody of codePane.querySelectorAll('[data-source-markdown-body="true"]')) {
3367
+ void runMermaidOnFreshDocNodes(sourceMdBody);
3368
+ rewriteHubRelativeBrowseAnchorsIn(sourceMdBody);
3369
+ }
2242
3370
  }
3371
+ let sourceMarkdownControlsAbort = null;
2243
3372
  function wireSourceMarkdownControls(shell, codePane, onAfterFlip) {
2244
3373
  const sourceMdFlip = document.getElementById("source-markdown-pane-flip");
2245
3374
  const sourceMdFlipScroll = document.getElementById("source-markdown-pane-flip-scroll");
2246
3375
  if (!(sourceMdFlip instanceof HTMLButtonElement))
2247
3376
  return;
2248
- 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);
2249
3380
  initializeSourceMarkdownPane(shell);
2250
3381
  }
2251
3382
  function buildDualPaneSearcherBundle(shell, codePane) {
@@ -2296,23 +3427,28 @@ function buildDualPaneSearcherBundle(shell, codePane) {
2296
3427
  };
2297
3428
  }
2298
3429
  function wireDualPaneCodeBrowser(shell, codePane) {
2299
- const dom = readDualPaneDomBundle();
2300
- if (!dom)
3430
+ const coreDom = readDualPaneCoreDom();
3431
+ if (!coreDom) {
2301
3432
  return;
2302
- const { docBody, docScrollEl, gutter, wrapCb, searchInput, searchClear, searchResults } = dom;
3433
+ }
3434
+ const { docBody, docScrollEl, gutter, wrapCb } = coreDom;
3435
+ const searchDom = readDualPaneSearchDom();
2303
3436
  const bundle = buildDualPaneSearcherBundle(shell, codePane);
2304
3437
  rewriteHubRelativeBrowseAnchorsIn(document);
2305
- wireSearchUi({
2306
- scope: bundle.scope,
2307
- filePathLabel: bundle.filePathLabel,
2308
- mutable: bundle.mutable,
2309
- rawCode: bundle.rawCode,
2310
- searchInput,
2311
- searchClear,
2312
- searchResults,
2313
- docScrollEl,
2314
- });
2315
- 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
+ }
2316
3452
  const pct0 = parseFloat(readWebStorageItem(localStorage, STORAGE_SPLIT_PCT) || "46");
2317
3453
  const pct = clamp(Number.isFinite(pct0) ? pct0 : 46, 15, 85);
2318
3454
  codePane.style.flex = `0 0 ${pct}%`;
@@ -2348,8 +3484,8 @@ function wireDualPaneCodeBrowser(shell, codePane) {
2348
3484
  multiPayload,
2349
3485
  mutable: bundle.mutable,
2350
3486
  rebuildSearcher: bundle.rebuildSearcher,
2351
- searchInput,
2352
- searchResults,
3487
+ searchInput: searchDom?.searchInput,
3488
+ searchResults: searchDom?.searchResults,
2353
3489
  requestBlockRayRedraw,
2354
3490
  });
2355
3491
  const flipBtn = document.getElementById("mobile-pane-flip");
@@ -2475,46 +3611,9 @@ function safePermalinkHref(raw) {
2475
3611
  return null;
2476
3612
  }
2477
3613
  }
2478
- function humaneBrowseAliasPathForSource(sourcePath) {
2479
- return sourcePath
2480
- .split("/")
2481
- .filter((seg) => seg.length > 0)
2482
- .map((seg) => seg.startsWith(".") ? `%2E${encodeURIComponent(seg.slice(1))}` : encodeURIComponent(seg))
2483
- .join("/");
2484
- }
2485
- function companionStemFromCommentrayPath(commentrayPath) {
2486
- const norm = normPosixPath(commentrayPath);
2487
- const last = norm.split("/").filter(Boolean).at(-1) ?? "";
2488
- return last.replace(/\.md$/i, "").trim();
2489
- }
2490
3614
  function makeAbsoluteUrlAgainst(raw, baseHref) {
2491
3615
  return new URL(raw, baseHref).toString();
2492
3616
  }
2493
- function shellEligibleForHumaneBackfill(shell, pathname) {
2494
- if ((shell.getAttribute("data-layout") ?? "dual") !== "dual")
2495
- return false;
2496
- if (pathname.includes("/browse/"))
2497
- return false;
2498
- return pathname.endsWith("/") || pathname.endsWith("/index.html");
2499
- }
2500
- function nextHumaneBrowsePathForShell(shell, pathname) {
2501
- const sourcePath = normPosixPath(shell.getAttribute("data-commentray-pair-source-path") ?? "");
2502
- if (sourcePath.length === 0)
2503
- return null;
2504
- const alias = humaneBrowseAliasPathForSource(sourcePath);
2505
- if (alias.length === 0)
2506
- return null;
2507
- const angleSel = document.getElementById("angle-select");
2508
- const selectedAngle = angleSel instanceof HTMLSelectElement ? angleSel.value.trim() : "";
2509
- const commentrayPath = shell.getAttribute("data-commentray-pair-commentray-path") ?? "";
2510
- const stem = companionStemFromCommentrayPath(commentrayPath);
2511
- const angleName = selectedAngle.length > 0 ? selectedAngle : stem;
2512
- if (angleName.length === 0)
2513
- return null;
2514
- const angleAlias = `${alias}@${encodeURIComponent(angleName)}.html`;
2515
- const siteRoot = siteRootPathnameFromPathname(pathname);
2516
- return siteRoot === "/" ? `/browse/${angleAlias}` : `${siteRoot}/browse/${angleAlias}`;
2517
- }
2518
3617
  function absolutizeNavJsonUrls(shell, beforeHref) {
2519
3618
  const navSearchRaw = shell.getAttribute("data-nav-search-json-url")?.trim() ?? "";
2520
3619
  if (navSearchRaw.length > 0) {
@@ -2534,14 +3633,6 @@ function normalizePairBrowseHrefForCurrentPath(shell, pathname) {
2534
3633
  shell.setAttribute("data-commentray-pair-browse-href", resolveStaticBrowseHref(pairBrowseRaw, pathname, globalThis.location.origin));
2535
3634
  }
2536
3635
  }
2537
- function normalizeDocumentationHomeHrefForCurrentPath() {
2538
- const home = document.querySelector('a[aria-label="Documentation home"]');
2539
- if (!(home instanceof HTMLAnchorElement))
2540
- return;
2541
- const siteRoot = siteRootPathnameFromPathname(globalThis.location.pathname);
2542
- const normalized = siteRoot === "/" ? "/" : `${siteRoot}/`;
2543
- home.setAttribute("href", normalized);
2544
- }
2545
3636
  function activeCommentrayHashTokenFromViewport() {
2546
3637
  const docPane = document.getElementById("doc-pane");
2547
3638
  if (!(docPane instanceof HTMLElement))
@@ -2552,42 +3643,33 @@ function activeCommentrayHashTokenFromViewport() {
2552
3643
  if (anchors.length === 0)
2553
3644
  return null;
2554
3645
  const mdLine0 = probeCommentrayLine0FromDoc(docScrollEl);
2555
- if (!Number.isFinite(mdLine0) || mdLine0 < 0)
3646
+ if (mdLine0 === null || !Number.isFinite(mdLine0) || mdLine0 < 0)
2556
3647
  return null;
2557
3648
  return `commentray-md-line-${String(mdLine0)}`;
2558
3649
  }
2559
- function maybeBackfillAddressBarWithHumanePairLink() {
2560
- const shell = document.getElementById("shell");
2561
- 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")
2562
3656
  return;
2563
3657
  const pathname = globalThis.location.pathname;
2564
- normalizeDocumentationHomeHrefForCurrentPath();
2565
- if (!shellEligibleForHumaneBackfill(shell, pathname))
2566
- return;
2567
3658
  const beforeHref = globalThis.location.href;
2568
3659
  absolutizeNavJsonUrls(shell, beforeHref);
2569
3660
  normalizePairBrowseHrefForCurrentPath(shell, pathname);
2570
- /** Prefer the same `staticBrowseUrl` the static build put on `#shell` (slug or `…/index.html` shims). */
2571
- const canonicalBrowsePathname = (() => {
2572
- const raw = shell.getAttribute("data-commentray-pair-browse-href")?.trim() ?? "";
2573
- if (raw.length === 0)
2574
- return null;
2575
- try {
2576
- const u = new URL(raw, globalThis.location.href);
2577
- if (u.origin !== globalThis.location.origin)
2578
- return null;
2579
- if (!u.pathname.includes("/browse/"))
2580
- return null;
2581
- return u.pathname;
2582
- }
2583
- catch {
2584
- return null;
2585
- }
2586
- })();
2587
- const nextPath = canonicalBrowsePathname ?? nextHumaneBrowsePathForShell(shell, pathname);
2588
- if (nextPath === null)
2589
- return;
2590
- 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;
2591
3673
  }
2592
3674
  function permalinkHashSuffixFromUi() {
2593
3675
  const tokens = [];
@@ -2611,16 +3693,31 @@ function permalinkHashSuffixFromUi() {
2611
3693
  return tokens.length > 0 ? `#${tokens.join("&")}` : "";
2612
3694
  }
2613
3695
  function sharePermalinkFromShell(shell) {
2614
- const raw = shell.getAttribute("data-commentray-pair-browse-href") ?? "";
2615
- const canonical = isHubRelativeStaticBrowseHref(raw.trim()) && raw.trim().length > 0
2616
- ? resolveStaticBrowseHref(raw.trim(), globalThis.location.pathname, globalThis.location.origin)
2617
- : safePermalinkHref(raw);
2618
- const base = canonical ?? globalThis.location.href;
2619
- const u = new URL(base, globalThis.location.href);
3696
+ const u = new URL(pairBrowsePermalinkBaseHrefFromShell(shell), globalThis.location.href);
2620
3697
  const hash = permalinkHashSuffixFromUi();
2621
3698
  u.hash = hash.length > 0 ? hash.slice(1) : "";
2622
3699
  return u.toString();
2623
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
+ }
2624
3721
  async function writeTextToClipboard(text) {
2625
3722
  try {
2626
3723
  await navigator.clipboard.writeText(text);
@@ -2673,6 +3770,7 @@ function wireSharePermalinkButton() {
2673
3770
  });
2674
3771
  }
2675
3772
  function main() {
3773
+ announceScrollSyncTraceOnBoot();
2676
3774
  wireSharePermalinkButton();
2677
3775
  wireColorThemeToolbar();
2678
3776
  wireDocumentedFilesTree();
@@ -2686,11 +3784,22 @@ function main() {
2686
3784
  wireWideModeIntroTrigger(shell);
2687
3785
  const layout = shell.getAttribute("data-layout") || "dual";
2688
3786
  if (layout === "stretch") {
2689
- 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
+ }
2690
3799
  return;
2691
3800
  }
2692
3801
  wireDualPaneCodeBrowser(shell, codePane);
2693
- maybeBackfillAddressBarWithHumanePairLink();
3802
+ resolveEmbeddedStaticNavUrlsForCurrentPage(shell);
2694
3803
  }
2695
3804
  if (document.readyState === "loading") {
2696
3805
  document.addEventListener("DOMContentLoaded", main);