commit 2dbc7d622f624438b2aa16264745aeda82055089
parent 3da64705ccff7146af9abc5904711b32c66e22de
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 29 Aug 2026 19:43:12 -0400
export: one media-query hook, one player geometry, one viewport
The workspace's hand-rolled matchMedia effect becomes
common/lib/useMediaQuery, so every future breakpoint reader agrees
within a render. Player width and top edge move into CSS custom
properties, the viewport opts into the safe area and a keyboard that
resizes the layout, and the manifest stops locking portrait on an
archive that is mostly video.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
6 files changed, 410 insertions(+), 13 deletions(-)
diff --git a/common/lib/useMediaQuery.ts b/common/lib/useMediaQuery.ts
@@ -0,0 +1,39 @@
+"use client";
+
+// One media query, read as external state.
+//
+// The app is mobile-first: layout decisions live in CSS, and JS only reads a
+// breakpoint when a *structure* has to change (which container holds the
+// filters, whether Split is offered, whether a `<details>` starts open). Every
+// such reader goes through this hook so they all agree within a single render —
+// `useSyncExternalStore` re-reads `matches` synchronously, so two components
+// asking the same question in the same commit can never disagree.
+//
+// The server snapshot is `false`: SSR has no viewport, so the narrow variant is
+// what gets prerendered. That is deliberate — the narrow shape is the one that
+// works everywhere — and it means callers must render layout *shells*
+// unconditionally and gate only their contents on the result, so the first
+// client render after hydration never shifts the main column.
+
+import { useCallback, useSyncExternalStore } from "react";
+
+export function useMediaQuery(query: string): boolean {
+ const subscribe = useCallback(
+ (onChange: () => void) => {
+ if (typeof window === "undefined" || !window.matchMedia) return () => {};
+ const mq = window.matchMedia(query);
+ mq.addEventListener("change", onChange);
+ return () => mq.removeEventListener("change", onChange);
+ },
+ [query],
+ );
+
+ const getSnapshot = useCallback(() => {
+ if (typeof window === "undefined" || !window.matchMedia) return false;
+ return window.matchMedia(query).matches;
+ }, [query]);
+
+ return useSyncExternalStore(subscribe, getSnapshot, () => false);
+}
+
+export default useMediaQuery;
diff --git a/export/app/(workspace)/WorkspaceView.tsx b/export/app/(workspace)/WorkspaceView.tsx
@@ -18,6 +18,7 @@
import { useEffect, useState, type ReactNode } from "react";
import { usePathname } from "next/navigation";
+import { useMediaQuery } from "yt-dlp-transcript-common/lib/useMediaQuery";
import SearchResults from "yt-dlp-transcript-common/components/SearchResults";
import AskChat from "../ask/AskChat";
import WorkspaceNav from "./WorkspaceNav";
@@ -33,7 +34,7 @@ export default function WorkspaceView({ children }: { children: ReactNode }) {
// The only persisted view state: whether the user opted into Split. The single-
// pane choice always follows the route, so a direct /ask load shows the chat.
const [splitOn, setSplitOn] = useState(false);
- const [wide, setWide] = useState(false);
+ const wide = useMediaQuery("(min-width: 1024px)");
useEffect(() => {
/* eslint-disable react-hooks/set-state-in-effect -- one-time restore from
@@ -47,15 +48,6 @@ export default function WorkspaceView({ children }: { children: ReactNode }) {
/* eslint-enable react-hooks/set-state-in-effect */
}, []);
- useEffect(() => {
- if (typeof window === "undefined" || !window.matchMedia) return;
- const mq = window.matchMedia("(min-width: 1024px)");
- const sync = () => setWide(mq.matches);
- sync();
- mq.addEventListener("change", sync);
- return () => mq.removeEventListener("change", sync);
- }, []);
-
const setSplit = (on: boolean) => {
setSplitOn(on);
try {
diff --git a/export/app/globals.css b/export/app/globals.css
@@ -13,6 +13,33 @@ body {
font-family: var(--font-sans), ui-sans-serif, system-ui, sans-serif;
}
+/* Player geometry, in one place. The floating player (PlayerProvider) and the
+ transcript modal's reserved slot (TranscriptModal) both have to agree on how
+ wide the video is and where its top edge sits — they used to carry two copies
+ of `min(92vw,960px)` and `top-16`, which is exactly the kind of pair that
+ drifts. Below sm the player is full-bleed and tucked under the notch; from sm
+ it is the centred card it has always been. */
+:root {
+ --player-w: 100vw;
+ --player-top: env(safe-area-inset-top, 0px);
+}
+@media (min-width: 640px) {
+ :root {
+ --player-w: min(92vw, 960px);
+ --player-top: 4rem; /* was `top-16` */
+ }
+}
+
+/* Safe-area padding for the sticky bars (composer, selection toolbar, the
+ modal's control strip). Zero on every desktop browser, the home-indicator
+ inset on an iPhone — with `viewportFit: "cover"` set in layout.tsx. */
+@utility pb-safe {
+ padding-bottom: env(safe-area-inset-bottom, 0px);
+}
+@utility pt-safe {
+ padding-top: env(safe-area-inset-top, 0px);
+}
+
/* Archive shelf: a restrained load stagger — the spines rise onto the shelf in
sequence (per-spine delay set inline). Motion is the only animation on the
hub, and it yields entirely to a reduced-motion preference. */
diff --git a/export/app/layout.tsx b/export/app/layout.tsx
@@ -44,8 +44,20 @@ export function generateMetadata(): Metadata {
}
export function generateViewport(): Viewport {
- // Browser chrome color tracks the site's brand accent (falls back to base brand).
- return { themeColor: parseAccent(currentSite().accent) ?? FALLBACK_THEME_COLOR };
+ return {
+ // Browser chrome color tracks the site's brand accent (falls back to base brand).
+ themeColor: parseAccent(currentSite().accent) ?? FALLBACK_THEME_COLOR,
+ width: "device-width",
+ initialScale: 1,
+ // Let the layout run under the notch/home indicator; the safe-area insets
+ // are then non-zero and `pb-safe`/`pt-safe` (globals.css) keep the sticky
+ // bars — composer, selection toolbar, player control strip — clear of it.
+ // No maximumScale/userScalable: pinch-zoom is never taken away.
+ viewportFit: "cover",
+ // The on-screen keyboard resizes the layout viewport instead of covering
+ // it, so a `sticky bottom-0` composer stays above the keyboard.
+ interactiveWidget: "resizes-content",
+ };
}
export default async function RootLayout({
diff --git a/export/app/manifest.ts b/export/app/manifest.ts
@@ -24,7 +24,8 @@ export default function manifest(): MetadataRoute.Manifest {
start_url: "/",
scope: "/",
display: "standalone",
- orientation: "portrait-primary",
+ // No `orientation` lock: the archive is mostly video, and landscape is the
+ // right shape for the player view.
background_color: "#0a0a0a",
theme_color: themeColor,
icons: [
diff --git a/plans/export-responsive-redesign.md b/plans/export-responsive-redesign.md
@@ -0,0 +1,326 @@
+# Export site — mobile-first responsive redesign
+
+## Context
+
+The `export/` app (the public static archive: jeralyzer.pages.dev etc., plus hub mode) was
+designed desktop-first at `max-w-6xl` and relies on `flex-wrap` everywhere. Measured against
+a built `export/out` served locally at iPhone-13 width (390px) on 2026-08-29:
+
+- **Header** wraps to three rows inside a fixed `h-14`, so rows 2–3 render *outside* the
+ sticky header's background and page content scrolls through them (`Header.tsx:43`).
+- **Footer** row A has no `flex-wrap` + `whitespace-nowrap` → horizontal overflow at 360px.
+- **Search page**: profile row, query builder, and the fully expanded filter stack (channels,
+ type, audience, availability ×6, dates, advanced) occupy ~2 screens *above* the results —
+ on `/ask` too, before the user ever sees "Ask a question". The `/?q=election` full page is
+ 43,000px tall (every hit of every video rendered inline); `/duplicates` is 68,000px.
+- **Transcript modal** reserves the player slot with `calc(min(92vw,960px)*9/16 + 3rem)` and
+ `100vh`, leaving ~100px for the cue list at 360px; its control bar is 13 unlabeled 32px
+ emoji buttons (`title` tooltips only — invisible on touch) wrapping to 2–4 rows.
+- **Ask pane** has zero breakpoint classes; provider settings, grounding palette, pinned
+ panel, saved chats, context, report and the `max-h-[60vh]` nested message scroller stack in
+ one column with the composer at the very bottom.
+- Result-card headers are single-row `truncate` (title → "Hasan…"), hit rows spend a `w-16`
+ gutter, checkboxes are 16px, many buttons are `h-6`/`text-xs`.
+- Only viewport-aware JS in the app: `WorkspaceView.tsx:51` (`min-width:1024px` gating Split).
+ Only breakpoint classes outside the workspace: five `sm:` utilities. No `dvh`, no safe-area.
+
+Goal: a redesign that keeps **every** feature and every e2e hook, is designed mobile-first and
+works at any width, puts the standard path (type → results → open transcript / ask) in front,
+and folds the power features (query tree, profiles, advanced throughput, sweeps, clips…) behind
+explicit, discoverable disclosure that scales up to always-visible on wide screens.
+
+Screenshots for reference: `/home/user/.claude/jobs/a61f2393/tmp/shots/` (phone_* / desktop_*).
+
+## Non-negotiable invariants
+
+1. **Mount architecture is unchanged.** `(workspace)/layout.tsx → SiteWorkspace` keeps
+ `PlayerProvider > SingleSiteDataProvider > SearchSessionProvider > [bar, WorkspaceView]` with
+ `TranscriptModal`/`PostModal` siblings. Both panes stay lazily-mounted-then-latched and are
+ hidden with CSS, never unmounted (`WorkspaceView.tsx:76-121`). Streaming chat, the
+ AbortController, and the pre-hydration nav-click guard depend on it. Hub mode
+ (`TranscriptSearch.tsx`, `HubHome`, `AskHub`) keeps bypassing the shell; it inherits the
+ redesigned bar/results automatically and must not assume a player or search session exists.
+2. **State contracts are unchanged.** `SearchSessionContext` (draft/committed filters, profiles,
+ selection/grounding, view + chartShape, advanced opts, URL params `qt/view/cs/v/t/vm` + share
+ v2 keys), `useAskChat`, and every `ytdlp-tb:*` localStorage key keep their shapes. This is a
+ view-layer restructuring.
+3. **Every e2e hook survives with identical strings** (full list in §Hooks). E2E runs at
+ 1440×1200 (`export/playwright.config.ts`), so the desktop layout must expose everything the
+ specs reach today with at most the small helper edits listed per slice.
+4. Reduced-motion opt-outs stay on every animation; `hover:` never gates a *function* (use
+ `@media (hover: hover)` for reveal-on-hover).
+
+## Design
+
+### Breakpoint tiers (Tailwind v4 defaults)
+| Tier | Width | Shape |
+|---|---|---|
+| base | <640 | one column; sticky compact search bar; sheets for filters/menu; full-screen player view; sticky composer & selection bar with safe-area padding |
+| sm | ≥640 | same column, wider chips/rows; filters sheet slides from the right; modal returns to the centred `min(92vw,960px)` card |
+| md | ≥768 | header nav inline (no hamburger) |
+| lg | ≥1024 | Split (search ‖ chat) offered, as today; chart options + grounding panels expanded by default |
+| xl | ≥1280 | filters become an inline, collapsible left sidebar next to results |
+
+Shared plumbing: `common/lib/useMediaQuery.ts` (`useSyncExternalStore`, server snapshot
+`false`, used for `wide` and the filters container); `generateViewport` gains
+`viewportFit:"cover"` + `interactiveWidget:"resizes-content"`; `100vh → 100dvh`; safe-area
+utilities (`pb-[env(safe-area-inset-bottom)]`); manifest drops `orientation:"portrait-primary"`
+(video wants landscape). Inputs keep `text-base md:text-sm` (iOS zoom guard). Primary-path tap
+targets ≥ 44px, secondary ≥ 36px; checkbox labels get padded rows.
+
+### 1. Shell — header, footer, menu
+- `Header.tsx`: `h-14 → min-h-14`, no wrap. Brand left. **< md**: right cluster = ThemeToggle +
+ hamburger (`aria-label="Open menu"`) opening a `Sheet side="right"` (`ui/sheet.tsx`, currently
+ unused anywhere) with: primary links (Search, **Ask AI** (new link, `/ask/`), Duplicates,
+ Downloads, Use with AI, Offline when `site.pwa`, Changelog), a "Sites" section listing
+ `resolveRelatedSites` groups, the Hub backlink (today `hidden sm:inline-flex` — it becomes
+ reachable on phones), then Theme family + Mode radio lists. Header is a server component:
+ add a small client `MobileMenu` that receives serialised link groups as props. **md+**: inline
+ nav as today plus "Ask AI"; SiblingSwitcher/ThemeMenu/ThemeToggle unchanged (their roles are
+ test hooks).
+- `Footer.tsx`: `flex-wrap` on row A and its inner group, stack below sm, drop
+ `whitespace-nowrap`, safe-area bottom padding. All links/gating unchanged.
+
+### 2. Search bar (the standard path)
+`WorkspaceSearchBar.tsx` (910 lines) splits into `SearchBar` + `FiltersPanel` (+ the existing
+`QueryBuilder`). `SearchBar` is **sticky below the header on < lg** (top offset = header height):
+- **Row 1**: the compact leaf's input (`leaf-query-<id>`, `type=search`, 44px tall, flex-1) +
+ Search submit (`data-testid="search-submit"`, `data-dirty`; icon-only below sm with
+ `aria-label="Search"`, label from sm).
+- **Row 2**: a horizontally scrolling chip row (`overflow-x-auto`, no scrollbar, snap):
+ `WorkspaceNav` [Search|Chat|Split] (moved here from `WorkspaceView`; Split still gated on
+ `wide`), scope `<select>` (`leaf-scope-<id>`), Regex toggle (`leaf-regex-<id>`),
+ **Layers** chip (`compact-add-layer` + add-group; shows leaf count when the tree is non-compact),
+ **Filters** chip with an active-count badge ("3 of 5 channels · dates") that opens the
+ FiltersPanel container, **Share** (`button` named "Share current search" → "Link copied!",
+ icon-only on phone via `aria-label`), Reset layers (`reset-layers`). The dirty hint and the
+ live-chat hint become a one-line status under the bar.
+- The full query tree (non-compact) renders inline under the bar at all sizes, as today; leaf
+ cards put the input on its own row and the scope/regex/hits/NOT/count/wrap/× controls on a
+ wrapping second row below sm. Alias suggestions unchanged.
+
+`FiltersPanel` (one component, one mount): profiles row (`profiles-row`, select, dirty dot, Save,
+Save as…, Revert, Rename, Delete — moved here, since a profile *is* a saved filter+query set) →
+Channels (All / Reset channels / Reset all / Reset everything, inline chips, collapsible group
+boxes) → Type → Audience → Availability (nested Missing states) → Date (From/To stacked below sm)
+→ **Advanced** nested `<details>` (Max hits per batch + No limit, Fetch concurrency, Flush
+interval, Search through all transcripts, Reset to defaults). Container chosen by
+`useMediaQuery("(min-width:1280px)")`: **xl+** inline left sidebar (~280px) in the workspace
+grid, collapsible to a rail button, persisted with the existing `filtersCollapsed`; **< xl**
+`Sheet` (`side="bottom"`, `max-h-[90dvh]` below sm; `side="right"` from sm) with a sticky
+"Apply/Search" footer button that commits and closes. At the 1440 e2e viewport the sidebar is
+inline and open by default, so filter specs keep passing; add `openFilters(page)` to
+`e2e/helpers.ts` for any spec that asserts on a collapsed state.
+
+### 3. Results
+- Summary line (`results-summary`, loading/progress) on its own row; toolbar row wraps:
+ [Results|Chart] (`view-toggle`), Copy for AI, selection controls.
+- **Card header** (`data-card-header`): below sm two rows — (checkbox + title clamped to 2 lines)
+ then (badges + `channel · date · N hits` + Ask); from sm the current single row. Titles never
+ truncate to a few characters again.
+- **Per-card hit cap**: first 8 hits per leaf section + "Show all N hits" (real button; all sizes).
+ This is what turns 43k-px pages into scannable ones. Virtualizer must re-measure on expand
+ (`measureElement` / `estimateSize` in `SearchResults.tsx:290-399`).
+- Hit rows: `w-14` mono gutter below sm, `w-16` from sm; `TrackBadge` inline.
+- **Selection toolbar** (`selection-toolbar`): static when nothing is selected; when N>0 and
+ < lg it becomes `sticky bottom-0` ("N selected · Ask AI · Clear", safe-area padded) so the
+ ask handoff is one tap away. Same element, same testid.
+- Duplicate strip, badges, browse-hint, Load more: unchanged behaviour, wrapped layout.
+- **Chart**: `h-[240px] sm:h-[320px]`; `chart-options` `<details>` collapsed by default below lg
+ (open at lg+, as the charts spec expects); YAxis width 40 on phone, X tick thinning; the
+ Examples menu anchors within the viewport.
+
+### 4. Transcript modal + player (the biggest change)
+- **< sm: full-screen player view.** `fixed inset-0 h-[100dvh] flex flex-col`: player slot
+ (full width, aspect-video, safe-area top) → title/meta row → **mode tab strip** = the same
+ "Show transcript" / "Show live chat" / "Show AI chapters" buttons styled as tabs, gated exactly
+ as today → `flex-1 min-h-0` cue list (virtualized as today, auto-scroll unchanged) → **bottom
+ action strip** (`sticky bottom-0`, horizontally scrollable, safe-area): every current
+ `ControlButton` kept as a real `<button>` with the identical `aria-label`/`title`, now a lucide
+ icon **plus a short visible label**, grouped: [Clip in · Clip out · readout · yt-dlp]
+ [Share · Download ▾ · Copy MD · Preservetube] [Mini · Hide · Close]. Digest panel, snap-back
+ notice, download-format menu unchanged.
+- **sm+**: current centred card, but with the labelled strip, `dvh`-based max height, and the
+ content column matching the player width.
+- Player geometry becomes CSS custom properties in `globals.css` (`--player-w: 100vw` base,
+ `min(92vw,960px)` from sm; `--player-top`), consumed by **both** `PlayerProvider.tsx`
+ (modal/mini/hidden positions) and `TranscriptModal.tsx` (reserved slot) so they can never drift.
+- Mini player: phone `w-44`, bottom-right above the safe area; hidden-controls pill full width on
+ phone. Backdrop→mini, Escape→mini, "Reopen transcript" unchanged.
+- `PostModal`: `max-h-[90dvh]`, `p-4 sm:p-8`, close button ≥ 44px.
+
+### 5. Ask pane
+- Layout: intro header (route chrome) → provider `<details>` → **grounding strip** → messages →
+ suggestions → composer. Below lg the message list uses the document scroll (no nested
+ `max-h-[60vh]`) and the composer is `sticky bottom-0` with safe-area padding; lg+ keeps a
+ nested scroller sized in `dvh` so the composer stays in view. "Jump to latest" works in both.
+- **Grounding strip**: one line ("Grounded in 15 results · whole search" / "3 selected videos")
+ with a chevron expanding the existing GroundingPalette + PinnedResultsPanel (all testids and
+ the `role="tab"` toggle inside). Expanded by default at lg+ (ask-workspace spec), collapsed
+ below. Paused-sweep banner stays top-level.
+- **ProviderSettings**: basic = provider, model (`input[list="ask-models"]`), key
+ (`placeholder^="sk-ant"`), Remember, Search mode; **Advanced** nested `<details>` = max answer
+ tokens, sweep batch, RPM + reset, debug JSON. Spec edits: open Advanced before
+ "Download debug JSON" (ask-chat.spec ~L280).
+- Saved chats row: same controls, wraps to two lines, buttons get `size="sm"` hit areas.
+ Context / Report `<details>` unchanged (their `<summary>` text is a selector). Message action
+ buttons (Copy answer, Retry/Regenerate, Edit) → `size="sm"`. Citations unchanged.
+
+### 6. Secondary pages & hub
+- `/duplicates`: filter `<details>` collapsed by default below lg with an active-filter summary;
+ cluster list windowed with `useWindowVirtualizer`; member meta wraps; native checkboxes →
+ `ui/checkbox` with padded labels (names unchanged: "YouTube", "near transcript", "filter
+ duplicates"…).
+- `/offline`: buttons `size="sm"`, rows wrap, offline search input 44px. `/downloads`: download
+ tabs `min-h-10`. `/use-with-ai`, `/changelog`: already fine; verify `pre` scroll.
+- Hub: `ArchiveShelf` remove button visible unless `(hover:hover)`; `AddArchive` input
+ `min-w-0 w-full sm:min-w-64`; `HubOfflineManager` rows wrap; `HubStats` wraps.
+- `NewSinceLastVisit`: unchanged.
+
+### Suggestions folded in (call out if you disagree)
+- "Ask AI" in primary nav (today only reachable via the workspace control or Use with AI).
+- Per-card hit cap (§3) — a desktop behaviour change too; the 500-row cards are the main
+ reason the page is unreadable on any device.
+- Filters as a sidebar only from `xl` (not `lg`) so Split + filters never compete at 1024.
+- Labels on the modal controls at every size (space exists at 960px; tooltips are not an
+ accessibility story).
+- Landscape allowed in the manifest.
+
+## Hooks that must survive (verbatim)
+data-testid: search-form, search-submit(+data-dirty), reset-layers, reset-everything,
+filters-summary-channels, inline-channel-chip, av-available, av-missing, av-<state>, date-from,
+date-to, profiles-row, profile-select, profile-dirty-dot, profile-revert, query-builder,
+compact-add-layer, group-op/negate/count/unwrap/delete/add-leaf/add-group-<id>,
+leaf-query/scope/regex/hits/negate/count/delete/wrap-<id>, leaf-alias-suggestions-<id>,
+leaf-alias-<leaf>-<alias>, leaf-alias-apply/dismiss-<id>, results-section, results-summary,
+view-toggle, selection-toolbar, browse-hint, chart-options, chart-caption,
+chart-examples-button, chart-examples-menu, state-badge-<state>, workspace-nav, view-split,
+pane-search, pane-chat, new-since-last-visit, grounding-palette, grounding-mode,
+selection-chips, saved-chats-row, saved-chat-select, chat-dirty-dot, sweep-paused,
+rate-limit-notice, shelf-spine.
+data-attrs: data-card-header, data-card-open, data-result-slug, data-leaf-section,
+data-leaf-id, data-group-id, data-cache-hit, data-duplicate-strip/slug/aligned,
+data-post-modal, data-index, data-theme-mode.
+Accessible names: "Mark clip start at current time", "Mark clip end at current time",
+/Copy yt-dlp download command/, "Show live chat", "Show transcript", "Show AI chapters",
+"Copy share link at current time", "Collapse to mini-player", "Reopen transcript",
+"Close player", "Hide player (keep audio playing)", "Share current search" → "Link copied!",
+"Choose theme", /switch to/i, "Select all in <group>", "All"/"None", checkbox names
+(Videos, Livestreams, Posts, Available, Deleted, …), 'Select "<title>" for AI', per-card "Ask",
+"Ask" (composer), "Stop", "New chat", "Save as…", "Detach", "Ground in my search",
+"Apply as starting point", /Build report from all N results/, "Add to report",
+"Start new report", "Stop the sweep", "Pause the sweep", "Resume", "Download debug JSON",
+"Copy answer", "Retry"/"Regenerate", "Remove <title> from the selection", "Dismiss",
+"Archive URL", "Add", "Remove <site>", "connection status", "offline search",
+"offline search results", "build search index for <slug>", "download <slug> for offline",
+"remove offline copy of <slug>", "<slug> indexed", "filter duplicates", /^duplicate cluster/,
+"Related sites" (nav), "Archilyzer" (footer link, same tab), "Copy link to <anchor>".
+Implicit selectors: `input[placeholder^="sk-ant"]`, `input[list="ask-models"]`,
+`summary` text "Report" / "Context" (+ `details textarea`), `<li>` citation sources,
+`<a>` citations named `[1]` / `[1 @ 0:05]`, `role="tab"` grounding toggle,
+`aria-current="page"` on the Search/Chat links, `p[role="alert"]` (AddArchive),
+`getByLabel("Answer only from these results")`, "Format answers", "Report mode",
+placeholder /Ask about the transcripts/, title "Open the transcript at this moment",
+`html[style*="--brand"]`, heading "Your archives", "Offline" h1.
+Seeds used by specs: `ytdlp-tb:ai:rpm = {"*":100000}` (all tests), `ytdlp-tb:ai:sweepchunk`.
+
+## Slices (each leaves lint + unit + affected e2e green)
+
+Verified facts used below: `ui/sheet.tsx` supports `side="bottom"` (`:70-71`), overlay+content
+are `z-50` (`:39,:63`), content unmounts while closed (no duplicate testids), Radix Dialog sets
+`body.pointer-events:none` + RemoveScroll. `ui/tabs.tsx` exists but must NOT be used for the
+modal mode strip (specs query `getByRole("button",{name:"Show live chat"…})`). SearchSession
+exposes `filtersCollapsed` + `toggleFiltersCollapsed` (`SearchSessionContext.tsx:1832-1833`,
+persisted via `persistUiCollapse` `:1196-1210`). `viewportFit`/`interactiveWidget` are typed in
+`export/node_modules/next/dist/lib/metadata/types/extra-types.d.ts:52-53`. Tailwind 4.2.4 →
+`pointer-coarse:`, `max-md:`, `h-dvh`, `w-(--var)` all available; `@source "../../common/components"`
+in `globals.css:3` means `@utility`s defined there are usable from `common/`.
+`@tanstack/virtual-core` re-measures via a per-element ResizeObserver (hit cap needs no
+`estimateSize` change). Other `vh/vw` users: `TranscriptModal.tsx:238-242`,
+`PlayerProvider.tsx:925,928,1098`, `AskChat.tsx:293`.
+
+### Slice 1 — Foundation (S, 0 spec edits)
+- New `common/lib/useMediaQuery.ts`: `useSyncExternalStore(subscribe, () => matchMedia(q).matches, () => false)`, `subscribe` memoised on the query.
+- `export/app/(workspace)/WorkspaceView.tsx:36,50-57`: delete the `wide` state+effect → `const wide = useMediaQuery("(min-width: 1024px)")`. Keep the eslint-disable block at `:39-47` (it is for `splitOn`).
+- `export/app/layout.tsx:46-49`: `generateViewport` returns `{ themeColor, width:"device-width", initialScale:1, viewportFit:"cover", interactiveWidget:"resizes-content" }` (no `maximumScale`/`userScalable`).
+- `export/app/globals.css`: `:root { --player-w: 100vw; --player-top: env(safe-area-inset-top, 0px); } @media (min-width: 640px) { :root { --player-w: min(92vw, 960px); --player-top: 4rem; } }` (4rem = today's `top-16`); `@utility pb-safe { padding-bottom: env(safe-area-inset-bottom) }` and `pt-safe`.
+- `export/app/manifest.ts:27`: delete `orientation`.
+- Risk: server snapshot `false` → one narrow-variant render on desktop before the flip (no hydration warning). Always render layout *shells* unconditionally and gate only their content on `wide` so the main column never shifts. Playwright `setViewportSize` fires `matchMedia` change events (the 600px ask-workspace test keeps passing).
+
+### Slice 2 — Transcript modal + player (M, 0 spec edits)
+- `common/components/PlayerProvider.tsx:922-934`: modal → `fixed left-1/2 -translate-x-1/2 top-(--player-top) w-(--player-w) aspect-video z-[60] max-sm:rounded-none`; mini `:927-928` → `fixed right-4 bottom-[calc(1rem+env(safe-area-inset-bottom))] w-44 sm:w-96`; hidden `:932-933` unchanged. `HiddenControls` `:1098` → `max-sm:inset-x-3 max-sm:right-auto max-sm:max-w-none` + safe area. `ControlButton` `:1110-1141` gains optional `icon?: ReactNode` + `label?: string` (visible text); keeps `title` and `aria-label={title}` (`:1126-1127`) so every accessible name is unchanged; `char` stays as the fallback used by `HiddenControls :1103-1105`.
+- `common/components/TranscriptModal.tsx:236-244`: replace both inline calcs with `marginTop: calc(var(--player-top) + var(--player-w) * 9 / 16 + 0.75rem)` and `maxHeight: calc(100dvh - var(--player-top) - var(--player-w) * 9 / 16 - 1.5rem)`, width `w-(--player-w)`, drop `pt-4`; add `max-sm:h-full max-sm:rounded-none` so the phone gets the full-screen column. Reorder: title block `:377-391` → mode toggles `:279-292` as their own tab-styled row (same `ControlButton`s, same names) → cue container `:401-404` with `min-h-0 overscroll-contain touch-pan-y` + new `data-testid="cue-list"` → control bar `:245-375` as the bottom strip (`shrink-0 overflow-x-auto flex-nowrap sticky bottom-0 pb-safe`, icon + label, grouped). Keep active-row class `bg-blue-950/50` (`:610`, live-chat.spec selects `li.bg-blue-950\/50`) and `role="status"` on the notice (`:394`). Both "Collapse to mini-player" buttons (backdrop `:232`, control `:361`) stay.
+- Download-format menu `:317-333` is `absolute top-9` and would clip inside a scrolling strip → replace with `ui/dropdown-menu` (portal, content `z-[70]`), delete the outside-click effect `:77-96`.
+- `common/components/ui/sheet.tsx:39,:63`: bump overlay + content to `z-[70]` (modal `z-50` < player `z-[60]` < sheets; otherwise the mini player floats over a sheet and is unclickable under `pointer-events:none`). Sheet is unused today, so this is safe.
+- Body-scroll: `TranscriptModal :157-165` sets/restores `body.style.overflow`; RemoveScroll also touches body — they never overlap today (no sheet opens over the modal); leave a comment. Escape is handled by both PlayerProvider `:837-844` and Radix — acceptable.
+
+### Slice 3 — Header + Footer (M, 0 spec edits)
+- `export/app/components/Header.tsx:43`: `h-14 … flex-wrap` → `min-h-14 flex-nowrap`. Nav `:51-77` → `hidden md:flex` + add "Ask AI" → `/ask/`. Right cluster `:79-99`: SiblingSwitcher, Hub `<a>`, Changelog, ThemeMenu → `hidden md:…`; `ThemeToggle` stays visible at every width (theme.spec `/switch to/i`; theme-family.spec "Choose theme" is served by the inline ThemeMenu at 1440).
+- New client `export/app/components/MobileMenu.tsx`: props `{ links:{href,label}[]; sites: SwitcherGroup[] (type from SiblingSwitcher.tsx:18-19); hubUrl?: string }`. `<Sheet>` + `<SheetTrigger asChild><Button variant="ghost" size="icon-sm" aria-label="Open menu" className="md:hidden">`, `<SheetContent side="right">` with a `<SheetTitle>` (Radix warns without one); every `<Link>` wrapped in `<SheetClose asChild>` (Header lives in the root layout and never remounts on client nav). Theme lists via `useTheme()` (`ThemeProvider.tsx:35-38`) + `THEME_FAMILIES`/`THEME_MODES` from `themeConfig` as native radio groups — do not nest `ThemeMenu`'s DropdownMenu in the dialog. Offline link gated like `Footer.tsx:56` (`site.pwa`).
+- `export/app/components/Footer.tsx:29` → `flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between pb-safe`; `:82` drop `whitespace-nowrap`. Leave the anchor naming (`"Built with"` outside the `<a>`) and `nav aria-label="Related sites"` untouched.
+
+### Slice 4 — Results (M, 1 spec edit)
+- `common/components/SearchResults.tsx` card header `:470-538`: `flex items-stretch` → `flex flex-col sm:flex-row`; title `:504` `truncate` → `line-clamp-2 sm:line-clamp-none sm:truncate`; meta `:521-526` → `basis-full sm:basis-auto`; Ask `:528-537` `border-l` → `max-sm:border-t`. Preserve `data-result-slug`/`data-card-header` (`:460-461`), `data-card-open` (`:487`), checkbox aria-label (`:480`).
+- Toolbar `:99-164`: split the `<h2>` into a summary row (`results-summary` + loading spans) and a controls row (`view-toggle`, Copy for AI). `selection-toolbar` `:166-193` gets `max-sm:sticky max-sm:bottom-0 max-sm:z-10 max-sm:bg-background/95 max-sm:pb-safe` only when `selectedCount > 0`; on phone add right padding (`pr-48`) so it does not sit under the `w-44` mini player.
+- **Hit cap**: at most 8 hits across the per-leaf buckets (`:448-456`, `:575-589`) + a "Show all N hits" button rendered **outside** the hit `<ul>` (after `:594`) — the virtualization spec counts `li button`. Expansion state `expandedSlugs: Set<string>` lives in `VirtualResultList` (cards unmount when scrolled out, `:368-396`), passed as an `expanded` prop to the memoised `ResultCard`. `measureElement` (`:373`) already re-measures via ResizeObserver; after collapsing a tall card call `scrollIntoView({block:"nearest"})` on its header.
+- Chart: `chart-options` `:197-201` `open` → `open={userOpen ?? wide}` with `onToggle` (pattern: `ProviderSettings.tsx:100-103`), `wide = useMediaQuery("(min-width:1024px)")`. `ChartView.tsx:71,90,143` `h-[280px]` → `h-[240px] sm:h-[320px]`; `YAxis width` `:126` 52 → 40 narrow; `minTickGap` `:114` 16 → 28 narrow.
+- Spec: `search-results-virtualization.spec.ts:125-149` asserts `[data-result-slug=DENSE] li button` count = 350 (`fixtures/many-results.ts:15`) → click `getByRole("button",{name:/Show all 350 hits/})` first (or assert 8 → click → 350). charts.spec `openChart :30-38` and `:74` need `chart-options` open at 1440 — satisfied by `open = wide`.
+
+### Slice 5 — Ask pane (M, 1 spec edit)
+- `export/app/ask/AskChat.tsx:293`: scroller → `lg:max-h-[calc(100dvh-16rem)] lg:overflow-y-auto` (no max-h below lg). `Composer.tsx:33-39` form → `max-lg:sticky max-lg:bottom-0 max-lg:z-10 max-lg:bg-background max-lg:pb-safe max-lg:-mx-4 max-lg:px-4`; textarea `:58` `text-sm` → `text-base md:text-sm`; on focus `scrollIntoView({block:"end"})` (iOS ignores `interactiveWidget`).
+- Auto-scroll `:53-72`: `nested = useMediaQuery("(min-width:1024px)")`; a `metrics()` helper reads `{scrollTop, scrollHeight, clientHeight}` from `scrollRef.current` when nested else from `document.scrollingElement`; `onScroll` binds to the element when nested, to `window` (passive) otherwise; `jumpToLatest` targets the same. Gotchas: (1) the chat pane is CSS-hidden on `/` (`WorkspaceView.tsx:115`) while sweeps append messages (`useAskChat.ts:1025`) — with document scrolling the effect at `:62-65` would yank the search page: guard with `scrollRef.current?.getClientRects().length > 0`; (2) it fires per streamed token — throttle `window.scrollTo` through rAF. "Jump to latest" pill `:347-355`: second placement inside the sticky composer wrapper (`absolute -top-10 left-1/2`) below lg, CSS-hidden per breakpoint.
+- Grounding: wrap `GroundingPalette` `:166-186` + `PinnedResultsPanel` `:188-213` in `<details open={userOpen ?? wide}>` with a one-line summary (mode + count). `ProviderSettings.tsx:201-301` (max tokens, sweep batch, RPM, Debug) → nested `<details><summary>Advanced</summary>`; `:109-199` stay top-level (`placeholder^="sk-ant"` `:147`, `list="ask-models"` `:127`, mode buttons `:177-192`).
+- Spec: `ask-chat.spec.ts:~280` add `await page.getByText("Advanced",{exact:true}).click()` before "Download debug JSON". `grounding-palette`/`grounding-mode`/`selection-chips` visible at 1440 because `open = wide`.
+
+### Slice 6 — Search bar split (L, 1 spec edit) — do this after 4 and 5
+Anatomy of `common/components/WorkspaceSearchBar.tsx`: ProfilesRow call `:135-147` (def `:657-783`); `<form data-testid="search-form">` `:148-202` with the `mounted` gate/placeholder `:156-165`; submit + `reset-layers` row `:166-201`; Filters `<details>` `:204-588` (summary `:206-233` incl. `filters-summary-channels` `:228`, "N of M channels" `:220-223` used by inline-channel-chips:176-188); channel row incl. `reset-everything` `:246-273` (gated on >1 channel `:235`); group cards `:314-447`; type/audience/availability/date `:453-585`; Advanced `<details>` `:590-652`; helpers `ChannelAllToggle :785`, `NumberField :810`, `LimitField :857`.
+- `common/components/SearchBar.tsx`: the `<form data-testid="search-form">` (Enter-to-submit is relied on by share-current-search:37-38 — the compact leaf input must stay inside the form; all chip-row buttons `type="button"`). Sticky wrapper `max-lg:sticky max-lg:top-14 max-lg:z-10 bg-background` around the compact rows only; the non-compact tree renders outside it. `mounted` placeholder `:164` → `h-[5.5rem]`. Render the `nav` slot **outside** the mounted gate so `workspace-nav` exists pre-hydration as plain anchors.
+- Chip row via slots on the compact leaf: `QueryLeafView.tsx:238-253` already renders row 1 (swatch + input) and row 2 (scope/regex/count/cached). Add `inputTrailing?: ReactNode` (Search submit into row 1) and `trailingControls?: ReactNode` (row 2), threaded through `QueryBuilder.tsx:68-118`; move QueryBuilder's `compact-add-layer`/add-group (`:83-116`) into the "Layers" chip (keep `data-testid="compact-add-layer"`). `SearchBar` uses `isCompactRoot(draftRoot)` (from `lib/searchQuery`): compact → submit in row 1; non-compact → the existing submit row below the tree. Exactly one `search-submit` in the DOM either way. Icon-only submit: `<SearchIcon/><span className="sr-only sm:not-sr-only">Search</span>` (name stays "Search"; keep `data-dirty`). Selects `text-xs` (`QueryLeafView.tsx:186`) → `text-base sm:text-xs`.
+- `common/components/FiltersPanel.tsx`: ProfilesRow + `:234-585` + Advanced `:590-652` nested `<details><summary>Advanced</summary>` + helpers. **Keep an outer `<details data-testid="filters-panel" open={…}>` wrapper** in both containers — channel-group-chips:120-123 and inline-channel-chips:124-127,185,190 locate group cards as `details details` / `details details[open]`. In the sidebar it doubles as the collapse element driven by `filtersCollapsed`/`toggleFiltersCollapsed` (today's preventDefault+toggle pattern `:205-214`).
+- `common/components/FiltersContainer.tsx` exporting `FiltersTrigger` (chip row: when `!wide` the "Filters" chip with active-count badge + `<Sheet>` containing `<FiltersPanel/>`; when `wide` a rail toggle) and `FiltersSidebar` (grid: `<aside className="hidden xl:block">` shell always rendered, `<FiltersPanel/>` only when `wide`). Both use `useMediaQuery("(min-width: 1280px)")` (synchronous `matches` → both agree within a render). Sheet side `useMediaQuery("(min-width: 640px)") ? "right" : "bottom"`, `className="max-h-[90dvh] overflow-y-auto pb-safe"`, plus an "Apply" button calling `commitSearch()` then closing (no `search-submit` testid). Active count from draft state (excluded channels, `draftNov/Nol/Nop/Naa/Nar`, `draftStates.size < 6`, dates).
+- **Share button**: five specs resolve it as `getByTestId("profiles-row").getByRole("button",{name:"Share current search"})` then `getByRole("button",{name:"Link copied!"})` unscoped; both buttons flip on the shared `shareCopied` (`:1487`). Keep the ProfilesRow button inside FiltersPanel and give the bar's icon button `xl:hidden` → exactly one visible at every width (`getByRole` ignores `display:none`; the sheet is unmounted when closed).
+- WorkspaceNav into the bar: `WorkspaceSearchBar` (common) cannot import the export app's `WorkspaceNav` → `nav?: ReactNode` slot. Lift `splitOn/wide/view/setSplit` from `WorkspaceView` into a `WorkspaceViewProvider` + `useWorkspaceView()` (same file), mounted in `SiteWorkspace.tsx:30-36` around bar + view; `WorkspaceNav` reads the context; `WorkspaceView` drops its `<WorkspaceNav>` at `:90-96`. Pane latching `:83-86` untouched.
+- Grid: `SiteWorkspace.tsx:30-36` → `<SearchBar nav={<WorkspaceNav/>}/>` then `<div className="xl:grid xl:grid-cols-[280px_1fr] xl:gap-6"><FiltersSidebar/><WorkspaceView/></div>`. `common/components/TranscriptSearch.tsx:19-28` (hub) adopts the same `[FiltersSidebar | SearchResults]` grid, no nav slot (e2e-hub waits only on `query-builder`).
+- Spec: profile-row.spec "Profile row renders above the QueryBuilder" (boundingBox compare) → assert `profiles-row` is inside `filters-panel` and visible. Add `openFilters(page)` to `export/e2e/helpers.ts` (no-op when `filters-panel` is visible, else click the "Filters" chip); not needed by current specs at 1440 (`filtersCollapsed` defaults false, `SearchSessionContext.tsx:407`).
+
+### Slice 7 — Secondary pages + PostModal (S, 0 spec edits)
+- `export/app/duplicates/DuplicatesClient.tsx`: `FilterPanel :333` `<details open>` → `open={userOpen ?? wide}` + active count in the summary `:334-342`; `Check :425-445` → `ui/checkbox` inside the `<label>` (pattern `WorkspaceSearchBar:457-463`); `MemberRow :497-509` → `flex-wrap`, meta `basis-full sm:basis-auto`; window the list `:283-291` with `useWindowVirtualizer` (copy `scrollMargin` pattern from `SearchResults.tsx:327-361`); card root `:459` `<li>` → `<article aria-label=…>` (VirtualRow `as="li"` would nest li-in-li; the spec uses `getByLabel(/^duplicate cluster/)`).
+- `export/app/components/OfflineManager.tsx:219-252` → `Button size="sm" variant="outline"`, aria-labels unchanged. `export/app/downloads/page.tsx:120-127` anchor → `min-h-10`.
+- `hub/ArchiveShelf.tsx:53` add `pointer-coarse:opacity-100`; `:35` `min-w-52` → `min-w-0 w-full sm:w-auto sm:min-w-52`. `hub/AddArchive.tsx:62` → `min-w-0 w-full sm:w-auto sm:min-w-64` (keep `aria-label="Archive URL"`). `hub/HubOfflineManager.tsx:114` → `flex-wrap`.
+- `common/components/PostModal.tsx:74` → `max-h-[90dvh] overflow-y-auto` (keep `data-post-modal :68`).
+
+### Slice 8 — `export/e2e/responsive.spec.ts` (S)
+`test.use({ viewport:{width:390,height:844}, isMobile:true, hasTouch:true })` (file-level override of the Desktop Chrome project). Use `installRoutes` from helpers (`/duplicates.json` is 404-mocked → empty state, fine for the overflow check). Overflow: `document.documentElement.scrollWidth - clientWidth <= 0` on `/`, `/ask/`, `/downloads/`, `/duplicates/`, `/use-with-ai/`. Menu: "Open menu" → `getByRole("dialog")` contains link "Ask AI". Filters: "Filters" chip → inside the dialog toggle "Livestreams" → Apply → `results-summary` changes. Modal: `/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}` → `expectModalOpen` → `getByTestId("cue-list").boundingBox().height >= 300` and "Close player" `toBeInViewport()`. Composer: `getByPlaceholder(/Ask about the transcripts/)` `toBeInViewport()` after a mocked answer.
+
+Order rationale: 2 before 3 (sheet z-index depends on the player stack); 6 after 4/5 so the panes are phone-ready when the grid lands.
+
+## Verification
+- Static: `pnpm --filter export run lint`; `pnpm --filter export exec tsc --noEmit`;
+ `pnpm --filter common exec tsc --noEmit` (common components changed).
+- Unit: `pnpm --filter export exec tsx --test "app/**/*.test.ts"` (94 pass today);
+ `pnpm --filter common test`.
+- Visual: run `next dev` with the e2e fixture env
+ (`SETTINGS_FILE=export/test-settings.json SITES_DIR=export/e2e/fixtures/sites SITE_ID=testsite
+ pnpm --filter export dev --port 3777`) and screenshot `/`, `/?q=…`, `/ask/`, `/downloads/`,
+ `/duplicates/`, `/use-with-ai/` at 360, 390, 640, 768, 1024, 1280, 1440 with a Playwright
+ script (pattern: `/home/user/.claude/jobs/a61f2393/tmp/shot.mjs`, run from `export/`).
+ Assert `document.documentElement.scrollWidth === innerWidth` on every page.
+- New `export/e2e/responsive.spec.ts` with
+ `test.use({ viewport:{width:390,height:844}, isMobile:true, hasTouch:true })`: no horizontal
+ overflow on each route; "Open menu" sheet lists nav + theme; Filters sheet opens, unchecking a
+ channel + Apply changes `results-summary`; opening a result shows the player view with a cue
+ list ≥ 300px tall and the "Close player" button visible without scrolling; `/ask/` shows the
+ composer within the first viewport after a message.
+- Full e2e: `pnpm --filter export run e2e` — serial queue lock, ~25 min: run detached
+ (`setsid nohup … > log &`) and poll the log; never from a foreground Bash call.
+ Also `e2e:hub` and `e2e:2origin` (hub shelf/AddArchive touched).
+- Do not run `pnpm dev:editor` against the real corpus (AGENTS.md).
+
+## Handoff notes for the implementer
+- Read `export/node_modules/next/dist/docs/` before writing Next code (Next 16 differs from
+ training data; `AGENTS.md`). Tailwind v4 (`@import "tailwindcss"`, `@theme inline`), shadcn
+ via the `radix-ui` umbrella package, lucide icons.
+- The feature inventories that produced this plan are summarised in this file; re-derive
+ details from the code, not from memory. Line numbers were measured on `ecf80a5` and will
+ drift as slices land — re-locate by content.
+- Ship in the slice order above; commit per slice with the repo's commit style (see `git log`).