Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit dd562e8bcfdbe9dcb9a1dd47b33c4874c309c28c
parent 3425e755790b87640a5e7091dbf09a686957e967
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri, 22 May 2026 13:59:28 -0400

fix virtual list gaps by wrapping cards as elements

Diffstat:
Mcommon/components/TranscriptSearch.tsx | 346+++++++++++++++++++++++++++++--------------------------------------------------
Mexport/CHANGELOG.md | 2+-
Mexport/e2e/search-results-virtualization.spec.ts | 31+++++++++++++++++++++----------
3 files changed, 148 insertions(+), 231 deletions(-)

diff --git a/common/components/TranscriptSearch.tsx b/common/components/TranscriptSearch.tsx @@ -73,10 +73,10 @@ type ResultGroup = { const DEFAULT_MAX_HITS = 500; const DEFAULT_FETCH_CONCURRENCY = 6; const DEFAULT_FLUSH_INTERVAL_MS = 120; -// Average rendered height of a result row (card-header / leaf-header / hit). -// Used as the virtualizer's initial estimate; the real heights are measured -// per row by `rowVirtualizer.measureElement` after mount. -const RESULT_ROW_ESTIMATE_PX = 48; +// Rough first-paint guess for one full result card (header + a couple of +// leaf sections + a handful of hits). After mount, ResizeObserver measures +// the card's actual height and the virtualizer re-positions later cards. +const CARD_ROW_ESTIMATE_PX = 120; // Legacy URL keys still parsed for hydration (old share-links), but stripped // on the first commit so the modern `qt=` is canonical going forward. @@ -1482,115 +1482,33 @@ type LeafInfo = { scope: "transcripts" | "chat" | "metadata"; }; +// Each card is one virtualized element. Intra-card layout (header, leaf +// sections, hit rows) is plain CSS flow inside the card — the virtualizer +// only ever measures a card's total height, so there is no inter-row +// translateY math to misalign. type ResultRow = - | { - kind: "card-header"; - cardIndex: number; - group: ResultGroup; - isFirstInCard: true; - isLastInCard: boolean; - } - | { - kind: "leaf-header"; - cardIndex: number; - group: ResultGroup; - leafId: string; - hitCount: number; - isFirstInCard: false; - isLastInCard: boolean; - } - | { - kind: "hit"; - cardIndex: number; - group: ResultGroup; - leafId: string; - hit: LayerHit; - hitIndex: number; - isFirstInCard: false; - isLastInCard: boolean; - } - // A dedicated 12px transparent row that sits between cards. Doing the - // gap as its own virtualized row (rather than padding-bottom on the - // previous card's last row) means the row that "owns" the gap has its - // height pinned to a constant — the virtualizer never has to discover - // it through ResizeObserver, so the gap stays put even before the - // dynamic-measurement pass completes. + | { kind: "card"; cardIndex: number; group: ResultGroup } | { kind: "gap"; cardIndex: number }; const GAP_ROW_PX = 12; function buildResultRows( resultGroups: ReadonlyArray<ResultGroup>, - orderedLeafIds: ReadonlyArray<string>, ): ResultRow[] { const out: ResultRow[] = []; resultGroups.forEach((g, cardIndex) => { - const buckets = new Map<string, LayerHit[]>(); - for (const id of orderedLeafIds) buckets.set(id, []); - for (const h of g.hits) { - const arr = buckets.get(h.leafId); - if (arr) arr.push(h); - } - const cardStart = out.length; - out.push({ - kind: "card-header", - cardIndex, - group: g, - isFirstInCard: true, - isLastInCard: false, - }); - for (const [leafId, hits] of buckets) { - if (hits.length === 0) continue; - out.push({ - kind: "leaf-header", - cardIndex, - group: g, - leafId, - hitCount: hits.length, - isFirstInCard: false, - isLastInCard: false, - }); - hits.forEach((hit, hitIndex) => { - out.push({ - kind: "hit", - cardIndex, - group: g, - leafId, - hit, - hitIndex, - isFirstInCard: false, - isLastInCard: false, - }); - }); - } - // Mark the last content row of this card so the renderer can apply - // the rounded-b / border-b. Tombstone cards (header only) get the - // header marked. - const last = out[out.length - 1]; - if (last && last.kind !== "gap" && last.cardIndex === cardIndex) { - (last as { isLastInCard: boolean }).isLastInCard = true; - } - // Inter-card gap, skipped after the final card so there's no - // trailing empty space below the result list. + out.push({ kind: "card", cardIndex, group: g }); if (cardIndex < resultGroups.length - 1) { out.push({ kind: "gap", cardIndex }); } - void cardStart; }); return out; } function rowKey(row: ResultRow): string { - switch (row.kind) { - case "card-header": - return `c${row.cardIndex}:h`; - case "leaf-header": - return `c${row.cardIndex}:l:${row.leafId}`; - case "hit": - return `c${row.cardIndex}:h:${row.leafId}:${row.hitIndex}`; - case "gap": - return `c${row.cardIndex}:gap`; - } + return row.kind === "card" + ? `c${row.cardIndex}` + : `c${row.cardIndex}:gap`; } function VirtualResultList({ @@ -1609,8 +1527,8 @@ function VirtualResultList({ openWithMode: (slug: string, hit?: LayerHit) => void; }) { const flatRows = useMemo( - () => buildResultRows(resultGroups, orderedLeafIds), - [resultGroups, orderedLeafIds], + () => buildResultRows(resultGroups), + [resultGroups], ); const listRef = useRef<HTMLDivElement | null>(null); @@ -1636,12 +1554,16 @@ function VirtualResultList({ const rowVirtualizer = useWindowVirtualizer({ count: flatRows.length, - // The estimate only matters until ResizeObserver measures each row. - // Picking a value close to the most common row height (transcript - // hits + leaf-headers) keeps any pre-measurement layout shift small. + // Card heights vary a lot (header-only tombstone vs. a card with + // hundreds of hits), so estimateSize is only a rough first-paint + // guess; ResizeObserver measures each card's actual height on mount + // and the virtualizer re-positions later cards. The unit here is a + // whole card, not a row inside one — there is no intra-card row + // positioning to get wrong. estimateSize: (index) => - flatRows[index]?.kind === "gap" ? GAP_ROW_PX : RESULT_ROW_ESTIMATE_PX, - overscan: 12, + flatRows[index]?.kind === "gap" ? GAP_ROW_PX : CARD_ROW_ESTIMATE_PX, + // Cards are large; ~4 above/below the viewport is plenty of buffer. + overscan: 4, scrollMargin, }); @@ -1656,39 +1578,25 @@ function VirtualResultList({ > {virtualItems.map((vi) => { const row = flatRows[vi.index]; - if (row.kind === "gap") { - // Fixed-height transparent row. Putting the frame classes on - // the same div the virtualizer measures (rather than wrapping - // an inner frame div) avoids a stale-measurement class of - // bugs where the gap "appeared" between rows until a scroll - // forced re-measurement. - return ( - <VirtualRow - key={rowKey(row)} - measureRef={rowVirtualizer.measureElement} - index={vi.index} - offset={vi.start - scrollMargin} - className="" - > - <div style={{ height: GAP_ROW_PX }} aria-hidden="true" /> - </VirtualRow> - ); - } return ( <VirtualRow key={rowKey(row)} measureRef={rowVirtualizer.measureElement} index={vi.index} offset={vi.start - scrollMargin} - className={rowFrameClass(row)} > - <ResultRowView - row={row} - leavesById={leavesById} - activeVideo={activeVideo} - activeTime={activeTime} - openWithMode={openWithMode} - /> + {row.kind === "gap" ? ( + <div style={{ height: GAP_ROW_PX }} aria-hidden="true" /> + ) : ( + <ResultCard + group={row.group} + leavesById={leavesById} + orderedLeafIds={orderedLeafIds} + activeVideo={activeVideo} + activeTime={activeTime} + openWithMode={openWithMode} + /> + )} </VirtualRow> ); })} @@ -1696,116 +1604,114 @@ function VirtualResultList({ ); } -// Tailwind classes for the row's outer (virtualized + measured) div. Side -// borders on every row plus a top border on every non-first row of a card -// give the divide-y effect; the first and last rows of a card carry the -// rounded corners. The frame lives on the measured div directly so the -// virtualizer's ResizeObserver sees the row's true height without an -// intermediate wrapper. -function rowFrameClass( - row: Exclude<ResultRow, { kind: "gap" }>, -): string { - const base = - "border-l border-r border-zinc-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 overflow-hidden"; - const top = row.isFirstInCard - ? "rounded-t-lg border-t" - : "border-t border-zinc-200/60 dark:border-zinc-800/60"; - const bottom = row.isLastInCard ? "rounded-b-lg border-b" : ""; - return `${base} ${top} ${bottom}`; -} - -function ResultRowView({ - row, +function ResultCard({ + group, leavesById, + orderedLeafIds, activeVideo, activeTime, openWithMode, }: { - row: Exclude<ResultRow, { kind: "gap" }>; + group: ResultGroup; leavesById: ReadonlyMap<string, LeafInfo>; + orderedLeafIds: ReadonlyArray<string>; activeVideo: string | null; activeTime: number | null; openWithMode: (slug: string, hit?: LayerHit) => void; }) { - if (row.kind === "card-header") { - const g = row.group; - return ( - <div data-result-slug={g.slug} data-card-header=""> - <button - type="button" - onClick={() => openWithMode(g.slug)} - className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2" - > - <span className="font-medium truncate flex-1 min-w-0"> - {g.title} - </span> - {g.isLivestream && <LivestreamBadge />} - {g.ageRestricted && <AgeRestrictedBadge />} - <span className="text-xs text-zinc-500 shrink-0"> - {g.channel && `${g.channel} · `} - {g.date} - {` · ${g.hits.length} hit${g.hits.length === 1 ? "" : "s"}`} - </span> - </button> - </div> - ); - } - - if (row.kind === "leaf-header") { - const leafInfo = leavesById.get(row.leafId); - if (!leafInfo) return null; - // data-leaf-section is on a child so the existing test selector - // `[data-result-slug="X"] [data-leaf-section]` (descendant combinator) - // still matches across the flat-row structure. - return ( - <div data-result-slug={row.group.slug}> - <div - className="flex items-center gap-2 px-4 py-1.5 bg-zinc-50/60 dark:bg-zinc-900/40" - data-leaf-section={row.leafId} - > - <LayerSwatch leafId={row.leafId} size="xs" /> - <span className="text-[10px] uppercase tracking-wide text-zinc-500"> - {leafInfo.scope === "metadata" - ? "Title / channel" - : leafInfo.scope === "chat" - ? "Live chat" - : "Transcripts"} - </span> - <span className="font-mono text-xs text-zinc-600 dark:text-zinc-400 truncate"> - {leafInfo.query} - </span> - <span className="ml-auto text-[10px] text-zinc-500"> - {row.hitCount} hit{row.hitCount === 1 ? "" : "s"} - </span> - </div> - </div> - ); - } + // Bucket hits per contributing leaf so the user sees one section per + // layer rather than an interleaved mishmash. + const buckets = useMemo(() => { + const m = new Map<string, LayerHit[]>(); + for (const id of orderedLeafIds) m.set(id, []); + for (const h of group.hits) { + const arr = m.get(h.leafId); + if (arr) arr.push(h); + } + return m; + }, [group.hits, orderedLeafIds]); - const leafInfo = leavesById.get(row.leafId); - if (!leafInfo) return null; - const h = row.hit; - const isActive = - activeVideo === row.group.slug && activeTime === h.start; return ( - <div data-result-slug={row.group.slug}> + <div + data-result-slug={group.slug} + data-card-header="" + className="border border-zinc-200 dark:border-zinc-800 rounded-lg overflow-hidden bg-white dark:bg-zinc-900" + > <button type="button" - onClick={() => openWithMode(row.group.slug, h)} - className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${ - isActive - ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60" - : "" - }`} + onClick={() => openWithMode(group.slug)} + className="w-full text-left px-4 py-2 bg-zinc-50 dark:bg-zinc-900/60 border-b border-zinc-200 dark:border-zinc-800 hover:bg-zinc-100 dark:hover:bg-zinc-800 flex items-baseline gap-2" > - <span className="text-xs font-mono text-zinc-500 shrink-0 w-16"> - {h.scope === "metadata" ? "—" : formatSeconds(h.start)} + <span className="font-medium truncate flex-1 min-w-0"> + {group.title} </span> - {h.track && h.track !== "live_chat" && <TrackBadge track={h.track} />} - <span className="flex-1 min-w-0 text-sm"> - {highlight(h.text, leafInfo.query, leafInfo.useRegex)} + {group.isLivestream && <LivestreamBadge />} + {group.ageRestricted && <AgeRestrictedBadge />} + <span className="text-xs text-zinc-500 shrink-0"> + {group.channel && `${group.channel} · `} + {group.date} + {` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`} </span> </button> + <ul className="flex flex-col divide-y divide-zinc-200 dark:divide-zinc-800"> + {Array.from(buckets.entries()).map(([leafId, hits]) => { + if (hits.length === 0) return null; + const leafInfo = leavesById.get(leafId); + if (!leafInfo) return null; + return ( + <li key={leafId}> + <div + className="flex items-center gap-2 px-4 py-1.5 bg-zinc-50/60 dark:bg-zinc-900/40 border-b border-zinc-200 dark:border-zinc-800" + data-leaf-section={leafId} + > + <LayerSwatch leafId={leafId} size="xs" /> + <span className="text-[10px] uppercase tracking-wide text-zinc-500"> + {leafInfo.scope === "metadata" + ? "Title / channel" + : leafInfo.scope === "chat" + ? "Live chat" + : "Transcripts"} + </span> + <span className="font-mono text-xs text-zinc-600 dark:text-zinc-400 truncate"> + {leafInfo.query} + </span> + <span className="ml-auto text-[10px] text-zinc-500"> + {hits.length} hit{hits.length === 1 ? "" : "s"} + </span> + </div> + <ul className="flex flex-col divide-y divide-zinc-200/60 dark:divide-zinc-800/60"> + {hits.map((h, i) => { + const isActive = + activeVideo === group.slug && activeTime === h.start; + return ( + <li key={i}> + <button + type="button" + onClick={() => openWithMode(group.slug, h)} + className={`w-full text-left flex gap-3 px-4 py-2 hover:bg-zinc-50 dark:hover:bg-zinc-800 ${ + isActive + ? "bg-amber-100/70 dark:bg-amber-500/10 ring-1 ring-inset ring-amber-400/60" + : "" + }`} + > + <span className="text-xs font-mono text-zinc-500 shrink-0 w-16"> + {h.scope === "metadata" ? "—" : formatSeconds(h.start)} + </span> + {h.track && h.track !== "live_chat" && ( + <TrackBadge track={h.track} /> + )} + <span className="flex-1 min-w-0 text-sm"> + {highlight(h.text, leafInfo.query, leafInfo.useRegex)} + </span> + </button> + </li> + ); + })} + </ul> + </li> + ); + })} + </ul> </div> ); } diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -3,7 +3,7 @@ ## [Unreleased] ### Changed -- Search results now render through a single virtualised list (`@tanstack/react-virtual`) covering every video card, leaf-section header, and individual hit row. Scrolling stays smooth across searches with hundreds of matching videos or a single video with hundreds of hits — only the rows currently in view are mounted. The manual "Show more videos" pagination button and the "Videos per page" advanced option are gone; all matching videos render in one continuous list. When the search pipeline still caps at its per-batch hit limit, a "Load more results" button surfaces below the list (the existing `hitLimit` flow, just relabeled now that pagination of cards is no longer needed). +- Search results now render through a virtualised list (`@tanstack/react-virtual`), one element per matching video. Cards mount in full when they enter the viewport (header + leaf sections + hits laid out in normal CSS flow inside the card) and unmount once scrolled away — earlier iterations virtualised every row inside each card, which produced occasional empty bands between a card's header and its leaf sections while ResizeObserver caught up to row sizes. The manual "Show more videos" pagination button and the "Videos per page" advanced option are gone; all matching videos render in one continuous virtualised list. When the search pipeline still caps at its per-batch hit limit, a "Load more results" button surfaces below the list (the existing `hitLimit` flow, just relabelled now that pagination of cards is no longer needed). ### Added - Composable layered search. The single search bar is now a query builder: any number of layers can be combined with AND / OR / NOT and arbitrary nesting. Each layer targets a scope (transcripts, live chat, or title/channel), and matches from every contributing layer are surfaced in the result list with a per-layer colour swatch. Per-layer results are memoised in IndexedDB so editing a deeper leaf only re-runs that layer against its already-narrowed scope. Simple one-keyword search still looks like a single input — the builder collapses to compact mode when there's only one layer. The composite query serialises into a new `qt=` URL parameter; legacy `?q=&m=&re=` links auto-migrate to a one-layer tree. diff --git a/export/e2e/search-results-virtualization.spec.ts b/export/e2e/search-results-virtualization.spec.ts @@ -122,20 +122,30 @@ test.describe("search results virtualization", () => { ).toHaveCount(0); }); - test("dense per-card hit list is also virtualized", async ({ page }) => { + test("dense card renders all its hits when mounted, none when scrolled away", async ({ + page, + }) => { await gotoBulkSearch(page); await expect(page.locator('[data-testid="results-summary"]')).toContainText( `${VIDEO_COUNT} videos`, ); - // The dense video has DENSE_HIT_COUNT hits — without virtualization they - // would all be in the DOM at once. With it, only a small slice exists. + // The dense card is the first in the list; while it's in view all of + // its hit rows are in the DOM (intra-card layout is normal flow, not + // nested virtualization). const denseHitButtons = page.locator( - `[data-result-slug="${DENSE_SLUG}"]:not([data-card-header]) button`, + `[data-card-header][data-result-slug="${DENSE_SLUG}"] li button`, ); - const renderedHitCount = await denseHitButtons.count(); - expect(renderedHitCount).toBeGreaterThan(0); - expect(renderedHitCount).toBeLessThan(DENSE_HIT_COUNT); + await expect(denseHitButtons).toHaveCount(DENSE_HIT_COUNT); + + // Scrolling past the dense card unmounts it (along with all of its + // hit buttons) — that's the per-card virtualization the new + // structure delivers. + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + await expect( + page.locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"]`), + ).toHaveCount(0); + await expect(denseHitButtons).toHaveCount(0); }); test("the legacy Show more videos button is gone", async ({ page }) => { @@ -156,10 +166,11 @@ test.describe("search results virtualization", () => { `${VIDEO_COUNT} videos`, ); - // The dense card is the first one rendered; clicking its header (not a - // hit row) opens the modal for it. + // The dense card is the first one rendered; clicking its header (the + // direct-child button of the card-header div, not one of the nested + // hit-row buttons) opens the modal for it. await page - .locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"] button`) + .locator(`[data-card-header][data-result-slug="${DENSE_SLUG}"] > button`) .click(); await page .getByRole("button", { name: "Mark clip start at current time" })