Archilyzer · Source

archilyzer

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

commit a4a71723b3ebcd3fab8face32fbb144bf4a6d03a
parent 192794b8f77c842ae4af9e4ca9236e4b2885bf4a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 29 Aug 2026 20:18:23 -0400

results: a card shows eight hits and offers the rest

A 500-hit card is a wall, and it is why a phone's search page was
39,000px tall and a desktop's 20,000. Each card now shows the first
eight hits across its layer sections with one button for the rest; the
same query is 2,062px. The header stops truncating titles to three
characters at phone widths, the toolbar stops fighting for one row, the
selection toolbar rides the bottom of the screen while something is
selected, and the chart fits a 390px canvas.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Diffstat:
Mcommon/components/SearchResults.tsx | 126++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcommon/components/charts/ChartView.tsx | 15++++++++++-----
Mexport/e2e/search-results-virtualization.spec.ts | 16+++++++++++-----
3 files changed, 133 insertions(+), 24 deletions(-)

diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx @@ -9,6 +9,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from " import { useRouter } from "next/navigation"; import { MessageSquareIcon } from "lucide-react"; import { useWindowVirtualizer } from "@tanstack/react-virtual"; +import { useMediaQuery } from "../lib/useMediaQuery"; import { VirtualRow } from "./VirtualRow"; import { Checkbox } from "./ui/checkbox"; import type { LayerHit } from "./searchPipeline"; @@ -45,6 +46,10 @@ import { // the card's actual height and the virtualizer re-positions later cards. const CARD_ROW_ESTIMATE_PX = 120; +// How many hit rows a card shows before it asks. Eight is about a phone screen +// of context and still enough to judge a match on a desktop. +const CARD_HIT_CAP = 8; + export default function SearchResults() { const { hasActiveQuery, @@ -94,9 +99,18 @@ export default function SearchResults() { [selectOnly, router], ); + // Chart options: expanded on a wide screen (where there is room beside the + // chart), collapsed on a phone (where it would push the chart off-screen), + // and whatever the user last chose once they choose. + const wide = useMediaQuery("(min-width: 1024px)"); + const [chartOptionsOpen, setChartOptionsOpen] = useState<boolean | null>(null); + return ( <section data-testid="results-section"> - <h2 className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-2 flex-wrap"> + {/* Summary on one line, controls on the next below sm: at 390px the + count, the two view tabs and Copy for AI cannot share a row. */} + <div className="mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"> + <h2 className="min-w-0 text-sm font-medium text-muted-foreground flex items-center gap-2 flex-wrap"> <span data-testid="results-summary"> {hasActiveQuery ? ( <> @@ -122,9 +136,11 @@ export default function SearchResults() { {pipelineActive ? "…" : ""} </span> )} + </h2> + <div className="flex items-center gap-2 sm:ml-auto"> <span data-testid="view-toggle" - className="ml-auto inline-flex overflow-hidden rounded-md border border-border bg-muted" + className="inline-flex overflow-hidden rounded-md border border-border bg-muted" > <button type="button" @@ -161,12 +177,20 @@ export default function SearchResults() { </button> </span> )} - </h2> + </div> + </div> {view !== "chart" && resultGroups.length > 0 && ( <div data-testid="selection-toolbar" - className="mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground" + // With a selection the toolbar is the handoff to the chat, so below lg + // it rides the bottom of the screen instead of scrolling away 40 + // cards up. `pr-48` keeps it out from under the w-44 mini player. + className={`mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground ${ + selectedCount > 0 + ? "max-lg:sticky max-lg:bottom-0 max-lg:z-10 max-lg:-mx-4 max-lg:border-t max-lg:border-border max-lg:bg-background/95 max-lg:px-4 max-lg:py-2 max-lg:pr-48 max-lg:backdrop-blur-sm max-lg:pb-[calc(0.5rem+env(safe-area-inset-bottom,0px))]" + : "" + }`} > <span> {selectedCount > 0 @@ -196,7 +220,10 @@ export default function SearchResults() { <div className="flex flex-col gap-4"> <details data-testid="chart-options" - open + open={chartOptionsOpen ?? wide} + onToggle={(e) => + setChartOptionsOpen((e.currentTarget as HTMLDetailsElement).open) + } className="rounded-lg border border-border px-3 py-2" > <summary className="cursor-pointer text-sm font-medium text-foreground"> @@ -310,6 +337,21 @@ function VirtualResultList({ }) { const flatRows = useMemo(() => buildResultRows(resultGroups), [resultGroups]); + // Which cards have had their hit cap lifted. Lives HERE, not in the card: a + // card unmounts as soon as it scrolls out of the window virtualizer, so state + // held inside it would silently reset on every scroll past. + const [expandedSlugs, setExpandedSlugs] = useState<ReadonlySet<string>>( + () => new Set(), + ); + const onToggleExpand = useCallback((slug: string) => { + setExpandedSlugs((prev) => { + const next = new Set(prev); + if (next.has(slug)) next.delete(slug); + else next.add(slug); + return next; + }); + }, []); + // "This exists elsewhere in the archive." One root-level JSON, fetched once; // absent on most sites, which is why every use of it is additive. const duplicates = useDuplicates(); @@ -389,6 +431,8 @@ function VirtualResultList({ onAsk={onAsk} duplicates={duplicates} openDuplicate={openDuplicate} + expanded={expandedSlugs.has(row.group.slug)} + onToggleExpand={onToggleExpand} /> )} </VirtualRow> @@ -410,6 +454,8 @@ const ResultCard = memo(function ResultCard({ onAsk, duplicates, openDuplicate, + expanded, + onToggleExpand, }: { group: ResultGroup; leavesById: ReadonlyMap<string, LeafInfo>; @@ -422,6 +468,8 @@ const ResultCard = memo(function ResultCard({ onAsk: (slug: string) => void; duplicates: DuplicateLookup; openDuplicate: (slug: string, seconds: number) => void; + expanded: boolean; + onToggleExpand: (slug: string) => void; }) { // Other copies of this video elsewhere in the archive. // @@ -455,8 +503,29 @@ const ResultCard = memo(function ResultCard({ return m; }, [group.hits, orderedLeafIds]); + // A card with 500 hits is not a result, it is a wall — and it is why the + // search page was 43,000px tall on a phone and unreadable on a desktop too. + // Show the first few across all the layer sections, and let the reader ask + // for the rest. The cap is per CARD, not per section, so there is exactly one + // "Show all" control and it sits outside the hit lists. + const shownBuckets = useMemo(() => { + if (expanded) return buckets; + let left = CARD_HIT_CAP; + const m = new Map<string, LayerHit[]>(); + for (const [id, hits] of buckets) { + const take = Math.min(left, hits.length); + m.set(id, take === hits.length ? hits : hits.slice(0, take)); + left -= take; + } + return m; + }, [buckets, expanded]); + const capped = group.hits.length > CARD_HIT_CAP; + + const cardRef = useRef<HTMLDivElement | null>(null); + return ( <div + ref={cardRef} data-result-slug={group.slug} data-card-header="" className="border border-border rounded-lg overflow-hidden bg-card" @@ -467,7 +536,11 @@ const ResultCard = memo(function ResultCard({ : undefined } > - <div className="flex items-stretch border-b border-border bg-muted"> + {/* Below sm the header is two rows — (checkbox + title) then (badges, + channel · date · N hits, Ask) — because a single truncating row turned + every title into three characters and an ellipsis. */} + <div className="flex flex-col sm:flex-row sm:items-stretch border-b border-border bg-muted"> + <div className="flex min-w-0 flex-1 items-stretch"> {/* Per-card selection: an inclusion checkbox that builds the AI grounding subset. Kept quiet — the boldness is the chat's grounding palette. */} <label @@ -486,14 +559,16 @@ const ResultCard = memo(function ResultCard({ variant="ghost" data-card-open="" onClick={() => openWithMode(group.slug)} - className="min-w-0 flex-1 h-auto justify-start text-left items-baseline gap-2 px-2 py-2 rounded-none bg-transparent font-normal" + className="min-w-0 flex-1 h-auto flex-wrap sm:flex-nowrap whitespace-normal justify-start text-left items-baseline gap-x-2 gap-y-1 px-2 py-2 rounded-none bg-transparent font-normal" > {/* A post has no title, no livestream/age state and no VOD expiry — its body IS the headline, so the card leads with the text and a platform badge instead of the video decorations. */} {group.post ? ( <> - <span className="truncate flex-1 min-w-0">{group.post.text}</span> + <span className="line-clamp-2 sm:line-clamp-none sm:truncate flex-1 min-w-0"> + {group.post.text} + </span> {/* A deleted post is the archive's most valuable artefact — flag it with the same badge videos use. */} {group.post.isDeleted && <VideoStateBadge state="deleted" />} @@ -501,7 +576,7 @@ const ResultCard = memo(function ResultCard({ </> ) : ( <> - <span className="font-medium truncate flex-1 min-w-0"> + <span className="font-medium line-clamp-2 sm:line-clamp-none sm:truncate flex-1 min-w-0"> {group.title} </span> {group.isLivestream && <LivestreamBadge />} @@ -518,20 +593,21 @@ const ResultCard = memo(function ResultCard({ })()} </> )} - <span className="text-xs text-muted-foreground shrink-0"> + <span className="basis-full sm:basis-auto text-xs text-muted-foreground shrink-0"> {group.channel && `${group.channel} · `} {group.date} {group.hits.length > 0 && ` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`} </span> </Button> + </div> <button type="button" onClick={() => onAsk(group.slug)} title={ group.post ? "Ask the AI about this post" : "Ask the AI about this video" } - className="flex shrink-0 items-center gap-1 border-l border-border px-3 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground" + className="flex shrink-0 items-center justify-center gap-1 border-t border-border px-3 py-2 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground sm:justify-start sm:border-t-0 sm:border-l sm:py-0" > <MessageSquareIcon className="size-3.5" /> Ask </button> @@ -544,7 +620,7 @@ const ResultCard = memo(function ResultCard({ /> )} <ul className="flex flex-col divide-y divide-border"> - {Array.from(buckets.entries()).map(([leafId, hits]) => { + {Array.from(shownBuckets.entries()).map(([leafId, hits]) => { if (hits.length === 0) return null; const leafInfo = leavesById.get(leafId); if (!leafInfo) return null; @@ -568,7 +644,8 @@ const ResultCard = memo(function ResultCard({ {leafInfo.query} </span> <span className="ml-auto text-[10px] text-muted-foreground"> - {hits.length} hit{hits.length === 1 ? "" : "s"} + {buckets.get(leafId)?.length ?? hits.length} hit + {(buckets.get(leafId)?.length ?? hits.length) === 1 ? "" : "s"} </span> </div> <ul className="flex flex-col divide-y divide-border/60"> @@ -592,6 +669,27 @@ const ResultCard = memo(function ResultCard({ ); })} </ul> + {capped && ( + <button + type="button" + onClick={() => { + const wasExpanded = expanded; + onToggleExpand(group.slug); + // Collapsing a 500-hit card removes thousands of pixels above the + // viewport; without this the reader is left somewhere else entirely. + if (wasExpanded) { + requestAnimationFrame(() => + cardRef.current?.scrollIntoView({ block: "nearest" }), + ); + } + }} + className="w-full border-t border-border px-4 py-2.5 text-left text-xs text-brand transition-colors hover:bg-accent" + > + {expanded + ? "Show fewer hits" + : `Show all ${group.hits.length} hits`} + </button> + )} </div> ); }); @@ -684,7 +782,7 @@ const HitRow = memo(function HitRow({ }`} > {!noSeek && ( - <span className="text-xs font-mono text-muted-foreground shrink-0 w-16"> + <span className="text-xs font-mono text-muted-foreground shrink-0 w-14 sm:w-16"> {hit.scope === "metadata" ? "—" : formatSeconds(hit.start)} </span> )} diff --git a/common/components/charts/ChartView.tsx b/common/components/charts/ChartView.tsx @@ -23,6 +23,7 @@ import { ChartTooltipContent, type ChartConfig as ShadcnChartConfig, } from "../ui/chart"; +import { useMediaQuery } from "../../lib/useMediaQuery"; import type { ChartData } from "../../lib/chartAggregate"; import { xAxisLabel, yAxisLabel, type ChartConfig } from "../../lib/chartConfig"; @@ -49,6 +50,10 @@ export function ChartView({ config: ChartConfig; }) { const meta = useMemo(() => seriesMeta(data, config), [data, config]); + // A 52px Y gutter and a 16px minimum tick gap are a desktop chart's + // proportions; on a 390px canvas they eat the plot and crowd the axis into + // overlapping labels. + const narrow = !useMediaQuery("(min-width: 640px)"); const shadcnConfig = useMemo<ShadcnChartConfig>(() => { const c: ShadcnChartConfig = {}; @@ -68,7 +73,7 @@ export function ChartView({ if (data.categories.length === 0) { return ( - <div className="flex h-[280px] items-center justify-center text-sm text-muted-foreground"> + <div className="flex h-[240px] sm:h-[320px] items-center justify-center text-sm text-muted-foreground"> No data for this configuration. </div> ); @@ -87,7 +92,7 @@ export function ChartView({ pieConfig[`p${i}`] = { label, color: `var(--chart-${(i % 5) + 1})` }; }); return ( - <ChartContainer config={pieConfig} className="h-[280px] w-full"> + <ChartContainer config={pieConfig} className="h-[240px] sm:h-[320px] w-full"> <PieChart> <ChartTooltip content={<ChartTooltipContent />} /> <Pie data={pieData} dataKey="value" nameKey="name" innerRadius={40}> @@ -111,7 +116,7 @@ export function ChartView({ tickLine={false} axisLine={false} tickMargin={8} - minTickGap={16} + minTickGap={narrow ? 28 : 16} label={{ value: xAxisLabel(config.x), position: "insideBottom", @@ -123,7 +128,7 @@ export function ChartView({ key="y" tickLine={false} axisLine={false} - width={52} + width={narrow ? 40 : 52} label={{ value: yAxisLabel(config), angle: -90, @@ -140,7 +145,7 @@ export function ChartView({ const margin = { top: 8, right: 12, bottom: 20, left: 8 }; return ( - <ChartContainer config={shadcnConfig} className="h-[280px] w-full"> + <ChartContainer config={shadcnConfig} className="h-[240px] sm:h-[320px] w-full"> {config.type === "line" ? ( <LineChart data={rows} margin={margin} accessibilityLayer> {common} diff --git a/export/e2e/search-results-virtualization.spec.ts b/export/e2e/search-results-virtualization.spec.ts @@ -130,12 +130,18 @@ test.describe("search results virtualization", () => { `${VIDEO_COUNT} videos`, ); - // 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-card-header][data-result-slug="${DENSE_SLUG}"] li button`, + // The dense card is the first in the list. A card shows the first 8 hits + // and offers the rest behind one button (the per-card hit cap); once it is + // expanded, all of its hit rows are in the DOM — intra-card layout is + // normal flow, not nested virtualization. + const denseCard = page.locator( + `[data-card-header][data-result-slug="${DENSE_SLUG}"]`, ); + const denseHitButtons = denseCard.locator("li button"); + await expect(denseHitButtons).toHaveCount(8); + await denseCard + .getByRole("button", { name: `Show all ${DENSE_HIT_COUNT} hits` }) + .click(); await expect(denseHitButtons).toHaveCount(DENSE_HIT_COUNT); // Scrolling past the dense card unmounts it (along with all of its