Archilyzer · Source

archilyzer

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

commit 4ea812b3c23899c3148e070363352a9453077734
parent 163e4481a7d621df00ae6c2647169dbab3c3a5fa
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 29 Aug 2026 20:55:37 -0400

search: the bar is a query box and one row of chips

The profile row and five filter sections used to sit between a visitor
and the search box — two phone screens of chrome before the first
result. The bar is now the query, the submit and a wrapping chip row;
the filters (profiles included, since a profile is a saved filter set)
live behind a Filters chip in a sheet below xl, and inline as before
from xl. On a phone the first result card starts 400px down instead of
1000.

WorkspaceSearchBar splits into SearchBar, FiltersPanel and
FiltersContainer, and the [Search|Chat|Split] control moves next to the
search box — so its state lifts into a WorkspaceViewProvider that wraps
both the bar and the panes it governs.

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

Diffstat:
Acommon/components/FiltersContainer.tsx | 94+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/components/FiltersPanel.tsx | 863+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/QueryBuilder.tsx | 95+++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------
Mcommon/components/QueryLeafView.tsx | 13++++++++++---
Acommon/components/SearchBar.tsx | 223+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/components/WorkspaceSearchBar.tsx | 925++-----------------------------------------------------------------------------
Mexport/app/(workspace)/SiteWorkspace.tsx | 20++++++++++++--------
Mexport/app/(workspace)/WorkspaceNav.tsx | 33+++++++++++----------------------
Mexport/app/(workspace)/WorkspaceView.tsx | 70++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
Mexport/e2e/helpers.ts | 22++++++++++++++++++++++
Mexport/e2e/inline-channel-chips.spec.ts | 6+++++-
Mexport/e2e/profile-row.spec.ts | 19++++++++++---------
12 files changed, 1384 insertions(+), 999 deletions(-)

