commit 292de396ded34fb74230b48caef0e33126008101
parent 4ea812b3c23899c3148e070363352a9453077734
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 29 Aug 2026 21:05:55 -0400
pages: duplicates windows its list, and touch targets grow
The duplicate clusters were all in the DOM at once; the list is now
windowed against the page scroll, so a 23,000px page is six cards and
400 nodes. Its filter stack folds away below lg, its checkboxes get
rows you can hit with a thumb, and the offline, downloads, hub and post
chrome stop overflowing or hiding controls behind a hover nobody on a
phone can perform.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
7 files changed, 121 insertions(+), 36 deletions(-)
diff --git a/common/components/PostModal.tsx b/common/components/PostModal.tsx
@@ -74,7 +74,7 @@ export default function PostModal() {
if (e.target === e.currentTarget) close();
}}
>
- <div className="w-full max-w-2xl rounded-lg border border-border bg-card shadow-lg">
+ <div className="max-h-[90dvh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-card shadow-lg">
<div className="flex items-center gap-2 border-b border-border px-4 py-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
{post.data ? platformLabel(post.data.platform) : "Post"}
@@ -84,7 +84,7 @@ export default function PostModal() {
variant="ghost"
aria-label="Close"
onClick={close}
- className="ml-auto h-auto px-2 py-1"
+ className="ml-auto size-11 sm:size-9"
>
<XIcon className="size-4" />
</Button>
diff --git a/export/app/components/OfflineManager.tsx b/export/app/components/OfflineManager.tsx
@@ -224,7 +224,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) {
title={
!online ? "Reconnect to build the index" : undefined
}
- className="rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted disabled:opacity-50"
+ className="min-h-9 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted disabled:opacity-50"
>
{st.index === "building" ? "Indexing…" : "Build index"}
</button>
@@ -235,7 +235,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) {
type="button"
onClick={() => remove(c.slug)}
aria-label={`remove offline copy of ${c.slug}`}
- className="rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted"
+ className="min-h-9 rounded-md border border-border px-2.5 py-1 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:bg-muted"
>
Remove
</button>
@@ -246,7 +246,7 @@ export function OfflineManager({ channels }: { channels: OfflineChannel[] }) {
onClick={() => download(c.slug)}
aria-label={`download ${c.slug} for offline`}
title={!online ? "Reconnect to download" : undefined}
- className="rounded-md border border-brand/30 bg-brand-soft px-2.5 py-1 text-xs font-medium text-brand transition-colors hover:bg-brand/15 disabled:opacity-50"
+ className="min-h-9 rounded-md border border-brand/30 bg-brand-soft px-2.5 py-1 text-xs font-medium text-brand transition-colors hover:bg-brand/15 disabled:opacity-50"
>
{st.download === "downloading" ? "Downloading…" : "Download"}
</button>
@@ -311,7 +311,7 @@ function OfflineSearch({
onChange={(e) => setTerm(e.target.value)}
aria-label="offline search"
placeholder="Search indexed transcripts…"
- className="rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
+ className="h-11 rounded-md border border-input bg-background px-3 py-2 text-base text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring sm:h-9 sm:text-sm"
/>
</label>
{term.trim().length >= 2 && (
diff --git a/export/app/components/hub/AddArchive.tsx b/export/app/components/hub/AddArchive.tsx
@@ -59,7 +59,7 @@ export default function AddArchive() {
setState({ status: "idle" });
}
}}
- className="flex-1 min-w-64 rounded-md border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground/70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
+ className="w-full min-w-0 flex-1 rounded-md border border-border bg-background px-3 py-2 text-base text-foreground sm:w-auto sm:min-w-64 sm:text-sm placeholder:text-muted-foreground/70 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand"
aria-label="Archive URL"
/>
<button
diff --git a/export/app/components/hub/ArchiveShelf.tsx b/export/app/components/hub/ArchiveShelf.tsx
@@ -32,7 +32,7 @@ function Spine({
return (
<li
data-testid="shelf-spine"
- className="shelf-spine group relative flex min-w-52 max-w-64 flex-col gap-2 rounded-md border border-border bg-card/60 py-3 pl-4 pr-3"
+ className="shelf-spine group relative flex w-full min-w-0 max-w-full flex-col gap-2 rounded-md border border-border bg-card/60 py-3 pl-4 pr-3 sm:w-auto sm:min-w-52 sm:max-w-64"
style={{ animationDelay: `${Math.min(index, 8) * 45}ms` }}
>
{/* The spine: a thick accent edge in the site's own color. */}
@@ -50,7 +50,9 @@ function Spine({
type="button"
onClick={() => onRemove(site.origin)}
aria-label={`Remove ${site.siteTitle}`}
- className="-mr-1 -mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
+ // Reveal-on-hover is not an affordance on a touch screen: a device
+ // with no hover shows it always. `hover:` never gates a function.
+ className="-mr-1 -mt-0.5 rounded p-0.5 text-muted-foreground/60 opacity-0 transition-opacity pointer-coarse:opacity-100 hover:text-destructive focus-visible:opacity-100 group-hover:opacity-100"
>
<X className="size-3.5" aria-hidden="true" />
</button>
diff --git a/export/app/components/hub/HubOfflineManager.tsx b/export/app/components/hub/HubOfflineManager.tsx
@@ -111,7 +111,7 @@ export default function HubOfflineManager() {
return (
<li
key={c.key}
- className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-1.5 text-sm"
+ className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 rounded border border-border/60 px-3 py-1.5 text-sm"
>
<span className="truncate text-foreground">{c.name}</span>
{st.status === "downloading" ? (
diff --git a/export/app/downloads/page.tsx b/export/app/downloads/page.tsx
@@ -120,7 +120,7 @@ function HoldingRow({ entry }: { entry: ArchiveManifestEntry }) {
<a
href={href}
download
- className="inline-flex shrink-0 items-center gap-2 rounded-md bg-brand-soft px-3.5 py-2 font-mono text-sm font-medium text-brand transition-colors hover:bg-brand hover:text-brand-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
+ className="inline-flex min-h-10 shrink-0 items-center gap-2 rounded-md bg-brand-soft px-3.5 py-2 font-mono text-sm font-medium text-brand transition-colors hover:bg-brand hover:text-brand-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-card"
>
<ArrowDownToLine className="size-4" aria-hidden />
{humanBytes(entry.bytes)}
diff --git a/export/app/duplicates/DuplicatesClient.tsx b/export/app/duplicates/DuplicatesClient.tsx
@@ -1,7 +1,14 @@
"use client";
-import { useEffect, useMemo, useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
+import {
+ useWindowVirtualizer,
+ type VirtualItem,
+} from "yt-dlp-transcript-common/components/virtualizer";
import { usePlayer } from "yt-dlp-transcript-common/components/PlayerProvider";
+import { useMediaQuery } from "yt-dlp-transcript-common/lib/useMediaQuery";
+import { Checkbox } from "yt-dlp-transcript-common/components/ui/checkbox";
+import { VirtualRow } from "yt-dlp-transcript-common/components/VirtualRow";
import { fetchTranscript } from "yt-dlp-transcript-common/components/transcriptCache";
import { formatDate } from "yt-dlp-transcript-common/lib/format";
import type {
@@ -280,15 +287,7 @@ function DuplicatesView({
No duplicates match the current filters.
</p>
) : (
- <ul className="flex flex-col gap-3">
- {visible.map((cluster) => (
- <DuplicateClusterCard
- key={cluster.clusterId}
- cluster={cluster}
- details={details}
- />
- ))}
- </ul>
+ <ClusterList clusters={visible} details={details} />
)}
</section>
);
@@ -329,9 +328,24 @@ function FilterPanel({
onToggleChannel: (c: string) => void;
}) {
const platformLabel = (n: number) => `platform${n === 1 ? "" : "s"}`;
+ // Five filter rows above the clusters is most of a phone screen. Open where
+ // there is room, closed where there isn't, and whatever the reader last chose
+ // once they choose.
+ const wide = useMediaQuery("(min-width: 1024px)");
+ const [userOpen, setUserOpen] = useState<boolean | null>(null);
+ const narrowed =
+ (query.trim() !== "" ? 1 : 0) +
+ (platforms.size !== presentPlatforms.length ? 1 : 0) +
+ (matchKinds.size !== MATCH_KINDS.length ? 1 : 0) +
+ (relationships.size > 0 ? 1 : 0) +
+ (channels.size > 0 ? 1 : 0);
return (
- <details open className="text-sm text-muted-foreground">
- <summary className="cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1">
+ <details
+ open={userOpen ?? wide}
+ onToggle={(e) => setUserOpen((e.currentTarget as HTMLDetailsElement).open)}
+ className="text-sm text-muted-foreground"
+ >
+ <summary className="cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1 py-1">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
Filters
</span>
@@ -339,6 +353,11 @@ function FilterPanel({
{platforms.size} of {presentPlatforms.length}{" "}
{platformLabel(presentPlatforms.length)}
</span>
+ {narrowed > 0 && (
+ <span className="text-xs text-muted-foreground">
+ · {narrowed} narrowed
+ </span>
+ )}
</summary>
<div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="flex items-center gap-1.5 select-none">
@@ -351,7 +370,7 @@ function FilterPanel({
placeholder="title or channel…"
value={query}
onChange={(e) => onQuery(e.target.value)}
- className="rounded-md border border-border bg-card px-2 py-1 text-sm"
+ className="h-11 rounded-md border border-border bg-card px-2 py-1 text-base sm:h-8 sm:text-sm"
/>
</label>
<FilterRow label="Platforms">
@@ -432,13 +451,9 @@ function Check({
children: React.ReactNode;
}) {
return (
- <label className="flex items-center gap-1.5 select-none">
- <input
- type="checkbox"
- checked={checked}
- onChange={onChange}
- className="accent-brand"
- />
+ // py-1.5 so the whole row is a tap target, not just the 16px box.
+ <label className="flex cursor-pointer items-center gap-1.5 py-1.5 select-none">
+ <Checkbox checked={checked} onCheckedChange={onChange} />
{children}
</label>
);
@@ -456,7 +471,7 @@ function DuplicateClusterCard({
details: DetailMap;
}) {
return (
- <li
+ <article
aria-label={`duplicate cluster ${cluster.clusterId}`}
className="border border-border rounded-lg overflow-hidden bg-card"
>
@@ -476,7 +491,75 @@ function DuplicateClusterCard({
<MemberRow key={ref.slug} member={ref} detail={details.get(ref.slug)} />
))}
</ul>
- </li>
+ </article>
+ );
+}
+
+// The cluster list, windowed against the page scroll — a site with a real
+// duplicate report renders tens of thousands of pixels of cards, and all of it
+// used to be in the DOM at once. Same shape as SearchResults' list: a spacer
+// div of the full height, absolutely-positioned rows, and a `scrollMargin` that
+// tracks where the list starts on the page.
+const CLUSTER_ESTIMATE_PX = 140;
+const CLUSTER_GAP_PX = 12;
+
+function ClusterList({
+ clusters,
+ details,
+}: {
+ clusters: DuplicateCluster[];
+ details: DetailMap;
+}) {
+ const listRef = useRef<HTMLUListElement | null>(null);
+ const [scrollMargin, setScrollMargin] = useState(0);
+
+ useEffect(() => {
+ if (typeof window === "undefined") return;
+ const update = () => {
+ const el = listRef.current;
+ if (!el) return;
+ setScrollMargin(el.getBoundingClientRect().top + window.scrollY);
+ };
+ update();
+ const ro = new ResizeObserver(update);
+ if (listRef.current) ro.observe(listRef.current);
+ ro.observe(document.body);
+ window.addEventListener("resize", update);
+ return () => {
+ ro.disconnect();
+ window.removeEventListener("resize", update);
+ };
+ }, []);
+
+ const virtualizer = useWindowVirtualizer({
+ count: clusters.length,
+ estimateSize: () => CLUSTER_ESTIMATE_PX + CLUSTER_GAP_PX,
+ overscan: 4,
+ scrollMargin,
+ });
+
+ return (
+ <ul
+ ref={listRef}
+ className="relative"
+ style={{ height: virtualizer.getTotalSize() }}
+ >
+ {virtualizer.getVirtualItems().map((vi: VirtualItem) => {
+ const cluster = clusters[vi.index];
+ return (
+ <VirtualRow
+ as="li"
+ key={cluster.clusterId}
+ measureRef={virtualizer.measureElement}
+ index={vi.index}
+ offset={vi.start - scrollMargin}
+ className="pb-3"
+ >
+ <DuplicateClusterCard cluster={cluster} details={details} />
+ </VirtualRow>
+ );
+ })}
+ </ul>
);
}
@@ -494,15 +577,15 @@ function MemberRow({
const title = detail?.title || member.title || member.slug;
return (
<li className="flex flex-col gap-0.5 px-4 py-2 hover:bg-muted">
- <div className="flex items-baseline gap-2">
+ <div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<button
type="button"
onClick={() => openTranscript(member.slug)}
- className="font-medium truncate flex-1 min-w-0 text-left hover:underline"
+ className="font-medium line-clamp-2 sm:truncate flex-1 min-w-0 text-left hover:underline"
>
{title}
</button>
- <span className="text-xs text-muted-foreground shrink-0">
+ <span className="basis-full sm:basis-auto text-xs text-muted-foreground shrink-0">
{member.channel || member.channelSlug} · {member.platform} ·{" "}
{formatDate(member.uploadDate)}
</span>