Archilyzer · Source

archilyzer

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

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:
Acommon/lib/useMediaQuery.ts | 39+++++++++++++++++++++++++++++++++++++++
Mexport/app/(workspace)/WorkspaceView.tsx | 12++----------
Mexport/app/globals.css | 27+++++++++++++++++++++++++++
Mexport/app/layout.tsx | 16++++++++++++++--
Mexport/app/manifest.ts | 3++-
Aplans/export-responsive-redesign.md | 326+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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`).