diff --git a/common/components/FiltersContainer.tsx b/common/components/FiltersContainer.tsx @@ -0,0 +1,94 @@ +"use client"; + +// Where FiltersPanel lives, which depends on how much room there is. +// +// • from xl — inline under the search bar, collapsible, exactly as it has +// always been on a desktop. The group-chip row is designed to pack uniform +// chips across the full width, so a narrow rail would be a downgrade there. +// • below xl — a sheet behind the "Filters" chip, with an Apply button that +// commits and closes. That is what gets the two screens of filter stack out +// from between a phone visitor and their results. +// +// Both readers ask the same media query, and `useMediaQuery` re-reads `matches` +// synchronously, so the trigger and the inline block can never both be showing +// the same single-mount panel. + +import { useState } from "react"; +import { ListFilterIcon } from "lucide-react"; +import { Button } from "./ui/button"; +import { + Sheet, + SheetContent, + SheetTitle, + SheetTrigger, +} from "./ui/sheet"; +import FiltersPanel from "./FiltersPanel"; +import { useMediaQuery } from "../lib/useMediaQuery"; + +export function FiltersTrigger({ + activeCount, + onApply, +}: { + activeCount: number; + onApply: () => void; +}) { + const [open, setOpen] = useState(false); + // From sm the sheet comes in from the right like the menu; on a phone it + // rises from the bottom, where a thumb is. + const fromSide = useMediaQuery("(min-width: 640px)"); + + return ( + <Sheet open={open} onOpenChange={setOpen}> + <SheetTrigger asChild> + <Button + type="button" + variant="outline" + size="sm" + data-testid="filters-trigger" + className="shrink-0 text-xs text-muted-foreground" + > + <ListFilterIcon aria-hidden="true" /> + Filters + {activeCount > 0 && ( + <span className="rounded-full bg-primary px-1.5 text-[10px] font-medium text-primary-foreground"> + {activeCount} + </span> + )} + </Button> + </SheetTrigger> + <SheetContent + side={fromSide ? "right" : "bottom"} + aria-describedby={undefined} + className="max-h-[90dvh] gap-0 overflow-y-auto sm:max-w-md" + > + <SheetTitle className="px-4 pt-4 text-base">Filters</SheetTitle> + <div className="px-4 pb-4 pt-2"> + <FiltersPanel forceOpen /> + </div> + {/* Sticky so a long filter list never buries the one button that + applies it. Deliberately NOT `search-submit`: there is exactly one + of those, and it is in the bar. */} + <div className="sticky bottom-0 border-t border-border bg-background px-4 py-3 pb-[calc(0.75rem+env(safe-area-inset-bottom,0px))]"> + <Button + type="button" + className="w-full" + onClick={() => { + onApply(); + setOpen(false); + }} + > + Apply filters + </Button> + </div> + </SheetContent> + </Sheet> + ); +} + +// The inline placement. Renders nothing below xl, where FiltersTrigger owns the +// panel — one mount, never two. +export function FiltersSection() { + const inline = useMediaQuery("(min-width: 1280px)"); + if (!inline) return null; + return <FiltersPanel />; +} diff --git a/common/components/FiltersPanel.tsx b/common/components/FiltersPanel.tsx @@ -0,0 +1,863 @@ +"use client"; + +// The filters half of the search bar: the profile row, every filter (channels, +// type, audience, availability, dates) and the advanced throughput knobs. +// +// It is ONE component with ONE mount, placed by FiltersContainer — inline under +// the bar from xl, inside a sheet below it. The profile row lives here rather +// than above the query builder because a profile IS a saved filter+query set, +// and on a phone it was three rows of chrome above the search box. +// +// The outer `<details data-testid="filters-panel">` is load-bearing: several +// specs locate a channel group card as `details details`, so this wrapper has +// to be the outer one. It also doubles as the inline collapse, driven by the +// session's persisted `filtersCollapsed`. + +import { Fragment, useCallback } from "react"; +import { ChevronRightIcon } from "lucide-react"; +import { + MISSING_STATES, + VIDEO_STATE_LABELS, + type VideoState, +} from "../lib/availability"; +import { Button } from "./ui/button"; +import { Checkbox } from "./ui/checkbox"; +import { cn } from "../lib/utils"; +import { clearAdvanced } from "./exportAdvancedStorage"; +import { ymdToInput, inputToYmd } from "../lib/ymd"; +import { + useSearchSession, + DEFAULT_MAX_HITS, + DEFAULT_FETCH_CONCURRENCY, + DEFAULT_FLUSH_INTERVAL_MS, +} from "./SearchSessionContext"; +import { useSearchData } from "./SearchDataContext"; + +export default function FiltersPanel({ + // Inside the sheet the sheet itself IS the disclosure, so the wrapper stays + // open and its summary is not offered as a second control. + forceOpen = false, +}: { + forceOpen?: boolean; +}) { + const { + profiles, + activeProfileName, + profileDirty, + shareCopied, + handleLoadProfile, + handleSaveProfile, + handleSaveAsProfile, + handleRenameProfile, + handleDeleteProfile, + handleRevertProfile, + handleShareCurrentSearch, + channelOptions, + filtersCollapsed, + toggleFiltersCollapsed, + selectedChannelNames, + selectedChannelsLabel, + draftExcludedChannels, + setDraftExcludedChannels, + handleResetChannels, + handleResetAllFilters, + handleResetEverything, + channelGroupings, + collapsedGroups, + toggleGroupCollapsed, + setSelected, + channelLabelByKey, + draftNov, + draftNop, + setDraftNop, + setDraftNov, + draftNol, + setDraftNol, + draftNaa, + setDraftNaa, + draftNar, + setDraftNar, + draftStates, + setDraftStates, + draftDateFrom, + setDraftDateFrom, + draftDateTo, + setDraftDateTo, + hitBatchValue, + setHitBatchValue, + hitBatchUnlimited, + setHitBatchUnlimited, + fetchConcurrency, + setFetchConcurrency, + flushIntervalMs, + setFlushIntervalMs, + setHitLimit, + } = useSearchSession(); + + // Only offer the Posts type toggle on a site that actually ships a posts + // corpus, so a pure-video deployment's filter row is unchanged. + const { postsManifest } = useSearchData(); + const hasPostsCorpus = (postsManifest?.channels.length ?? 0) > 0; + + const toggleState = useCallback( + (state: VideoState, keep: boolean) => { + setDraftStates((prev) => { + const next = new Set(prev); + if (keep) next.add(state); + else next.delete(state); + return next; + }); + }, + [setDraftStates], + ); + const setAllMissing = useCallback( + (keep: boolean) => { + setDraftStates((prev) => { + const next = new Set(prev); + for (const s of MISSING_STATES) { + if (keep) next.add(s); + else next.delete(s); + } + return next; + }); + }, + [setDraftStates], + ); + const allMissingKept = MISSING_STATES.every((s) => draftStates.has(s)); + const someMissingKept = MISSING_STATES.some((s) => draftStates.has(s)); + + return ( + <details + data-testid="filters-panel" + open={forceOpen || !filtersCollapsed} + className="min-w-0" + > + <summary + onClick={(e) => { + // Drive the open state from React rather than the browser's default + // toggle action. Avoids a spurious `toggle` event that fires when + // React first mounts <details open> after the manifest loads, which + // would clobber the persisted value. + e.preventDefault(); + if (!forceOpen) toggleFiltersCollapsed(); + }} + className={cn( + "cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted-foreground", + forceOpen && "hidden", + )} + > + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + Filters + </span> + {channelOptions.length > 0 && ( + <span className="text-xs text-muted-foreground"> + {selectedChannelNames.length} of {channelOptions.length} channel + {channelOptions.length === 1 ? "" : "s"} + </span> + )} + {filtersCollapsed && selectedChannelNames.length > 0 && ( + <span + className="text-xs text-muted-foreground truncate min-w-0 flex-1" + title={selectedChannelsLabel} + data-testid="filters-summary-channels" + > + {selectedChannelsLabel} + </span> + )} + </summary> + + <div className="mt-2 flex flex-col gap-4"> + <ProfilesRow + profileNames={Object.keys(profiles).sort()} + activeProfileName={activeProfileName} + profileDirty={profileDirty} + shareCopied={shareCopied} + onSelect={handleLoadProfile} + onSave={handleSaveProfile} + onSaveAs={handleSaveAsProfile} + onRename={handleRenameProfile} + onDelete={handleDeleteProfile} + onRevert={handleRevertProfile} + onShareCurrentSearch={handleShareCurrentSearch} + /> + + {channelOptions.length > 0 && ( + <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground"> + {channelOptions.length > 1 && ( + <div className="flex flex-col gap-2 w-full"> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + Channels + </span> + <ChannelAllToggle + channelOptions={channelOptions} + draftExcludedChannels={draftExcludedChannels} + setDraftExcludedChannels={setDraftExcludedChannels} + /> + <Button + type="button" + variant="link" + size="sm" + onClick={handleResetChannels} + className="text-xs text-muted-foreground" + > + Reset channels + </Button> + <Button + type="button" + variant="link" + size="sm" + onClick={handleResetAllFilters} + className="text-xs text-muted-foreground" + > + Reset all filters + </Button> + <Button + type="button" + variant="link" + size="sm" + onClick={handleResetEverything} + data-testid="reset-everything" + className="text-xs text-muted-foreground" + > + Reset everything + </Button> + </div> + <div className="flex flex-wrap items-start gap-1.5"> + {channelGroupings.map(({ group, channels }) => { + if (group.inline) { + // Inline group: no collapsible box — each member channel + // renders as its own loose chip flowing among the group + // chips, styled like a collapsed group chip. + return ( + <Fragment key={group.id}> + {channels.map((key) => { + const label = channelLabelByKey.get(key) ?? key; + return ( + <label + key={key} + title={label} + data-testid="inline-channel-chip" + className="flex items-center gap-2 rounded border border-border bg-card/60 px-2.5 py-1.5 text-sm cursor-pointer select-none min-w-0 max-w-full w-full sm:w-auto sm:min-w-48" + > + <span className="flex items-center shrink-0"> + <Checkbox + checked={!draftExcludedChannels.has(key)} + onCheckedChange={(value) => + setSelected([key], value === true) + } + /> + </span> + <span className="truncate">{label}</span> + </label> + ); + })} + </Fragment> + ); + } + const isOpen = !collapsedGroups.has(group.id); + const selectedCount = channels.reduce( + (n, name) => + draftExcludedChannels.has(name) ? n : n + 1, + 0, + ); + return ( + <details + key={group.id} + open={isOpen} + className={cn( + "rounded border border-border bg-card/60 min-w-0 max-w-full w-full", + !isOpen && "sm:w-auto sm:min-w-48", + )} + > + <summary + onClick={(e) => { + // Drive the open state from React rather than the + // browser's default toggle action — same rationale + // as the outer Filters summary above. + e.preventDefault(); + toggleGroupCollapsed(group.id, !isOpen); + }} + className="cursor-pointer select-none flex items-center gap-2 px-2.5 py-1.5 text-sm min-w-0" + title={!isOpen ? group.description : undefined} + > + <ChevronRightIcon + aria-hidden="true" + className={cn( + "size-3.5 shrink-0 text-muted-foreground transition-transform", + isOpen && "rotate-90", + )} + /> + <span + onClick={(e) => { + // Isolate checkbox clicks from the summary: no + // expand/collapse, just the group bulk-toggle. + e.preventDefault(); + e.stopPropagation(); + }} + className="flex items-center shrink-0" + > + <Checkbox + checked={ + selectedCount === 0 + ? false + : selectedCount === channels.length + ? true + : "indeterminate" + } + onCheckedChange={(value) => + setSelected(channels, value === true) + } + aria-label={`Select all in ${group.name || "group"}`} + /> + </span> + {group.name && ( + <span + className="font-medium text-foreground flex items-center gap-1.5 min-w-0" + title={group.name} + > + {group.accent && ( + <span + aria-hidden="true" + className="inline-block size-2 shrink-0 rounded-full" + style={{ background: group.accent }} + /> + )} + <span className="truncate">{group.name}</span> + </span> + )} + <span + className={cn( + "text-xs text-muted-foreground shrink-0", + !isOpen && "ml-auto", + )} + > + {selectedCount}/{channels.length} + <span className="sr-only"> selected</span> + </span> + {isOpen && ( + <span className="ml-auto flex items-center gap-2"> + <Button + type="button" + variant="link" + size="sm" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setSelected(channels, true); + }} + className="text-xs text-muted-foreground" + > + All + </Button> + <Button + type="button" + variant="link" + size="sm" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setSelected(channels, false); + }} + className="text-xs text-muted-foreground" + > + None + </Button> + </span> + )} + </summary> + {group.description && ( + <div className="px-3 pt-0 pb-1 text-xs text-muted-foreground"> + {group.description} + </div> + )} + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-1 pb-2"> + {channels.map((key) => { + const checked = !draftExcludedChannels.has(key); + return ( + <label + key={key} + className="flex items-center gap-1.5 select-none" + > + <Checkbox + checked={checked} + onCheckedChange={(value) => { + setDraftExcludedChannels((prev) => { + const next = new Set(prev); + if (value === true) next.delete(key); + else next.add(key); + return next; + }); + }} + /> + {channelLabelByKey.get(key) ?? key} + </label> + ); + })} + </div> + </details> + ); + })} + </div> + </div> + )} + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + Type + </span> + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + checked={!draftNov} + onCheckedChange={(value) => setDraftNov(value !== true)} + /> + Videos + </label> + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + checked={!draftNol} + onCheckedChange={(value) => setDraftNol(value !== true)} + /> + Livestreams + </label> + {/* The social-post corpus is a third media kind, not a video + sub-type — without its own toggle the video/livestream pair + would silently drop every post. Only offered when the site + actually ships posts. */} + {hasPostsCorpus && ( + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + checked={!draftNop} + onCheckedChange={(value) => setDraftNop(value !== true)} + /> + Posts + </label> + )} + </div> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + Audience + </span> + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + checked={!draftNaa} + onCheckedChange={(value) => setDraftNaa(value !== true)} + /> + All ages + </label> + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + checked={!draftNar} + onCheckedChange={(value) => setDraftNar(value !== true)} + /> + Age-restricted + </label> + </div> + {/* Availability is one enum, not three independent booleans: + "Missing" is an umbrella over the five ways a video can leave + its channel listing, so it renders as a parent with the leaves + nested under it. Toggling the parent sets or clears all five; + it goes indeterminate when only some are kept. */} + <div className="flex flex-wrap items-start gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-muted-foreground pt-0.5"> + Availability + </span> + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + data-testid="av-available" + checked={draftStates.has("available")} + onCheckedChange={(value) => + toggleState("available", value === true) + } + /> + Available + </label> + <div className="flex flex-col gap-1"> + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + data-testid="av-missing" + checked={ + allMissingKept + ? true + : someMissingKept + ? "indeterminate" + : false + } + onCheckedChange={(value) => setAllMissing(value === true)} + /> + Missing + </label> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1 pl-5"> + {MISSING_STATES.map((state) => ( + <label + key={state} + className="flex items-center gap-1.5 select-none" + > + <Checkbox + data-testid={`av-${state}`} + checked={draftStates.has(state)} + onCheckedChange={(value) => + toggleState(state, value === true) + } + /> + {VIDEO_STATE_LABELS[state]} + </label> + ))} + </div> + </div> + </div> + <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + Date + </span> + <label className="flex items-center gap-1.5 select-none"> + From + <input + type="date" + data-testid="date-from" + value={ymdToInput(draftDateFrom)} + onChange={(e) => + setDraftDateFrom(inputToYmd(e.target.value) ?? "") + } + className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm" + /> + </label> + <label className="flex items-center gap-1.5 select-none"> + To + <input + type="date" + data-testid="date-to" + value={ymdToInput(draftDateTo)} + onChange={(e) => + setDraftDateTo(inputToYmd(e.target.value) ?? "") + } + className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm" + /> + </label> + </div> + </div> + )} + + {/* Throughput knobs: nobody sets these on a first visit, and on a phone + they were four more rows between the query and the results. */} + <details className="text-sm text-muted-foreground"> + <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground"> + Advanced options + </summary> + <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-border bg-card/60"> + <LimitField + label="Max hits per batch" + hint="Pause after this many matches; “Show more” adds another batch." + value={hitBatchValue} + unlimited={hitBatchUnlimited} + defaultValue={DEFAULT_MAX_HITS} + onValueChange={setHitBatchValue} + onUnlimitedChange={setHitBatchUnlimited} + /> + <NumberField + label="Fetch concurrency" + hint="Parallel transcript downloads while searching. Applies on next search." + value={fetchConcurrency} + defaultValue={DEFAULT_FETCH_CONCURRENCY} + min={1} + max={32} + onChange={setFetchConcurrency} + /> + <NumberField + label="Flush interval (ms)" + hint="UI update cadence during search. Applies on next search." + value={flushIntervalMs} + defaultValue={DEFAULT_FLUSH_INTERVAL_MS} + min={16} + max={2000} + onChange={setFlushIntervalMs} + /> + <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1"> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + setHitBatchUnlimited(true); + setHitLimit(Infinity); + }} + className="text-xs" + > + Search through all transcripts + </Button> + <Button + type="button" + variant="outline" + size="sm" + onClick={() => { + setHitBatchValue(DEFAULT_MAX_HITS); + setHitBatchUnlimited(false); + setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY); + setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS); + clearAdvanced(); + }} + className="text-xs" + > + Reset to defaults + </Button> + </div> + </div> + </details> + </div> + </details> + ); +} + +function ProfilesRow({ + profileNames, + activeProfileName, + profileDirty, + shareCopied, + onSelect, + onSave, + onSaveAs, + onRename, + onDelete, + onRevert, + onShareCurrentSearch, +}: { + profileNames: string[]; + activeProfileName: string | null; + profileDirty: boolean; + shareCopied: boolean; + onSelect: (name: string) => void; + onSave: () => void; + onSaveAs: () => void; + onRename: () => void; + onDelete: () => void; + onRevert: () => void; + onShareCurrentSearch: () => void; +}) { + const showRevert = activeProfileName != null && profileDirty; + return ( + <div + className="flex flex-wrap items-center gap-x-3 gap-y-1" + data-testid="profiles-row" + > + <span className="text-xs uppercase tracking-wide text-muted-foreground"> + Profile + </span> + <select + value={activeProfileName ?? ""} + onChange={(e) => onSelect(e.target.value)} + data-testid="profile-select" + className="rounded border border-border bg-card text-foreground px-2 py-0.5 text-sm" + > + <option value="">(unsaved)</option> + {profileNames.map((n) => ( + <option key={n} value={n}> + {n} + </option> + ))} + </select> + {profileDirty && ( + <span + role="status" + aria-label="profile has unsaved changes" + data-testid="profile-dirty-dot" + className="text-base leading-none text-warning" + > + • + </span> + )} + <Button + type="button" + variant="link" + size="sm" + onClick={onSave} + disabled={!activeProfileName || !profileDirty} + className="text-xs text-muted-foreground" + > + Save + </Button> + <Button + type="button" + variant="link" + size="sm" + onClick={onSaveAs} + className="text-xs text-muted-foreground" + > + Save as… + </Button> + {showRevert && ( + <Button + type="button" + variant="link" + size="sm" + onClick={onRevert} + data-testid="profile-revert" + className="text-xs text-muted-foreground" + > + Revert + </Button> + )} + <Button + type="button" + variant="link" + size="sm" + onClick={onRename} + disabled={!activeProfileName} + className="text-xs text-muted-foreground" + > + Rename + </Button> + <Button + type="button" + variant="link" + size="sm" + onClick={onDelete} + disabled={!activeProfileName} + className="text-xs text-destructive" + > + Delete + </Button> + <div className="ml-auto flex items-center gap-x-3"> + <Button + type="button" + variant="link" + size="sm" + onClick={onShareCurrentSearch} + title={ + shareCopied + ? "Copied!" + : "Copy link encoding the current search + filters" + } + className="text-xs text-muted-foreground" + > + {shareCopied ? "Link copied!" : "Share current search"} + </Button> + </div> + </div> + ); +} + +function ChannelAllToggle({ + channelOptions, + draftExcludedChannels, + setDraftExcludedChannels, +}: { + channelOptions: string[]; + draftExcludedChannels: Set<string>; + setDraftExcludedChannels: (next: Set<string>) => void; +}) { + const allChecked = draftExcludedChannels.size === 0; + return ( + <label className="flex items-center gap-1.5 select-none"> + <Checkbox + checked={allChecked} + onCheckedChange={() => { + setDraftExcludedChannels( + allChecked ? new Set(channelOptions) : new Set(), + ); + }} + /> + All + </label> + ); +} + +function NumberField({ + label, + hint, + value, + defaultValue, + min, + max, + onChange, + disabled, +}: { + label: string; + hint?: string; + value: number; + defaultValue: number; + min: number; + max: number; + onChange: (n: number) => void; + disabled?: boolean; +}) { + return ( + <label className="flex flex-col gap-1"> + <span className="text-xs font-medium text-foreground"> + {label} + </span> + <input + type="number" + min={min} + max={max} + value={Number.isFinite(value) ? value : ""} + placeholder={String(defaultValue)} + disabled={disabled} + onChange={(e) => { + const raw = e.target.value; + if (raw === "") { + onChange(defaultValue); + return; + } + const n = Math.max(min, Math.min(max, Math.floor(Number(raw)))); + if (Number.isFinite(n)) onChange(n); + }} + className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50" + /> + {hint && <span className="text-xs text-muted-foreground">{hint}</span>} + </label> + ); +} + +function LimitField({ + label, + hint, + value, + unlimited, + defaultValue, + onValueChange, + onUnlimitedChange, +}: { + label: string; + hint?: string; + value: number; + unlimited: boolean; + defaultValue: number; + onValueChange: (n: number) => void; + onUnlimitedChange: (b: boolean) => void; +}) { + return ( + <div className="flex flex-col gap-1"> + <div className="flex items-baseline justify-between gap-2"> + <span className="text-xs font-medium text-foreground"> + {label} + </span> + <label className="flex items-center gap-1 text-xs select-none"> + <Checkbox + checked={unlimited} + onCheckedChange={(value) => onUnlimitedChange(value === true)} + /> + No limit + </label> + </div> + <input + type="number" + aria-label={label} + min={1} + max={1_000_000} + value={unlimited ? "" : value} + placeholder={unlimited ? "∞" : String(defaultValue)} + disabled={unlimited} + onChange={(e) => { + const raw = e.target.value; + if (raw === "") { + onValueChange(defaultValue); + return; + } + const n = Math.max(1, Math.floor(Number(raw))); + if (Number.isFinite(n)) onValueChange(n); + }} + className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50" + /> + {hint && <span className="text-xs text-muted-foreground">{hint}</span>} + </div> + ); +} diff --git a/common/components/QueryBuilder.tsx b/common/components/QueryBuilder.tsx @@ -23,6 +23,7 @@ import { type LeafNode, type QueryNode, } from "../lib/searchQuery"; +import type { ReactNode } from "react"; import type { GroupState, LeafState } from "../lib/searchEval"; import QueryGroupView from "./QueryGroupView"; import QueryLeafView from "./QueryLeafView"; @@ -33,13 +34,49 @@ type Props = { onChange: (next: GroupNode) => void; leafStates: ReadonlyMap<string, LeafState>; groupStates: ReadonlyMap<string, GroupState>; + /** Slotted beside the compact leaf's input — the search bar's submit button. */ + inputTrailing?: ReactNode; + /** The search bar renders "+ Add layer" / "+ Add group" in its own chip row + * (see `compactLayerActions`), so it turns the builder's own pair off rather + * than shipping two of each. */ + showCompactActions?: boolean; }; +// The two compact-mode tree edits, as callables. Exported because the search +// bar renders them as chips OUTSIDE this component and they must stay one +// implementation — the same normalize-on-write contract as `emit` below. +export function compactLayerActions( + root: GroupNode, + onChange: (next: GroupNode) => void, +) { + const emit = (next: GroupNode) => onChange(normalizeRoot(next)); + return { + addLayer: () => + emit( + insertChild( + root, + root.id, + newLeaf({ scope: "transcripts", contributeHits: true }), + ), + ), + addGroup: () => + emit( + insertChild( + root, + root.id, + newGroup({ op: "OR", children: [newLeaf({ scope: "transcripts" })] }), + ), + ), + }; +} + export default function QueryBuilder({ root, onChange, leafStates, groupStates, + inputTrailing, + showCompactActions = true, }: Props) { // All mutations flow through `emit` so the result is normalized: when a // change collapses the tree into compact mode, options the compact UI hides @@ -73,6 +110,7 @@ export default function QueryBuilder({ leaf={leaf} state={leafStates.get(leaf.id)} compact + inputTrailing={inputTrailing} canDelete={false} canNegate={false} onChange={(next) => onChangeNode(leaf.id, next)} @@ -80,40 +118,29 @@ export default function QueryBuilder({ /* not reachable: canDelete=false in compact mode */ }} /> - <div className="flex items-center gap-2 text-xs"> - <Button - type="button" - variant="link" - size="sm" - onClick={() => - onAddChild( - root.id, - newLeaf({ scope: "transcripts", contributeHits: true }), - ) - } - data-testid="compact-add-layer" - className="text-xs text-muted-foreground" - > - + Add layer - </Button> - <Button - type="button" - variant="link" - size="sm" - onClick={() => - onAddChild( - root.id, - newGroup({ - op: "OR", - children: [newLeaf({ scope: "transcripts" })], - }), - ) - } - className="text-xs text-muted-foreground" - > - + Add group - </Button> - </div> + {showCompactActions && ( + <div className="flex items-center gap-2 text-xs"> + <Button + type="button" + variant="link" + size="sm" + onClick={() => compactLayerActions(root, onChange).addLayer()} + data-testid="compact-add-layer" + className="text-xs text-muted-foreground" + > + + Add layer + </Button> + <Button + type="button" + variant="link" + size="sm" + onClick={() => compactLayerActions(root, onChange).addGroup()} + className="text-xs text-muted-foreground" + > + + Add group + </Button> + </div> + )} </div> ); } diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx @@ -8,7 +8,7 @@ // scope dropdown, query input, regex / show-hits / NOT toggles, a live // "→ N videos" count, a cached badge when applicable, and a delete button. -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import type { LeafNode, LayerScope } from "../lib/searchQuery"; import type { LeafState } from "../lib/searchEval"; import { matchAliases } from "../lib/searchAliases"; @@ -26,6 +26,9 @@ type Props = { leaf: LeafNode; state: LeafState | undefined; compact?: boolean; + /** Rendered beside the query input in compact mode — where the search bar + * puts its submit button, so the two sit on one line at any width. */ + inputTrailing?: ReactNode; canDelete: boolean; canNegate?: boolean; isOnlyLeaf?: boolean; @@ -47,6 +50,7 @@ export default function QueryLeafView({ leaf, state, compact = false, + inputTrailing, canDelete, canNegate = true, onChange, @@ -170,7 +174,9 @@ export default function QueryLeafView({ data-testid={`leaf-query-${leaf.id}`} aria-invalid={regexInvalid || undefined} className={cn( - "flex-1 min-w-0", + // text-base below sm: iOS zooms the page on focus for anything under + // 16px, and it never zooms back out. + "flex-1 min-w-0 h-11 text-base sm:h-9 sm:text-sm", regexInvalid && "border-destructive", leaf.useRegex && "font-mono", )} @@ -183,7 +189,7 @@ export default function QueryLeafView({ value={leaf.scope} onChange={(e) => update({ scope: e.target.value as LayerScope })} data-testid={`leaf-scope-${leaf.id}`} - className="rounded border border-border bg-card text-foreground px-1.5 py-1 text-xs font-mono" + className="rounded border border-border bg-card text-foreground px-1.5 py-1 text-base sm:text-xs font-mono" > {( [ @@ -241,6 +247,7 @@ export default function QueryLeafView({ <div className="flex items-center gap-2"> <LayerSwatch leafId={leaf.id} /> {queryInput} + {inputTrailing} </div> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground"> {scopeSelect} diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx @@ -0,0 +1,223 @@ +"use client"; + +// The standard path, and the only part of the bar a first-time visitor has to +// understand: a query box, a Search button, and one row of chips for everything +// else. Below lg it sticks under the header, so the search box is always one +// glance away no matter how far down the results the reader has gone. +// +// Everything that used to sit ABOVE this — the profile row, five filter +// sections, the availability tree, the date pickers — now lives in FiltersPanel +// behind the Filters chip (or inline from xl). That stack was two phone screens +// tall and it sat between the visitor and the thing they came for. + +import { useMemo, type ReactNode } from "react"; +import { LayersIcon, SearchIcon, Share2Icon } from "lucide-react"; +import { isCompactRoot } from "../lib/searchQuery"; +import { MISSING_STATES } from "../lib/availability"; +import { Button } from "./ui/button"; +import QueryBuilder, { compactLayerActions } from "./QueryBuilder"; +import { FiltersTrigger } from "./FiltersContainer"; +import { useMediaQuery } from "../lib/useMediaQuery"; +import { useSearchSession } from "./SearchSessionContext"; + +export default function SearchBar({ nav }: { nav?: ReactNode }) { + const { + commitSearch, + mounted, + draftRoot, + setDraftRoot, + leafStates, + groupStates, + queryDirty, + filtersDirty, + handleResetLayers, + layersResetDisabled, + hasSubs, + liveChatTotalCount, + shareCopied, + handleShareCurrentSearch, + channelOptions, + draftExcludedChannels, + draftNov, + draftNol, + draftNop, + draftNaa, + draftNar, + draftStates, + draftDateFrom, + draftDateTo, + } = useSearchSession(); + + // From xl the filters are an inline block under the bar with their own + // disclosure, so the chip (and the bar's share button, which the profile row + // already carries) would be a second copy of a control that is on screen. + const inlineFilters = useMediaQuery("(min-width: 1280px)"); + + const compact = isCompactRoot(draftRoot); + const layers = compactLayerActions(draftRoot, setDraftRoot); + + // What the Filters chip's badge counts: anything the reader has narrowed. + const activeFilters = useMemo(() => { + let n = 0; + if (draftExcludedChannels.size > 0) n += 1; + if (draftNov || draftNol || draftNop) n += 1; + if (draftNaa || draftNar) n += 1; + if (draftStates.size < MISSING_STATES.length + 1) n += 1; + if (draftDateFrom || draftDateTo) n += 1; + return n; + }, [ + draftExcludedChannels, + draftNov, + draftNol, + draftNop, + draftNaa, + draftNar, + draftStates, + draftDateFrom, + draftDateTo, + ]); + + const submit = ( + <Button + type="submit" + data-testid="search-submit" + data-dirty={queryDirty || filtersDirty ? "true" : "false"} + variant={queryDirty || filtersDirty ? "default" : "outline"} + className="shrink-0 h-11 sm:h-9" + > + <SearchIcon aria-hidden="true" /> + {/* The accessible name stays "Search" at every width; only the ink goes. */} + <span className="sr-only sm:not-sr-only">Search</span> + </Button> + ); + + const resetLayers = ( + <Button + type="button" + variant="link" + size="sm" + onClick={handleResetLayers} + disabled={layersResetDisabled} + data-testid="reset-layers" + className="shrink-0 text-xs text-muted-foreground" + > + Reset layers + </Button> + ); + + return ( + <div className="flex flex-col gap-2 max-lg:sticky max-lg:top-14 max-lg:z-10 max-lg:bg-background max-lg:py-2"> + <form + onSubmit={(e) => { + e.preventDefault(); + commitSearch(); + }} + className="flex flex-col gap-2" + data-testid="search-form" + > + {mounted ? ( + <QueryBuilder + root={draftRoot} + onChange={setDraftRoot} + leafStates={leafStates} + groupStates={groupStates} + // Compact: the submit sits on the input's own line. Full tree: it + // goes in the row below, exactly as before — either way there is + // exactly one `search-submit` in the DOM. + inputTrailing={compact ? submit : undefined} + showCompactActions={false} + /> + ) : ( + <div className="h-[5.5rem] rounded-lg border border-border bg-muted" /> + )} + + {!compact && ( + <div className="flex items-center gap-2 flex-wrap">{submit}</div> + )} + + {/* The chip row. Rendered OUTSIDE the `mounted` gate so the workspace + nav exists as plain anchors before hydration — a nav click lost to + the pre-hydration window is a click that goes nowhere. + It WRAPS rather than scrolling sideways: an `overflow-x-auto` row in + normal flow still widens `documentElement.scrollWidth` under Chrome's + mobile emulation (measured: 498px at a 390px viewport, and neither + `overflow-x: hidden` nor a fixed width fixed it), which is the exact + horizontal overflow this redesign exists to remove. The modal's + control strip can scroll because it is `position: fixed` and so + contributes nothing to the document's scroll width. */} + <div className="flex flex-wrap items-center gap-2 py-0.5"> + {nav} + {compact && ( + <> + <Button + type="button" + variant="outline" + size="sm" + onClick={layers.addLayer} + data-testid="compact-add-layer" + className="shrink-0 text-xs text-muted-foreground" + > + <LayersIcon aria-hidden="true" />+ Add layer + </Button> + <Button + type="button" + variant="outline" + size="sm" + onClick={layers.addGroup} + className="shrink-0 text-xs text-muted-foreground" + > + + Add group + </Button> + </> + )} + {!inlineFilters && channelOptions.length > 0 && ( + <FiltersTrigger + activeCount={activeFilters} + onApply={commitSearch} + /> + )} + {!inlineFilters && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={handleShareCurrentSearch} + title="Copy link encoding the current search + filters" + className="shrink-0 text-xs text-muted-foreground" + > + <Share2Icon aria-hidden="true" /> + {/* sr-only still contributes to the accessible name, so the + button is called "Share current search" — and, once copied, + "Link copied!" — at every width. Five specs wait on those. */} + <span className="sr-only sm:not-sr-only"> + {shareCopied ? "Link copied!" : "Share current search"} + </span> + </Button> + )} + {resetLayers} + </div> + + {/* One status line under the bar instead of two hints competing for + room inside it. */} + {(queryDirty || filtersDirty || hasSubs) && ( + <p className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs"> + {(queryDirty || filtersDirty) && ( + <span className="text-warning"> + Press Enter or click Search to apply + </span> + )} + {hasSubs && ( + <span + className="text-muted-foreground" + title={`${liveChatTotalCount} videos have live chat`} + > + Live chat available on {liveChatTotalCount} video + {liveChatTotalCount === 1 ? "" : "s"} — try scope: Live chat. + </span> + )} + </p> + )} + </form> + </div> + ); +} diff --git a/common/components/WorkspaceSearchBar.tsx b/common/components/WorkspaceSearchBar.tsx @@ -1,910 +1,31 @@ "use client"; -// The persistent search BAR: the profiles row, the query-builder form, the -// collapsible filters, and the advanced options. It's the input half of the old -// TranscriptSearch monolith, lifted into the workspace shell so it stays mounted -// (and keeps its committed search) across `/` ⇄ `/ask`. All of its state comes -// from the shared SearchSession. - -import { Fragment, useCallback } from "react"; -import { ChevronRightIcon } from "lucide-react"; -import { - MISSING_STATES, - VIDEO_STATE_LABELS, - type VideoState, -} from "../lib/availability"; -import { Button } from "./ui/button"; -import { Checkbox } from "./ui/checkbox"; -import QueryBuilder from "./QueryBuilder"; -import { cn } from "../lib/utils"; -import { clearAdvanced } from "./exportAdvancedStorage"; -import { ymdToInput, inputToYmd } from "../lib/ymd"; -import { - useSearchSession, - DEFAULT_MAX_HITS, - DEFAULT_FETCH_CONCURRENCY, - DEFAULT_FLUSH_INTERVAL_MS, -} from "./SearchSessionContext"; -import { useSearchData } from "./SearchDataContext"; - -export default function WorkspaceSearchBar() { - const { - // Profiles row - profiles, - activeProfileName, - profileDirty, - shareCopied, - handleLoadProfile, - handleSaveProfile, - handleSaveAsProfile, - handleRenameProfile, - handleDeleteProfile, - handleRevertProfile, - handleShareCurrentSearch, - // Query builder / commit - commitSearch, - mounted, - draftRoot, - setDraftRoot, - leafStates, - groupStates, - queryDirty, - filtersDirty, - handleResetLayers, - layersResetDisabled, - hasSubs, - liveChatTotalCount, - // Filters - channelOptions, - filtersCollapsed, - toggleFiltersCollapsed, - selectedChannelNames, - selectedChannelsLabel, - draftExcludedChannels, - setDraftExcludedChannels, - handleResetChannels, - handleResetAllFilters, - handleResetEverything, - channelGroupings, - collapsedGroups, - toggleGroupCollapsed, - setSelected, - channelLabelByKey, - draftNov, - draftNop, - setDraftNop, - setDraftNov, - draftNol, - setDraftNol, - draftNaa, - setDraftNaa, - draftNar, - setDraftNar, - draftStates, - setDraftStates, - draftDateFrom, - setDraftDateFrom, - draftDateTo, - setDraftDateTo, - // Advanced options - hitBatchValue, - setHitBatchValue, - hitBatchUnlimited, - setHitBatchUnlimited, - fetchConcurrency, - setFetchConcurrency, - flushIntervalMs, - setFlushIntervalMs, - setHitLimit, - } = useSearchSession(); - - // Only offer the Posts type toggle on a site that actually ships a posts - // corpus, so a pure-video deployment's filter row is unchanged. - const { postsManifest } = useSearchData(); - const hasPostsCorpus = (postsManifest?.channels.length ?? 0) > 0; - - const toggleState = useCallback( - (state: VideoState, keep: boolean) => { - setDraftStates((prev) => { - const next = new Set(prev); - if (keep) next.add(state); - else next.delete(state); - return next; - }); - }, - [setDraftStates], - ); - const setAllMissing = useCallback( - (keep: boolean) => { - setDraftStates((prev) => { - const next = new Set(prev); - for (const s of MISSING_STATES) { - if (keep) next.add(s); - else next.delete(s); - } - return next; - }); - }, - [setDraftStates], - ); - const allMissingKept = MISSING_STATES.every((s) => draftStates.has(s)); - const someMissingKept = MISSING_STATES.some((s) => draftStates.has(s)); - - return ( - <div className="flex flex-col gap-6"> - <ProfilesRow - profileNames={Object.keys(profiles).sort()} - activeProfileName={activeProfileName} - profileDirty={profileDirty} - shareCopied={shareCopied} - onSelect={handleLoadProfile} - onSave={handleSaveProfile} - onSaveAs={handleSaveAsProfile} - onRename={handleRenameProfile} - onDelete={handleDeleteProfile} - onRevert={handleRevertProfile} - onShareCurrentSearch={handleShareCurrentSearch} - /> - <form - onSubmit={(e) => { - e.preventDefault(); - commitSearch(); - }} - className="flex flex-col gap-3" - data-testid="search-form" - > - {mounted ? ( - <QueryBuilder - root={draftRoot} - onChange={setDraftRoot} - leafStates={leafStates} - groupStates={groupStates} - /> - ) : ( - <div className="h-12 rounded-lg border border-border bg-muted" /> - )} - <div className="flex items-center gap-2 flex-wrap"> - <Button - type="submit" - data-testid="search-submit" - data-dirty={queryDirty || filtersDirty ? "true" : "false"} - variant={queryDirty || filtersDirty ? "default" : "outline"} - className="shrink-0" - > - Search - </Button> - <Button - type="button" - variant="link" - size="sm" - onClick={handleResetLayers} - disabled={layersResetDisabled} - data-testid="reset-layers" - className="text-xs text-muted-foreground" - > - Reset layers - </Button> - {(queryDirty || filtersDirty) && ( - <span className="text-xs text-warning"> - Press Enter or click Search to apply - </span> - )} - {hasSubs && ( - <span - className="text-xs text-muted-foreground ml-auto" - title={`${liveChatTotalCount} videos have live chat`} - > - Live chat available on {liveChatTotalCount} video - {liveChatTotalCount === 1 ? "" : "s"} — try scope: Live chat. - </span> - )} - </div> - </form> - - {channelOptions.length > 0 && ( - <details open={!filtersCollapsed} className="-mt-3"> - <summary - onClick={(e) => { - // Drive the open state from React rather than the browser's - // default toggle action. Avoids a spurious `toggle` event that - // fires when React first mounts <details open> after the - // manifest loads, which would clobber the persisted value. - e.preventDefault(); - toggleFiltersCollapsed(); - }} - className="cursor-pointer select-none flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm text-muted-foreground" - > - <span className="text-xs uppercase tracking-wide text-muted-foreground"> - Filters - </span> - <span className="text-xs text-muted-foreground"> - {selectedChannelNames.length} of {channelOptions.length} channel - {channelOptions.length === 1 ? "" : "s"} - </span> - {filtersCollapsed && selectedChannelNames.length > 0 && ( - <span - className="text-xs text-muted-foreground truncate min-w-0 flex-1" - title={selectedChannelsLabel} - data-testid="filters-summary-channels" - > - {selectedChannelsLabel} - </span> - )} - </summary> - <div className="mt-2 flex flex-wrap items-center gap-x-4 gap-y-2 text-sm text-muted-foreground"> - {channelOptions.length > 1 && ( - <div className="flex flex-col gap-2 w-full"> - <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span className="text-xs uppercase tracking-wide text-muted-foreground"> - Channels - </span> - <ChannelAllToggle - channelOptions={channelOptions} - draftExcludedChannels={draftExcludedChannels} - setDraftExcludedChannels={setDraftExcludedChannels} - /> - <Button - type="button" - variant="link" - size="sm" - onClick={handleResetChannels} - className="text-xs text-muted-foreground" - > - Reset channels - </Button> - <Button - type="button" - variant="link" - size="sm" - onClick={handleResetAllFilters} - className="text-xs text-muted-foreground" - > - Reset all filters - </Button> - <Button - type="button" - variant="link" - size="sm" - onClick={handleResetEverything} - data-testid="reset-everything" - className="text-xs text-muted-foreground" - > - Reset everything - </Button> - </div> - <div className="flex flex-wrap items-start gap-1.5"> - {channelGroupings.map(({ group, channels }) => { - if (group.inline) { - // Inline group: no collapsible box — each member channel - // renders as its own loose chip flowing among the group - // chips, styled like a collapsed group chip. - return ( - <Fragment key={group.id}> - {channels.map((key) => { - const label = channelLabelByKey.get(key) ?? key; - return ( - <label - key={key} - title={label} - data-testid="inline-channel-chip" - className="flex items-center gap-2 rounded border border-border bg-card/60 px-2.5 py-1.5 text-sm cursor-pointer select-none min-w-0 max-w-full w-full sm:w-auto sm:min-w-48" - > - <span className="flex items-center shrink-0"> - <Checkbox - checked={!draftExcludedChannels.has(key)} - onCheckedChange={(value) => - setSelected([key], value === true) - } - /> - </span> - <span className="truncate">{label}</span> - </label> - ); - })} - </Fragment> - ); - } - const isOpen = !collapsedGroups.has(group.id); - const selectedCount = channels.reduce( - (n, name) => - draftExcludedChannels.has(name) ? n : n + 1, - 0, - ); - return ( - <details - key={group.id} - open={isOpen} - className={cn( - "rounded border border-border bg-card/60 min-w-0 max-w-full w-full", - !isOpen && "sm:w-auto sm:min-w-48", - )} - > - <summary - onClick={(e) => { - // Drive the open state from React rather than the - // browser's default toggle action — same rationale - // as the outer Filters summary above. - e.preventDefault(); - toggleGroupCollapsed(group.id, !isOpen); - }} - className="cursor-pointer select-none flex items-center gap-2 px-2.5 py-1.5 text-sm min-w-0" - title={!isOpen ? group.description : undefined} - > - <ChevronRightIcon - aria-hidden="true" - className={cn( - "size-3.5 shrink-0 text-muted-foreground transition-transform", - isOpen && "rotate-90", - )} - /> - <span - onClick={(e) => { - // Isolate checkbox clicks from the summary: no - // expand/collapse, just the group bulk-toggle. - e.preventDefault(); - e.stopPropagation(); - }} - className="flex items-center shrink-0" - > - <Checkbox - checked={ - selectedCount === 0 - ? false - : selectedCount === channels.length - ? true - : "indeterminate" - } - onCheckedChange={(value) => - setSelected(channels, value === true) - } - aria-label={`Select all in ${group.name || "group"}`} - /> - </span> - {group.name && ( - <span - className="font-medium text-foreground flex items-center gap-1.5 min-w-0" - title={group.name} - > - {group.accent && ( - <span - aria-hidden="true" - className="inline-block size-2 shrink-0 rounded-full" - style={{ background: group.accent }} - /> - )} - <span className="truncate">{group.name}</span> - </span> - )} - <span - className={cn( - "text-xs text-muted-foreground shrink-0", - !isOpen && "ml-auto", - )} - > - {selectedCount}/{channels.length} - <span className="sr-only"> selected</span> - </span> - {isOpen && ( - <span className="ml-auto flex items-center gap-2"> - <Button - type="button" - variant="link" - size="sm" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setSelected(channels, true); - }} - className="text-xs text-muted-foreground" - > - All - </Button> - <Button - type="button" - variant="link" - size="sm" - onClick={(e) => { - e.preventDefault(); - e.stopPropagation(); - setSelected(channels, false); - }} - className="text-xs text-muted-foreground" - > - None - </Button> - </span> - )} - </summary> - {group.description && ( - <div className="px-3 pt-0 pb-1 text-xs text-muted-foreground"> - {group.description} - </div> - )} - <div className="flex flex-wrap items-center gap-x-3 gap-y-1 px-3 pt-1 pb-2"> - {channels.map((key) => { - const checked = !draftExcludedChannels.has(key); - return ( - <label - key={key} - className="flex items-center gap-1.5 select-none" - > - <Checkbox - checked={checked} - onCheckedChange={(value) => { - setDraftExcludedChannels((prev) => { - const next = new Set(prev); - if (value === true) next.delete(key); - else next.add(key); - return next; - }); - }} - /> - {channelLabelByKey.get(key) ?? key} - </label> - ); - })} - </div> - </details> - ); - })} - </div> - </div> - )} - <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span className="text-xs uppercase tracking-wide text-muted-foreground"> - Type - </span> - <label className="flex items-center gap-1.5 select-none"> - <Checkbox - checked={!draftNov} - onCheckedChange={(value) => setDraftNov(value !== true)} - /> - Videos - </label> - <label className="flex items-center gap-1.5 select-none"> - <Checkbox - checked={!draftNol} - onCheckedChange={(value) => setDraftNol(value !== true)} - /> - Livestreams - </label> - {/* The social-post corpus is a third media kind, not a video - sub-type — without its own toggle the video/livestream pair - would silently drop every post. Only offered when the site - actually ships posts. */} - {hasPostsCorpus && ( - <label className="flex items-center gap-1.5 select-none"> - <Checkbox - checked={!draftNop} - onCheckedChange={(value) => setDraftNop(value !== true)} - /> - Posts - </label> - )} - </div> - <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span className="text-xs uppercase tracking-wide text-muted-foreground"> - Audience - </span> - <label className="flex items-center gap-1.5 select-none"> - <Checkbox - checked={!draftNaa} - onCheckedChange={(value) => setDraftNaa(value !== true)} - /> - All ages - </label> - <label className="flex items-center gap-1.5 select-none"> - <Checkbox - checked={!draftNar} - onCheckedChange={(value) => setDraftNar(value !== true)} - /> - Age-restricted - </label> - </div> - {/* Availability is one enum, not three independent booleans: - "Missing" is an umbrella over the five ways a video can leave - its channel listing, so it renders as a parent with the leaves - nested under it. Toggling the parent sets or clears all five; - it goes indeterminate when only some are kept. */} - <div className="flex flex-wrap items-start gap-x-3 gap-y-1"> - <span className="text-xs uppercase tracking-wide text-muted-foreground pt-0.5"> - Availability - </span> - <label className="flex items-center gap-1.5 select-none"> - <Checkbox - data-testid="av-available" - checked={draftStates.has("available")} - onCheckedChange={(value) => - toggleState("available", value === true) - } - /> - Available - </label> - <div className="flex flex-col gap-1"> - <label className="flex items-center gap-1.5 select-none"> - <Checkbox - data-testid="av-missing" - checked={ - allMissingKept - ? true - : someMissingKept - ? "indeterminate" - : false - } - onCheckedChange={(value) => setAllMissing(value === true)} - /> - Missing - </label> - <div className="flex flex-wrap items-center gap-x-3 gap-y-1 pl-5"> - {MISSING_STATES.map((state) => ( - <label - key={state} - className="flex items-center gap-1.5 select-none" - > - <Checkbox - data-testid={`av-${state}`} - checked={draftStates.has(state)} - onCheckedChange={(value) => - toggleState(state, value === true) - } - /> - {VIDEO_STATE_LABELS[state]} - </label> - ))} - </div> - </div> - </div> - <div className="flex flex-wrap items-center gap-x-3 gap-y-1"> - <span className="text-xs uppercase tracking-wide text-muted-foreground"> - Date - </span> - <label className="flex items-center gap-1.5 select-none"> - From - <input - type="date" - data-testid="date-from" - value={ymdToInput(draftDateFrom)} - onChange={(e) => - setDraftDateFrom(inputToYmd(e.target.value) ?? "") - } - className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm" - /> - </label> - <label className="flex items-center gap-1.5 select-none"> - To - <input - type="date" - data-testid="date-to" - value={ymdToInput(draftDateTo)} - onChange={(e) => - setDraftDateTo(inputToYmd(e.target.value) ?? "") - } - className="rounded-md border border-border bg-card text-foreground px-2 py-1 text-sm" - /> - </label> - </div> - </div> - </details> - )} - - <details className="-mt-3 text-sm text-muted-foreground"> - <summary className="cursor-pointer select-none text-xs uppercase tracking-wide text-muted-foreground hover:text-foreground"> - Advanced options - </summary> - <div className="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-3 p-4 rounded-md border border-border bg-card/60"> - <LimitField - label="Max hits per batch" - hint="Pause after this many matches; “Show more” adds another batch." - value={hitBatchValue} - unlimited={hitBatchUnlimited} - defaultValue={DEFAULT_MAX_HITS} - onValueChange={setHitBatchValue} - onUnlimitedChange={setHitBatchUnlimited} - /> - <NumberField - label="Fetch concurrency" - hint="Parallel transcript downloads while searching. Applies on next search." - value={fetchConcurrency} - defaultValue={DEFAULT_FETCH_CONCURRENCY} - min={1} - max={32} - onChange={setFetchConcurrency} - /> - <NumberField - label="Flush interval (ms)" - hint="UI update cadence during search. Applies on next search." - value={flushIntervalMs} - defaultValue={DEFAULT_FLUSH_INTERVAL_MS} - min={16} - max={2000} - onChange={setFlushIntervalMs} - /> - <div className="sm:col-span-2 flex flex-wrap gap-2 pt-1"> - <Button - type="button" - variant="outline" - size="sm" - onClick={() => { - setHitBatchUnlimited(true); - setHitLimit(Infinity); - }} - className="text-xs" - > - Search through all transcripts - </Button> - <Button - type="button" - variant="outline" - size="sm" - onClick={() => { - setHitBatchValue(DEFAULT_MAX_HITS); - setHitBatchUnlimited(false); - setFetchConcurrency(DEFAULT_FETCH_CONCURRENCY); - setFlushIntervalMs(DEFAULT_FLUSH_INTERVAL_MS); - clearAdvanced(); - }} - className="text-xs" - > - Reset to defaults - </Button> - </div> - </div> - </details> - </div> - ); -} - -function ProfilesRow({ - profileNames, - activeProfileName, - profileDirty, - shareCopied, - onSelect, - onSave, - onSaveAs, - onRename, - onDelete, - onRevert, - onShareCurrentSearch, -}: { - profileNames: string[]; - activeProfileName: string | null; - profileDirty: boolean; - shareCopied: boolean; - onSelect: (name: string) => void; - onSave: () => void; - onSaveAs: () => void; - onRename: () => void; - onDelete: () => void; - onRevert: () => void; - onShareCurrentSearch: () => void; -}) { - const showRevert = activeProfileName != null && profileDirty; - return ( - <div - className="flex flex-wrap items-center gap-x-3 gap-y-1" - data-testid="profiles-row" - > - <span className="text-xs uppercase tracking-wide text-muted-foreground"> - Profile - </span> - <select - value={activeProfileName ?? ""} - onChange={(e) => onSelect(e.target.value)} - data-testid="profile-select" - className="rounded border border-border bg-card text-foreground px-2 py-0.5 text-sm" - > - <option value="">(unsaved)</option> - {profileNames.map((n) => ( - <option key={n} value={n}> - {n} - </option> - ))} - </select> - {profileDirty && ( - <span - role="status" - aria-label="profile has unsaved changes" - data-testid="profile-dirty-dot" - className="text-base leading-none text-warning" - > - • - </span> - )} - <Button - type="button" - variant="link" - size="sm" - onClick={onSave} - disabled={!activeProfileName || !profileDirty} - className="text-xs text-muted-foreground" - > - Save - </Button> - <Button - type="button" - variant="link" - size="sm" - onClick={onSaveAs} - className="text-xs text-muted-foreground" - > - Save as… - </Button> - {showRevert && ( - <Button - type="button" - variant="link" - size="sm" - onClick={onRevert} - data-testid="profile-revert" - className="text-xs text-muted-foreground" - > - Revert - </Button> - )} - <Button - type="button" - variant="link" - size="sm" - onClick={onRename} - disabled={!activeProfileName} - className="text-xs text-muted-foreground" - > - Rename - </Button> - <Button - type="button" - variant="link" - size="sm" - onClick={onDelete} - disabled={!activeProfileName} - className="text-xs text-destructive" - > - Delete - </Button> - <div className="ml-auto flex items-center gap-x-3"> - <Button - type="button" - variant="link" - size="sm" - onClick={onShareCurrentSearch} - title={ - shareCopied - ? "Copied!" - : "Copy link encoding the current search + filters" - } - className="text-xs text-muted-foreground" - > - {shareCopied ? "Link copied!" : "Share current search"} - </Button> - </div> - </div> - ); -} - -function ChannelAllToggle({ - channelOptions, - draftExcludedChannels, - setDraftExcludedChannels, -}: { - channelOptions: string[]; - draftExcludedChannels: Set<string>; - setDraftExcludedChannels: (next: Set<string>) => void; -}) { - const allChecked = draftExcludedChannels.size === 0; - return ( - <label className="flex items-center gap-1.5 select-none"> - <Checkbox - checked={allChecked} - onCheckedChange={() => { - setDraftExcludedChannels( - allChecked ? new Set(channelOptions) : new Set(), - ); - }} - /> - All - </label> - ); -} - -function NumberField({ - label, - hint, - value, - defaultValue, - min, - max, - onChange, - disabled, -}: { - label: string; - hint?: string; - value: number; - defaultValue: number; - min: number; - max: number; - onChange: (n: number) => void; - disabled?: boolean; -}) { - return ( - <label className="flex flex-col gap-1"> - <span className="text-xs font-medium text-foreground"> - {label} - </span> - <input - type="number" - min={min} - max={max} - value={Number.isFinite(value) ? value : ""} - placeholder={String(defaultValue)} - disabled={disabled} - onChange={(e) => { - const raw = e.target.value; - if (raw === "") { - onChange(defaultValue); - return; - } - const n = Math.max(min, Math.min(max, Math.floor(Number(raw)))); - if (Number.isFinite(n)) onChange(n); - }} - className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50" - /> - {hint && <span className="text-xs text-muted-foreground">{hint}</span>} - </label> - ); -} - -function LimitField({ - label, - hint, - value, - unlimited, - defaultValue, - onValueChange, - onUnlimitedChange, +// The persistent search bar, as a composition of two pieces: +// +// • SearchBar — the query box, the submit, and one chip row. The standard +// path, and the only thing a first-time visitor must read. +// • FiltersPanel — the profile row and every filter, placed by +// FiltersContainer (inline from xl, a sheet below it). +// +// It stays mounted in the workspace shell, so the committed search survives +// `/` ⇄ `/ask`. All of its state comes from the shared SearchSession. + +import type { ReactNode } from "react"; +import SearchBar from "./SearchBar"; +import { FiltersSection } from "./FiltersContainer"; + +export default function WorkspaceSearchBar({ + // The [Search | Chat | Split] control, when the caller has one. It is an + // export-app component and this lives in common/, so it arrives as a slot — + // and it belongs beside the search box, not below the filters. + nav, }: { - label: string; - hint?: string; - value: number; - unlimited: boolean; - defaultValue: number; - onValueChange: (n: number) => void; - onUnlimitedChange: (b: boolean) => void; + nav?: ReactNode; }) { return ( - <div className="flex flex-col gap-1"> - <div className="flex items-baseline justify-between gap-2"> - <span className="text-xs font-medium text-foreground"> - {label} - </span> - <label className="flex items-center gap-1 text-xs select-none"> - <Checkbox - checked={unlimited} - onCheckedChange={(value) => onUnlimitedChange(value === true)} - /> - No limit - </label> - </div> - <input - type="number" - aria-label={label} - min={1} - max={1_000_000} - value={unlimited ? "" : value} - placeholder={unlimited ? "∞" : String(defaultValue)} - disabled={unlimited} - onChange={(e) => { - const raw = e.target.value; - if (raw === "") { - onValueChange(defaultValue); - return; - } - const n = Math.max(1, Math.floor(Number(raw))); - if (Number.isFinite(n)) onValueChange(n); - }} - className="w-full rounded border border-border bg-card text-foreground px-2 py-1 text-sm disabled:opacity-50" - /> - {hint && <span className="text-xs text-muted-foreground">{hint}</span>} + <div className="flex flex-col gap-4"> + <SearchBar nav={nav} /> + <FiltersSection /> </div> ); } diff --git a/export/app/(workspace)/SiteWorkspace.tsx b/export/app/(workspace)/SiteWorkspace.tsx @@ -16,7 +16,8 @@ import PostModal from "yt-dlp-transcript-common/components/PostModal"; import { SingleSiteDataProvider } from "yt-dlp-transcript-common/components/SearchDataContext"; import { SearchSessionProvider } from "yt-dlp-transcript-common/components/SearchSessionContext"; import WorkspaceSearchBar from "yt-dlp-transcript-common/components/WorkspaceSearchBar"; -import WorkspaceView from "./WorkspaceView"; +import WorkspaceView, { WorkspaceViewProvider } from "./WorkspaceView"; +import WorkspaceNav from "./WorkspaceNav"; export default function SiteWorkspace({ children, @@ -27,13 +28,16 @@ export default function SiteWorkspace({ <PlayerProvider> <SingleSiteDataProvider> <SearchSessionProvider> - <div className="flex flex-col gap-6"> - <WorkspaceSearchBar /> - {/* Both panes (results + chat) mount once inside WorkspaceView; the - [Search|Chat|Split] control governs which show. The routed - `children` is each route's intro chrome, slotted above its pane. */} - <WorkspaceView>{children}</WorkspaceView> - </div> + {/* The view provider wraps BOTH: the [Search|Chat|Split] control is + slotted into the bar's chip row, and the panes it governs live in + WorkspaceView. The routed `children` is each route's intro + chrome, slotted above its pane. */} + <WorkspaceViewProvider> + <div className="flex flex-col gap-6"> + <WorkspaceSearchBar nav={<WorkspaceNav />} /> + <WorkspaceView>{children}</WorkspaceView> + </div> + </WorkspaceViewProvider> </SearchSessionProvider> </SingleSiteDataProvider> <TranscriptModal /> diff --git a/export/app/(workspace)/WorkspaceNav.tsx b/export/app/(workspace)/WorkspaceNav.tsx @@ -10,26 +10,15 @@ import Link from "next/link"; import { ColumnsIcon, MessageSquareIcon, SearchIcon } from "lucide-react"; -import type { ViewMode } from "./WorkspaceView"; +import { useWorkspaceView } from "./WorkspaceView"; -export default function WorkspaceNav({ - onAsk, - view, - wide, - onPickSingle, - onPickSplit, -}: { - // Whether the current route is /ask (drives which single-pane link is active). - onAsk: boolean; - // The current (possibly split) view. - view: ViewMode; - // ≥ lg — Split is only offered here. - wide: boolean; - // Picking a single-pane view (navigates + clears any split). - onPickSingle: (v: "search" | "chat") => void; - // Picking the side-by-side view (stays on the current route). - onPickSplit: () => void; -}) { +// Reads its state from WorkspaceViewProvider rather than taking props, because +// it is rendered in the search bar (a common/ component that cannot import it) +// while the panes it governs are rendered in WorkspaceView. +export default function WorkspaceNav() { + const { onAsk, view, wide, setSplit } = useWorkspaceView(); + const onPickSingle = () => setSplit(false); + const onPickSplit = () => setSplit(true); const split = view === "split"; const base = "inline-flex items-center gap-1.5 px-3 py-1.5 font-medium transition-colors"; @@ -42,12 +31,12 @@ export default function WorkspaceNav({ role="group" aria-label="Search, chat, or split view" data-testid="workspace-nav" - className="inline-flex self-start overflow-hidden rounded-md border border-border bg-muted text-sm" + className="inline-flex shrink-0 self-start overflow-hidden rounded-md border border-border bg-muted text-sm" > <Link href="/" aria-current={!onAsk && !split ? "page" : undefined} - onClick={() => onPickSingle("search")} + onClick={onPickSingle} className={`${base} ${!split && !onAsk ? active : idle}`} > <SearchIcon className="size-3.5" /> Search @@ -55,7 +44,7 @@ export default function WorkspaceNav({ <Link href="/ask/" aria-current={onAsk && !split ? "page" : undefined} - onClick={() => onPickSingle("chat")} + onClick={onPickSingle} className={`${base} ${!split && onAsk ? active : idle}`} > <MessageSquareIcon className="size-3.5" /> Chat diff --git a/export/app/(workspace)/WorkspaceView.tsx b/export/app/(workspace)/WorkspaceView.tsx @@ -16,18 +16,42 @@ // header on `/`, the "Ask a question" header on `/ask`); it's slotted above the // pane it belongs to. -import { useEffect, useState, type ReactNode } from "react"; +import { + createContext, + useContext, + useEffect, + useMemo, + 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"; export type ViewMode = "search" | "chat" | "split"; const SPLIT_KEY = "ytdlp-tb:workspace-split"; -export default function WorkspaceView({ children }: { children: ReactNode }) { +type WorkspaceViewValue = { + onAsk: boolean; + view: ViewMode; + wide: boolean; + setSplit: (on: boolean) => void; +}; + +const Ctx = createContext<WorkspaceViewValue | null>(null); + +export function useWorkspaceView(): WorkspaceViewValue { + const v = useContext(Ctx); + if (!v) throw new Error("useWorkspaceView outside WorkspaceViewProvider"); + return v; +} + +// The view state lives ABOVE both the search bar and the panes, because the +// [Search | Chat | Split] control now sits in the bar's chip row — beside the +// search box, where the choice is made — while the panes it governs are here. +export function WorkspaceViewProvider({ children }: { children: ReactNode }) { const pathname = usePathname(); const onAsk = pathname?.startsWith("/ask") ?? false; @@ -48,19 +72,33 @@ export default function WorkspaceView({ children }: { children: ReactNode }) { /* eslint-enable react-hooks/set-state-in-effect */ }, []); - const setSplit = (on: boolean) => { - setSplitOn(on); - try { - localStorage.setItem(SPLIT_KEY, on ? "1" : "0"); - } catch { - /* ignore */ - } - }; - // Split only takes effect on a wide viewport; otherwise the view follows the // route (the two-state tab degradation). const view: ViewMode = splitOn && wide ? "split" : onAsk ? "chat" : "search"; + + const value = useMemo<WorkspaceViewValue>( + () => ({ + onAsk, + view, + wide, + setSplit: (on: boolean) => { + setSplitOn(on); + try { + localStorage.setItem(SPLIT_KEY, on ? "1" : "0"); + } catch { + /* ignore */ + } + }, + }), + [onAsk, view, wide], + ); + + return <Ctx.Provider value={value}>{children}</Ctx.Provider>; +} + +export default function WorkspaceView({ children }: { children: ReactNode }) { + const { onAsk, view } = useWorkspaceView(); const split = view === "split"; const showSearch = split || view === "search"; const showChat = split || view === "chat"; @@ -79,14 +117,6 @@ export default function WorkspaceView({ children }: { children: ReactNode }) { return ( <div className="flex flex-col gap-6"> - <WorkspaceNav - onAsk={onAsk} - view={view} - wide={wide} - onPickSingle={() => setSplit(false)} - onPickSplit={() => setSplit(true)} - /> - <div className={ split ? "grid gap-6 lg:grid-cols-2 lg:items-start" : "block" diff --git a/export/e2e/helpers.ts b/export/e2e/helpers.ts @@ -111,6 +111,28 @@ export async function installChartRoutes(page: Page) { }); } +// Make the filters reachable. From xl they are an inline block under the search +// bar and this is a no-op; below xl they live in a sheet behind the "Filters" +// chip, so a spec that asserts on a filter control has to open it first. +export async function openFilters(page: Page) { + // The bar only decides where the panel goes once it has hydrated and read + // the viewport, so wait for the builder before asking which shape it took. + await page.getByTestId("query-builder").waitFor(); + const panel = page.getByTestId("filters-panel"); + const trigger = page.getByTestId("filters-trigger"); + await panel.or(trigger).first().waitFor(); + if ((await panel.count()) === 0) { + await trigger.click(); + await panel.waitFor(); + } + // Inline placement: the panel is a <details> and may be collapsed. + const profiles = panel.getByTestId("profiles-row"); + if (!(await profiles.isVisible())) { + await panel.locator("summary").first().click(); + } + await profiles.waitFor(); +} + export async function urlParams(page: Page): Promise<URLSearchParams> { const search = await page.evaluate(() => window.location.search); return new URLSearchParams(search); diff --git a/export/e2e/inline-channel-chips.spec.ts b/export/e2e/inline-channel-chips.spec.ts @@ -1,7 +1,8 @@ import { expect, test, type Page, type Route } from "@playwright/test"; +import { openFilters } from "./helpers"; // Self-contained fixtures exercising inline channel groups -// (common/components/WorkspaceSearchBar.tsx): a group flagged `inline: true` +// (common/components/FiltersPanel.tsx): a group flagged `inline: true` // renders its member channels as loose individual chips — checkbox + name, // no collapsible box — flowing among the normal group chips. @@ -219,6 +220,9 @@ test.describe("inline channel chips on mobile", () => { await installRoutes(page); await page.goto("/"); await waitForHydration(page); + // Below xl the filters live in a sheet behind the "Filters" chip, so the + // chips have to be opened before they can be measured. + await openFilters(page); }); test("chips stack one per line at phone widths", async ({ page }) => { diff --git a/export/e2e/profile-row.spec.ts b/export/e2e/profile-row.spec.ts @@ -1,6 +1,6 @@ import { expect, test, type Page } from "@playwright/test"; import { CHANNEL } from "./fixtures/data"; -import { installRoutes } from "./helpers"; +import { installRoutes, openFilters } from "./helpers"; const STORAGE_KEY = "ytdlp-tb:export-filters"; @@ -65,22 +65,23 @@ test.describe("search UI — profile row + reset refinements", () => { await expect(reset).toBeDisabled(); }); - test("Profile row renders above the QueryBuilder, even with one channel", async ({ + test("Profile row lives in the filters panel, even with one channel", async ({ page, }) => { await page.goto("/"); - const profilesRow = page.getByTestId("profiles-row"); - const builder = page.getByTestId("query-builder"); + // A profile IS a saved filter+query set, so it sits inside the filters + // panel rather than as a third row above the search box. Its controls are + // reachable wherever the panel is placed (inline from xl, a sheet below). + await openFilters(page); + const panel = page.getByTestId("filters-panel"); + const profilesRow = panel.getByTestId("profiles-row"); await expect(profilesRow).toBeVisible(); - await expect(builder).toBeVisible(); - const profileBox = await profilesRow.boundingBox(); - const builderBox = await builder.boundingBox(); - if (!profileBox || !builderBox) throw new Error("missing bounding box"); - expect(profileBox.y).toBeLessThan(builderBox.y); + await expect(page.getByTestId("query-builder")).toBeVisible(); }); test("Share button lives inside the Profile row", async ({ page }) => { await page.goto("/"); + await openFilters(page); const profilesRow = page.getByTestId("profiles-row"); const shareInRow = profilesRow.getByRole("button", { name: "Share current search",