Archilyzer · Source

archilyzer

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

commit 97ca5360750e22c8028e87b3fe2043e8ef99f9ed
parent 56c1789ba250dc175a56d64603e3a3f3583ffaea
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sat, 29 Aug 2026 22:24:54 -0400

results: the selection toolbar rides the bottom of the screen

A `sticky bottom-0` row that sits ABOVE the cards in flow is only ever
pushed up when its natural position would fall below the viewport, so
it just scrolled away forty cards up and the ask handoff was never one
tap away. It is `fixed` below lg now, with the results section reserving
80px so the last card and Load more are never underneath, and it carries
the Ask AI button the design asked for — a pinned row offering only
"select more" and "undo" would not be worth pinning.

Above lg, and with nothing selected, it is the same static row it was.

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

Diffstat:
Mcommon/components/SearchResults.tsx | 33+++++++++++++++++++++++++++++----
Mexport/e2e/responsive.spec.ts | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 81 insertions(+), 4 deletions(-)

diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx @@ -105,8 +105,15 @@ export default function SearchResults() { const wide = useMediaQuery("(min-width: 1024px)"); const [chartOptionsOpen, setChartOptionsOpen] = useState<boolean | null>(null); + // Room for the fixed selection toolbar: without it the last card, "Load more + // results" and the footer sit underneath it and cannot be reached. + const selectionBar = view !== "chart" && resultGroups.length > 0 && selectedCount > 0; + return ( - <section data-testid="results-section"> + <section + data-testid="results-section" + className={selectionBar ? "max-lg:pb-20" : undefined} + > {/* Summary on one line, controls on the next below sm: at 390px the count, the two view tabs and Copy for AI cannot share a row. */} <div className="mb-2 flex flex-col gap-2 sm:flex-row sm:items-center sm:gap-3"> @@ -184,11 +191,16 @@ export default function SearchResults() { <div data-testid="selection-toolbar" // With a selection the toolbar is the handoff to the chat, so below lg - // it rides the bottom of the screen instead of scrolling away 40 - // cards up. `pr-48` keeps it out from under the w-44 mini player. + // it rides the bottom of the SCREEN — `fixed`, not `sticky`: this row + // sits ABOVE the cards in flow, and a `sticky bottom-0` element is + // only pushed up when its natural position would fall BELOW the + // viewport, so it simply scrolled away 40 cards up and the handoff was + // never one tap away. The section below compensates with bottom + // padding so nothing ends up underneath it. `pr-48` keeps the controls + // clear of the w-44 mini player. className={`mb-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground ${ selectedCount > 0 - ? "max-lg:sticky max-lg:bottom-0 max-lg:z-10 max-lg:-mx-4 max-lg:border-t max-lg:border-border max-lg:bg-background/95 max-lg:px-4 max-lg:py-2 max-lg:pr-48 max-lg:backdrop-blur-sm max-lg:pb-[calc(0.5rem+env(safe-area-inset-bottom,0px))]" + ? "max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:mb-0 max-lg:z-10 max-lg:border-t max-lg:border-border max-lg:bg-background/95 max-lg:px-4 max-lg:py-2 max-lg:pr-48 max-lg:backdrop-blur-sm max-lg:pb-[calc(0.5rem+env(safe-area-inset-bottom,0px))]" : "" }`} > @@ -197,6 +209,19 @@ export default function SearchResults() { ? `${selectedCount} selected for AI` : "Select videos to ask AI about a subset"} </span> + {/* The handoff itself. Without it the pinned row offers only "select + more" and "undo", and the reason for pinning it goes away. The + per-card Ask replaces the selection with one video; this one keeps + what the reader built. */} + {selectedCount > 0 && ( + <button + type="button" + onClick={() => router.push("/ask/")} + className="inline-flex min-h-8 items-center rounded-md bg-primary px-2.5 py-1 font-medium text-primary-foreground transition-colors hover:bg-brand-strong" + > + Ask AI + </button> + )} <button type="button" onClick={selectAllResults} diff --git a/export/e2e/responsive.spec.ts b/export/e2e/responsive.spec.ts @@ -132,6 +132,58 @@ test.describe("phone layout", () => { ).toBeInViewport(); }); + test("the selection toolbar rides the bottom of the screen while a selection exists", async ({ + page, + }) => { + await page.goto("/"); + const toolbar = page.getByTestId("selection-toolbar"); + // Nothing selected: it is an ordinary row above the cards, as the 1440 + // specs see it. + await expect(toolbar).toContainText("Select videos to ask AI about a subset"); + expect(await toolbar.evaluate((el) => getComputedStyle(el).position)).toBe( + "static", + ); + + await page + .getByRole("checkbox", { name: /^Select "Transcript only/ }) + .click(); + await expect(toolbar).toContainText("1 selected for AI"); + + // Scroll to the end of the results — the handoff must still be one tap + // away rather than forty cards back up. + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + expect(await toolbar.evaluate((el) => getComputedStyle(el).position)).toBe( + "fixed", + ); + await expect(toolbar).toBeInViewport(); + await expect(toolbar.getByRole("button", { name: "Ask AI" })).toBeInViewport(); + await expect(toolbar.getByRole("button", { name: "Clear" })).toBeInViewport(); + + const geo = await page.evaluate(() => { + const bar = document + .querySelector('[data-testid="selection-toolbar"]')! + .getBoundingClientRect(); + const cards = document.querySelectorAll("[data-card-header]"); + const last = cards[cards.length - 1]!.getBoundingClientRect(); + return { + barBottom: Math.round(bar.bottom), + barTop: Math.round(bar.top), + lastCardBottom: Math.round(last.bottom), + vh: window.innerHeight, + }; + }); + // Pinned to the bottom edge, not merely somewhere on screen… + expect(Math.abs(geo.barBottom - geo.vh)).toBeLessThanOrEqual(2); + // …and nothing is stranded underneath it. + expect(geo.lastCardBottom).toBeLessThanOrEqual(geo.barTop); + + await toolbar.getByRole("button", { name: "Clear" }).click(); + await expect(toolbar).toContainText("Select videos to ask AI about a subset"); + expect(await toolbar.evaluate((el) => getComputedStyle(el).position)).toBe( + "static", + ); + }); + test("the composer stays on screen once the chat has an answer", async ({ page, }) => {