commit ce8f5a9f3f70bed5c216b3c258942963f72d89f0
parent 281d0173f19cc605a12adfd4e640657e51588dd0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sat, 29 Aug 2026 21:53:40 -0400
search: only the compact bar sticks, and the phone menu offers Offline
The sticky wrapper enclosed the whole form, so a query tree of a few
leaves — 725px at 390px wide — pinned itself permanently over the
results it produced. Sticky now covers the input row and the chips, and
only in compact mode; the status line sits outside it, and a full tree
scrolls away like anything else. The submit keeps its word when it has
a row to itself instead of being a lone unlabelled magnifier.
The phone menu also gains Offline on a PWA-shipping site, gated the way
the footer gates it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Diffstat:
2 files changed, 107 insertions(+), 85 deletions(-)
diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx
@@ -85,8 +85,10 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
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>
+ {/* The accessible name stays "Search" everywhere; only the ink goes, and
+ only where it has to — beside the input on a narrow compact bar. With
+ a full tree the button has a row to itself, so it keeps its word. */}
+ <span className={compact ? "sr-only sm:not-sr-only" : ""}>Search</span>
</Button>
);
@@ -105,7 +107,7 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
);
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">
+ <div className="flex flex-col gap-2">
<form
onSubmit={(e) => {
e.preventDefault();
@@ -114,94 +116,108 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
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" />
- )}
+ {/* Sticky ONLY in compact mode, and only over the input row and the
+ chip row. A full query tree is several leaf cards and group boxes —
+ pinning that to the top of a phone would park a block taller than
+ the viewport permanently over the results it produced. Same element
+ either way, so flipping compact never remounts the builder. */}
+ <div
+ className={
+ compact
+ ? "flex flex-col gap-2 max-lg:sticky max-lg:top-14 max-lg:z-10 max-lg:bg-background max-lg:py-2"
+ : "flex flex-col gap-2"
+ }
+ >
+ {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>
- )}
+ {!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>
+ {/* 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>
+ </>
+ )}
+ {/* Not gated on `channelOptions.length` — the panel also holds the
+ profile row and the advanced options, which used to be visible
+ unconditionally, and the channel list is empty for the first
+ moment of every page load. */}
+ {!inlineFilters && (
+ <FiltersTrigger
+ activeCount={activeFilters}
+ onApply={commitSearch}
+ />
+ )}
+ {!inlineFilters && (
<Button
type="button"
variant="outline"
size="sm"
- onClick={layers.addGroup}
+ onClick={handleShareCurrentSearch}
+ title="Copy link encoding the current search + filters"
className="shrink-0 text-xs text-muted-foreground"
>
- + Add group
+ <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>
- </>
- )}
- {/* Not gated on `channelOptions.length` — the panel also holds the
- profile row and the advanced options, which used to be visible
- unconditionally, and the channel list is empty for the first
- moment of every page load. */}
- {!inlineFilters && (
- <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}
+ )}
+ {resetLayers}
+ </div>
</div>
{/* One status line under the bar instead of two hints competing for
- room inside it. */}
+ room inside it — and OUTSIDE the sticky region, so the pinned block
+ is never more than the input row plus the chips. */}
{(queryDirty || filtersDirty || hasSubs) && (
<p className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
{(queryDirty || filtersDirty) && (
diff --git a/export/app/components/Header.tsx b/export/app/components/Header.tsx
@@ -39,11 +39,9 @@ export default function Header() {
const showDownloads = hasArchives();
const showDuplicates = hasDuplicates();
- // One list, two renderings: the inline nav from md, and the sheet below it —
- // which also takes Changelog (a right-cluster link on a wide header) so the
- // phone menu is the complete set. Ask AI is new: the chat was only reachable
- // from the workspace control or from Use with AI, which is not where anyone
- // looks for it.
+ // The inline nav from md. Ask AI is new: the chat was only reachable from
+ // the workspace control or from Use with AI, which is not where anyone looks
+ // for it.
const navLinks = [
{ href: "/", label: "Search" },
{ href: "/ask/", label: "Ask AI" },
@@ -51,7 +49,15 @@ export default function Header() {
...(showDownloads ? [{ href: "/downloads", label: "Downloads" }] : []),
{ href: "/use-with-ai", label: "Use with AI" },
];
- const menuLinks = [...navLinks, { href: "/changelog", label: "Changelog" }];
+ // The sheet also takes the two links the wide header keeps in its right
+ // cluster or its footer: Changelog, and Offline on a PWA-shipping site —
+ // gated exactly as Footer.tsx gates it, so the two never disagree about
+ // whether this instance has an offline mode.
+ const menuLinks = [
+ ...navLinks,
+ ...(site.pwa ? [{ href: "/offline/", label: "Offline" }] : []),
+ { href: "/changelog", label: "Changelog" },
+ ];
return (
<header className="sticky top-0 z-20 border-b border-border bg-background/80 backdrop-blur-md">