diff --git a/src/cli/inspect/assets/app.js b/src/cli/inspect/assets/app.js index ea3faae4..75c3afde 100644 --- a/src/cli/inspect/assets/app.js +++ b/src/cli/inspect/assets/app.js @@ -2860,7 +2860,49 @@ // src/lenses/timeline.ts var ROW_H = 52; + var rowH = ROW_H; var OVERSCAN = 8; + var REMEASURE_SETTLE_MS = 150; + var remeasureTimer; + var everMeasured = false; + function remeasureTimelineRows() { + const list = $("#timeline"); + if (!list) return; + const rows = list.querySelectorAll("li.event[data-event-id]"); + if (rows.length === 0) return; + let total = 0; + for (const row of rows) total += row.getBoundingClientRect().height; + const mean = total / rows.length; + if (!Number.isFinite(mean) || mean <= 0) return; + everMeasured = true; + if (Math.abs(mean - rowH) < 0.5) return; + const anchored = anchoredScrollTop(list, rowH, mean); + rowH = mean; + list.scrollTop = anchored; + renderTimeline(); + } + __name(remeasureTimelineRows, "remeasureTimelineRows"); + function anchoredScrollTop(list, prevRowH, nextRowH) { + const listTop = list.getBoundingClientRect().top; + const first = list.firstElementChild; + const leadingPx = first?.dataset.spacer === "1" ? Number.parseFloat(first.style.height) || 0 : 0; + const paintStart = Math.round(leadingPx / prevRowH); + const rows = list.querySelectorAll("li.event[data-event-id]"); + let idx = 0; + for (const row of rows) { + const r = row.getBoundingClientRect(); + if (r.height > 0 && r.bottom > listTop) + return Math.max(0, (paintStart + idx) * nextRowH - (r.top - listTop)); + idx++; + } + return list.scrollTop / prevRowH * nextRowH; + } + __name(anchoredScrollTop, "anchoredScrollTop"); + function scheduleTimelineRemeasure() { + clearTimeout(remeasureTimer); + remeasureTimer = setTimeout(remeasureTimelineRows, REMEASURE_SETTLE_MS); + } + __name(scheduleTimelineRemeasure, "scheduleTimelineRemeasure"); function timelineRows() { return getState().history?.entries ?? []; } @@ -2875,12 +2917,12 @@ __name(loadedWindow, "loadedWindow"); function visibleRange(scrollTop, viewportH, rowCount) { if (viewportH <= 0 || rowCount === 0) return { start: 0, end: rowCount }; - const maxScroll = Math.max(0, rowCount * ROW_H - viewportH); + const maxScroll = Math.max(0, rowCount * rowH - viewportH); const clamped = Math.min(Math.max(0, scrollTop), maxScroll); - const start = Math.max(0, Math.floor(clamped / ROW_H) - OVERSCAN); + const start = Math.max(0, Math.floor(clamped / rowH) - OVERSCAN); const end = Math.min( rowCount, - Math.ceil((clamped + viewportH) / ROW_H) + OVERSCAN + Math.ceil((clamped + viewportH) / rowH) + OVERSCAN ); return { start, end }; } @@ -2924,6 +2966,8 @@ if (list.dataset.virtualized) return; list.dataset.virtualized = "1"; list.addEventListener("scroll", () => renderTimeline()); + if (typeof ResizeObserver !== "undefined") + new ResizeObserver(scheduleTimelineRemeasure).observe(list); } __name(ensureScrollListener, "ensureScrollListener"); function renderTimeline() { @@ -2948,12 +2992,13 @@ const paintEnd = Math.min(Math.max(end, offset), loadEnd); const selected = selectedEventId(); list.innerHTML = ""; - if (paintStart > 0) list.appendChild(spacer(paintStart * ROW_H)); + if (paintStart > 0) list.appendChild(spacer(paintStart * rowH)); for (let i = paintStart; i < paintEnd; i++) list.appendChild(eventRow(rows[i - offset], selected)); if (paintEnd < matchCount) - list.appendChild(spacer((matchCount - paintEnd) * ROW_H)); + list.appendChild(spacer((matchCount - paintEnd) * rowH)); maybeExtendWindow(viewportH, start, end, offset, loadEnd, matchCount); + if (!everMeasured && paintEnd > paintStart) remeasureTimelineRows(); } __name(renderTimeline, "renderTimeline"); function maybeExtendWindow(viewportH, visibleStart, visibleEnd, loadStart, loadEnd, matchCount) { @@ -2971,8 +3016,9 @@ if (!list) return; const local = timelineRows().findIndex((e) => e.eventId === eventId); if (local < 0) return; + remeasureTimelineRows(); const global = loadedWindow(getState()).offset + local; - const centered = global * ROW_H - Math.max(0, (list.clientHeight - ROW_H) / 2); + const centered = global * rowH - Math.max(0, (list.clientHeight - rowH) / 2); list.scrollTop = Math.max(0, centered); renderTimeline(); const el = list.querySelector(`li[data-event-id="${eventId}"]`); @@ -4211,6 +4257,7 @@ click to open the revision page"> { replace: true } ); }); + $("#density-toggle")?.addEventListener("click", scheduleTimelineRemeasure); } __name(wireToolbar, "wireToolbar"); function main() { diff --git a/src/cli/inspect/web/src/lenses/timeline.ts b/src/cli/inspect/web/src/lenses/timeline.ts index e688e427..74879eee 100644 --- a/src/cli/inspect/web/src/lenses/timeline.ts +++ b/src/cli/inspect/web/src/lenses/timeline.ts @@ -40,16 +40,105 @@ import type { HistoryEntry } from "../types"; import { typeColor, typeLabel } from "../types"; /** - * Estimated row height in pixels for the virtual window math. The real row height - * varies a little with density and meta wrapping; `OVERSCAN` rows above and below - * the viewport absorb that variance, and reveal does a final `scrollIntoView` to - * correct the exact offset. + * Fallback row height in pixels for the virtual window math, used until (and + * unless) the painted rows can be measured — no layout, nothing painted yet. + * The live estimate re-derives from real layout (`remeasureTimelineRows`) + * because the real height moves systematically with the pane: a narrow master + * wraps the meta line taller, compact density pads shorter. Residual per-row + * variance around the measured mean is absorbed by `OVERSCAN`, and reveal does + * a final `scrollIntoView` to correct the exact offset. */ export const ROW_H = 52; +// The live per-row estimate every consumer (window math, spacers, reveal) +// reads. A module singleton like the store: one timeline, one geometry. +let rowH: number = ROW_H; + +/** The live row-height estimate driving the virtual window math. */ +export function timelineRowHeight(): number { + return rowH; +} + /** Extra rows rendered above and below the viewport so a fast scroll never flashes blank. */ const OVERSCAN = 8; +/** Trailing settle before a re-measure, so a divider drag or a window-resize burst coalesces. */ +const REMEASURE_SETTLE_MS = 150; + +let remeasureTimer: ReturnType | undefined; + +// Whether a measurement has ever succeeded. Until one has, every paint retries +// (a paint without layout measures nothing), so the estimate is seeded by the +// FIRST laid-out paint — before the load-time reveal computes with it — rather +// than waiting on the observer's initial (async) delivery. +let everMeasured = false; + +/** + * Re-derive the row estimate from the painted rows' live layout: the mean + * `getBoundingClientRect().height` (unrounded — a half-pixel rounding error + * times hundreds of spacer rows is real drift). Guarded to keep the current + * estimate when there is nothing to measure: no painted rows (the empty state) + * or a zero/non-finite mean (no layout — tests, a hidden pane). A changed + * estimate repaints so the spacers and the visible window re-derive. + */ +export function remeasureTimelineRows(): void { + const list = $("#timeline"); + if (!list) return; + const rows = list.querySelectorAll("li.event[data-event-id]"); + if (rows.length === 0) return; + let total = 0; + for (const row of rows) total += row.getBoundingClientRect().height; + const mean = total / rows.length; + if (!Number.isFinite(mean) || mean <= 0) return; + everMeasured = true; + if (Math.abs(mean - rowH) < 0.5) return; + // Anchor the reading position before the repaint: the new estimate + // re-derives the whole scroll track, which would otherwise teleport the + // content under the reader (a density flip) or displace a just-revealed + // selection (the first measurement after a load's deep-link reveal). + const anchored = anchoredScrollTop(list, rowH, mean); + rowH = mean; + list.scrollTop = anchored; + renderTimeline(); +} + +/** + * Where the viewport top lands on the re-derived track. Prefer the painted + * row whose real rect straddles the viewport top and keep its exact offset — + * scrollTop itself cannot be trusted as `index * estimate`, because reveal's + * final `scrollIntoView` corrects it against REAL row heights. When no + * painted row intersects the top (a spacer region, or no layout), fall back + * to scaling scrollTop so the same global index stays at the top. + */ +function anchoredScrollTop( + list: HTMLElement, + prevRowH: number, + nextRowH: number, +): number { + const listTop = list.getBoundingClientRect().top; + const first = list.firstElementChild as HTMLElement | null; + const leadingPx = + first?.dataset.spacer === "1" + ? Number.parseFloat(first.style.height) || 0 + : 0; + const paintStart = Math.round(leadingPx / prevRowH); + const rows = list.querySelectorAll("li.event[data-event-id]"); + let idx = 0; + for (const row of rows) { + const r = row.getBoundingClientRect(); + if (r.height > 0 && r.bottom > listTop) + return Math.max(0, (paintStart + idx) * nextRowH - (r.top - listTop)); + idx++; + } + return (list.scrollTop / prevRowH) * nextRowH; +} + +/** Coalesce a burst of layout changes into one trailing `remeasureTimelineRows`. */ +export function scheduleTimelineRemeasure(): void { + clearTimeout(remeasureTimer); + remeasureTimer = setTimeout(remeasureTimelineRows, REMEASURE_SETTLE_MS); +} + /** The loaded page entries — the server already filtered and ordered them. */ export function timelineRows(): HistoryEntry[] { return getState().history?.entries ?? []; @@ -88,12 +177,12 @@ export function visibleRange( // search narrows the result set) the viewport's scrollTop can be far past the // new content height; without this clamp `start` would exceed `rowCount` and // the window would be empty, painting a blank list under a giant spacer. - const maxScroll = Math.max(0, rowCount * ROW_H - viewportH); + const maxScroll = Math.max(0, rowCount * rowH - viewportH); const clamped = Math.min(Math.max(0, scrollTop), maxScroll); - const start = Math.max(0, Math.floor(clamped / ROW_H) - OVERSCAN); + const start = Math.max(0, Math.floor(clamped / rowH) - OVERSCAN); const end = Math.min( rowCount, - Math.ceil((clamped + viewportH) / ROW_H) + OVERSCAN, + Math.ceil((clamped + viewportH) / rowH) + OVERSCAN, ); return { start, end }; } @@ -137,13 +226,22 @@ function eventRow(e: HistoryEntry, selected: string | null): HTMLLIElement { return li; } -// Repaint the visible window when the viewport scrolls. Registered once per -// `#timeline` element (guarded by the `virtualized` dataset flag); the element is -// stable across timeline renders (renderMaster rebuilds it only on a lens change). +// Repaint the visible window when the viewport scrolls, and re-measure the row +// estimate when the element's size changes. Registered once per `#timeline` +// element (guarded by the `virtualized` dataset flag); the element is stable +// across timeline renders (renderMaster rebuilds it only on a lens change). +// One size observation covers every width-changing trigger — divider release +// (the drag burst settles through the trailing debounce), window resize, the +// narrow media query, detail-pane open/close, and reading mode — plus the +// initial delivery on observe, which seeds the first real measurement. Density +// is the one height-changing trigger with no width signal; the composition +// root routes that toggle here explicitly. function ensureScrollListener(list: HTMLElement): void { if (list.dataset.virtualized) return; list.dataset.virtualized = "1"; list.addEventListener("scroll", () => renderTimeline()); + if (typeof ResizeObserver !== "undefined") + new ResizeObserver(scheduleTimelineRemeasure).observe(list); } /** Paint the visible window of the server-filtered timeline page into `#timeline`. */ @@ -172,12 +270,16 @@ export function renderTimeline(): void { const paintEnd = Math.min(Math.max(end, offset), loadEnd); const selected = selectedEventId(); list.innerHTML = ""; - if (paintStart > 0) list.appendChild(spacer(paintStart * ROW_H)); + if (paintStart > 0) list.appendChild(spacer(paintStart * rowH)); for (let i = paintStart; i < paintEnd; i++) list.appendChild(eventRow(rows[i - offset], selected)); if (paintEnd < matchCount) - list.appendChild(spacer((matchCount - paintEnd) * ROW_H)); + list.appendChild(spacer((matchCount - paintEnd) * rowH)); maybeExtendWindow(viewportH, start, end, offset, loadEnd, matchCount); + // Seed the estimate from the first paint that has anything to measure (no + // recursion: a successful measurement flips `everMeasured` before its + // repaint). One synchronous layout read, once per load. + if (!everMeasured && paintEnd > paintStart) remeasureTimelineRows(); } // Fetch the next page when the viewport nears the trailing loaded edge and more @@ -219,9 +321,14 @@ export function scrollTimelineSelectionIntoView(eventId: string): void { if (!list) return; const local = timelineRows().findIndex((e) => e.eventId === eventId); if (local < 0) return; + // Sync the estimate with the live layout first: it can be stale at reveal + // time (the load path measures at full width, then the same render pass + // opens the split and reflows the rows before the deep-link reveal runs). + // Seeding from a stale estimate makes the scrollIntoView correction's + // repaint recompute a window that drops the target — the lost cursor. + remeasureTimelineRows(); const global = loadedWindow(getState()).offset + local; - const centered = - global * ROW_H - Math.max(0, (list.clientHeight - ROW_H) / 2); + const centered = global * rowH - Math.max(0, (list.clientHeight - rowH) / 2); list.scrollTop = Math.max(0, centered); renderTimeline(); const el = list.querySelector(`li[data-event-id="${eventId}"]`); diff --git a/src/cli/inspect/web/src/main.ts b/src/cli/inspect/web/src/main.ts index 819862f8..d4f29bde 100644 --- a/src/cli/inspect/web/src/main.ts +++ b/src/cli/inspect/web/src/main.ts @@ -17,6 +17,7 @@ import { initControls as initDiff } from "./diff/controller"; import { $ } from "./dom"; import { initControls as initHelp } from "./help-overlay"; import { onKey } from "./keyboard"; +import { scheduleTimelineRemeasure } from "./lenses/timeline"; import { presentTypes } from "./model"; import { onDocumentClick } from "./navigation"; import { initControls as initPalette } from "./palette"; @@ -58,6 +59,11 @@ function wireToolbar(): void { { replace: true }, ); }); + // Cross-module trigger the composition root owns: a density flip changes row + // heights without resizing the #timeline box, so the timeline's own size + // observer can never see it. The toggle itself stays prefs-owned; the settle + // delay means listener order against prefs' handler doesn't matter. + $("#density-toggle")?.addEventListener("click", scheduleTimelineRemeasure); } /** diff --git a/src/cli/inspect/web/test/integration/main.test.ts b/src/cli/inspect/web/test/integration/main.test.ts index 596c7a25..07a413cf 100644 --- a/src/cli/inspect/web/test/integration/main.test.ts +++ b/src/cli/inspect/web/test/integration/main.test.ts @@ -162,6 +162,33 @@ describe("wired interactions drive the DOM/route through the delegates", () => { }); }); +describe("the density toggle re-measures the timeline rows", () => { + // Density changes row heights without resizing the #timeline box, so the + // lens's size observer cannot see it — the composition root routes the + // toggle to the timeline's re-measure explicitly. + it("a density click re-derives the row estimate after the settle", async () => { + await main.main(); + const timeline = await import("../../src/lenses/timeline"); + for (const li of document.querySelectorAll( + "#master #timeline li.event[data-event-id]", + )) { + vi.spyOn(li, "getBoundingClientRect").mockReturnValue({ + height: 44, + } as DOMRect); + } + vi.useFakeTimers(); + try { + document + .querySelector("#density-toggle") + ?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + vi.advanceTimersByTime(1000); + expect(timeline.timelineRowHeight()).toBe(44); + } finally { + vi.useRealTimers(); + } + }); +}); + describe("advisory framing (rendered DOM, reader-relative, never a gate)", () => { it("surfaces the read-only / advisory posture and exposes no gate affordance", async () => { await main.main(); diff --git a/src/cli/inspect/web/test/lenses/timeline.test.ts b/src/cli/inspect/web/test/lenses/timeline.test.ts index 9d3f0e4b..109fc69d 100644 --- a/src/cli/inspect/web/test/lenses/timeline.test.ts +++ b/src/cli/inspect/web/test/lenses/timeline.test.ts @@ -413,6 +413,231 @@ describe("paged virtual timeline (server matchCount + offset window)", () => { }); }); +// Real rows render taller than the built-in estimate when the master pane is +// narrow (the meta line wraps) or shorter under compact density; the lens +// re-derives its estimate from the painted rows' live layout. happy-dom has no +// layout engine, so real measurements come back 0 — stub the painted rows' +// rects to simulate a laid-out pane. +function stubPaintedRowHeights(height: number): void { + for (const li of document.querySelectorAll( + "#timeline li.event[data-event-id]", + )) { + vi.spyOn(li, "getBoundingClientRect").mockReturnValue({ + height, + } as DOMRect); + } +} + +describe("measured row-height estimate", () => { + it("derives the estimate from the mean painted-row height and re-derives the spacers", () => { + seedHistory(manyEntries(500)); + mockViewport(500); + timeline.renderTimeline(); + stubPaintedRowHeights(72); + timeline.remeasureTimelineRows(); + expect(timeline.timelineRowHeight()).toBe(72); + // The change repainted: the whole scroll track re-derives on the new + // estimate (spacers exactly cover the off-window rows at 72px/row, which + // also proves spacers and the empty state are never sampled). + const spacerHeight = spacerHeights().reduce((sum, h) => sum + h, 0); + expect(spacerHeight + rowIds().length * 72).toBe(500 * 72); + }); + + it("keeps the fallback estimate when layout yields no row heights", () => { + seedHistory(manyEntries(500)); + mockViewport(500); + timeline.renderTimeline(); + // No stubs: happy-dom rects measure 0 — the estimate must not collapse. + timeline.remeasureTimelineRows(); + expect(timeline.timelineRowHeight()).toBe(timeline.ROW_H); + }); + + it("keeps the estimate when nothing is painted (the empty state)", () => { + seedHistory([], { matchCount: 0 }); + timeline.renderTimeline(); + timeline.remeasureTimelineRows(); + expect(timeline.timelineRowHeight()).toBe(timeline.ROW_H); + }); + + it("reveals with the live estimate, not the built-in fallback", () => { + seedHistory(manyEntries(40, 100), { offset: 100, matchCount: 500 }); + // Scrolled into the loaded window, so rows are painted and measurable. + const list = mockViewport(500, 100 * timeline.ROW_H); + timeline.renderTimeline(); + stubPaintedRowHeights(80); + timeline.remeasureTimelineRows(); + timeline.scrollTimelineSelectionIntoView("e139"); + // Centered in the 80px-row model: global index 139. + expect(list.scrollTop).toBe(139 * 80 - (500 - 80) / 2); + expect( + document.querySelector('#timeline li[data-event-id="e139"]'), + ).not.toBeNull(); + }); + + it("keeps the revealed row inside the viewport across the post-reveal repaint", () => { + // The lost-cursor mechanism: reveal's final scrollIntoView fires a scroll + // event whose repaint re-anchors rows to the spacer model. With a measured + // estimate the model matches the real rows, so that repaint cannot push + // the revealed row out of the viewport. + seedHistory(manyEntries(500)); + const list = mockViewport(500, 0); + timeline.renderTimeline(); + stubPaintedRowHeights(72); + timeline.remeasureTimelineRows(); + timeline.scrollTimelineSelectionIntoView("e250"); + // Emulate the browser correction: center the row's painted position, then + // fire the scroll event scrollIntoView produces. + const rowTop = () => + spacerHeights()[0] + + rowIds().indexOf("e250") * timeline.timelineRowHeight(); + (list as unknown as { scrollTop: number }).scrollTop = + rowTop() - (500 - 72) / 2; + list.dispatchEvent(new Event("scroll")); + expect(rowIds()).toContain("e250"); + expect(rowTop()).toBeGreaterThanOrEqual(list.scrollTop); + expect(rowTop() + 72).toBeLessThanOrEqual(list.scrollTop + 500); + }); + + it("preserves the viewport-top index across an estimate change (no layout fallback)", () => { + // A new estimate re-derives the whole scroll track; without anchoring, + // a fixed scrollTop lands on entirely different rows (the density toggle + // teleports the reading position). With no row geometry to anchor to + // (zero rects), the same global index must stay at the viewport top: + // scrollTop scales with the estimate. + seedHistory(manyEntries(500)); + const list = mockViewport(500, 100 * timeline.ROW_H); + timeline.renderTimeline(); + stubPaintedRowHeights(72); + timeline.remeasureTimelineRows(); + expect(list.scrollTop).toBe(100 * 72); + }); + + it("anchors to the real row at the viewport top when layout disagrees with the model", () => { + // After a reveal, scrollIntoView corrects scrollTop against REAL row + // heights, so scrollTop is no longer `index * estimate` — scaling it + // would overshoot. The anchor must come from the painted DOM: the row + // whose rect straddles the viewport top keeps its exact offset. + seedHistory(manyEntries(500)); + const list = mockViewport(500, 100 * timeline.ROW_H); + timeline.renderTimeline(); + vi.spyOn(list, "getBoundingClientRect").mockReturnValue({ + top: 0, + bottom: 500, + height: 500, + } as DOMRect); + // Lay the painted rows out at their real 72px heights, starting where the + // leading spacer ends (the 52px model), relative to the mocked scrollTop. + const spacerPx = spacerHeights()[0]; + const rows = Array.from( + document.querySelectorAll( + "#timeline li.event[data-event-id]", + ), + ); + rows.forEach((li, i) => { + const top = spacerPx + i * 72 - list.scrollTop; + vi.spyOn(li, "getBoundingClientRect").mockReturnValue({ + top, + bottom: top + 72, + height: 72, + } as DOMRect); + }); + const paintStart = Math.round(spacerPx / timeline.ROW_H); + // The first row whose real bottom crosses the viewport top, and its offset. + const topIdx = rows.findIndex( + (li) => li.getBoundingClientRect().bottom > 0, + ); + const topOffset = rows[topIdx].getBoundingClientRect().top; + timeline.remeasureTimelineRows(); + expect(timeline.timelineRowHeight()).toBe(72); + expect(list.scrollTop).toBe((paintStart + topIdx) * 72 - topOffset); + }); + + it("measures on the first laid-out paint, before any reveal runs", () => { + // The load-time deep-link reveal runs right after the first paint; if it + // computes on the fallback estimate, its own scrollIntoView-correction + // repaint displaces the revealed row before any observer fires. The first + // paint with real layout must seed the estimate itself. + const rect = vi + .spyOn(Element.prototype, "getBoundingClientRect") + .mockReturnValue({ top: 0, bottom: 72, height: 72 } as DOMRect); + try { + seedHistory(manyEntries(500)); + mockViewport(500); + timeline.renderTimeline(); + expect(timeline.timelineRowHeight()).toBe(72); + } finally { + rect.mockRestore(); + } + }); + + it("re-measures at reveal time so a stale estimate cannot lose the target", () => { + // The estimate can go stale between paints — the load path measures at + // full width, then the same render pass opens the split and narrows the + // pane before the deep-link reveal runs. Reveal must measure the (already + // reflowed) painted rows itself before seeding the scroll position. + seedHistory(manyEntries(500)); + const list = mockViewport(500, 0); + timeline.renderTimeline(); + stubPaintedRowHeights(72); + timeline.scrollTimelineSelectionIntoView("e250"); + expect(timeline.timelineRowHeight()).toBe(72); + expect(list.scrollTop).toBe(250 * 72 - (500 - 72) / 2); + expect(rowIds()).toContain("e250"); + }); + + it("coalesces scheduled re-measures into one trailing measurement", () => { + vi.useFakeTimers(); + try { + seedHistory(manyEntries(500)); + mockViewport(500); + timeline.renderTimeline(); + stubPaintedRowHeights(72); + timeline.scheduleTimelineRemeasure(); + timeline.scheduleTimelineRemeasure(); + // Trailing, not immediate: a divider drag's burst must settle first. + expect(timeline.timelineRowHeight()).toBe(timeline.ROW_H); + vi.advanceTimersByTime(1000); + expect(timeline.timelineRowHeight()).toBe(72); + } finally { + vi.useRealTimers(); + } + }); + + it("observes the timeline element's size (once) and re-measures on a change", () => { + const observed: Element[] = []; + let fireResize = () => {}; + class FakeResizeObserver { + constructor(cb: () => void) { + fireResize = cb; + } + observe(el: Element): void { + observed.push(el); + } + unobserve(): void {} + disconnect(): void {} + } + vi.stubGlobal("ResizeObserver", FakeResizeObserver); + vi.useFakeTimers(); + try { + seedHistory(manyEntries(500)); + mockViewport(500); + timeline.renderTimeline(); + timeline.renderTimeline(); + // One observer on the (stable) timeline element, same guard as the + // scroll listener — covers divider release, window resize, the narrow + // media query, pane open/close, and reading mode via one width signal. + expect(observed).toEqual([document.querySelector("#timeline")]); + stubPaintedRowHeights(64); + fireResize(); + vi.advanceTimersByTime(1000); + expect(timeline.timelineRowHeight()).toBe(64); + } finally { + vi.useRealTimers(); + vi.unstubAllGlobals(); + } + }); +}); + describe("scrollTimelineSelectionIntoView (global-index scroller)", () => { it("scrolls a loaded off-screen row into the window using its global index", () => { seedHistory(manyEntries(40, 100), { offset: 100, matchCount: 500 });