commit eec2f36df6d2b02f6b17ff6ee36315efcbd8b1f1
parent 70d18eb9a9c87a7161156908ac4b11e278725851
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 29 Sep 2026 22:20:50 -0400
Merge r14/first-search (release 14 slice S1) — the search page, and the hub's, shows a clear screen until the first Search: the bar, the intro, the hint and the footer; a link carrying a query or a filter runs on load; a stored query stays held; reviewed SHIP
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
15 files changed, 625 insertions(+), 43 deletions(-)
diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx
@@ -30,6 +30,7 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
groupStates,
queryDirty,
filtersDirty,
+ searchedThisPageLife,
handleResetLayers,
layersResetDisabled,
hasSubs,
@@ -79,6 +80,11 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
draftTags,
]);
+ // The line under the bar says what to do: after an edit that is not applied
+ // yet, and before the first Search of the page life, when the results area
+ // is empty and waits for it.
+ const promptApply = queryDirty || filtersDirty || !searchedThisPageLife;
+
const submit = (
<Button
type="submit"
@@ -220,12 +226,12 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
{/* One status line under the bar instead of two hints competing for room
inside it — and a SIBLING of the form, so the pinned block is never
more than the input row plus the chips. */}
- {(queryDirty || filtersDirty || hasSubs) && (
+ {(promptApply || hasSubs) && (
<p className="flex flex-wrap items-center gap-x-3 gap-y-0.5 text-xs">
{/* The site's accent, not the warning hue: an unapplied edit is
the next step, not a fault. `--brand` clears 4.5:1 on every
base's page ground (lib/brand.ts MIN_ACCENT_CONTRAST). */}
- {(queryDirty || filtersDirty) && (
+ {promptApply && (
<span className="text-brand">
Press Enter or click Search to apply
</span>
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -55,6 +55,7 @@ const CARD_HIT_CAP = 8;
export default function SearchResults() {
const {
+ searchedThisPageLife,
hasActiveQuery,
resultGroups,
totalHits,
@@ -112,6 +113,13 @@ export default function SearchResults() {
// results" and the footer sit underneath it and cannot be reached.
const selectionBar = view !== "chart" && resultGroups.length > 0 && selectedCount > 0;
+ // A clear screen until the visitor asks: no count, no controls, no chart and
+ // no listing — the bar, the page's intro and the footer, with the bar's
+ // "Press Enter or click Search" line saying what to do. An empty Search shows
+ // every video; a link with a query or a filter shows its results on load.
+ // `resultGroups` is still computed underneath.
+ if (!searchedThisPageLife) return null;
+
return (
<section
data-testid="results-section"
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -52,6 +52,7 @@ import {
} from "../lib/availability";
import { useUrlParams, writeUrlParams } from "./urlState";
import {
+ FILTER_URL_KEYS_V1,
buildShareSearchParams,
hasShareV1,
parseShareV1,
@@ -197,13 +198,23 @@ export type GroundingMode = "search" | "selection";
// export-filter parser.
const SELECTION_KEY = "ytdlp-tb:selection";
-// Whether a search has run in this page life: a commit, a profile load, or a
-// query arriving on the URL. Module state, so it survives client-side
-// navigation and resets on a reload or a new visit. A stored query is held
-// (see `runHeld`) only on the page life's FIRST load — the hub mounts a fresh
-// provider per route, and `/` → `/ask` must keep grounding in the search the
-// visitor just ran there.
+// Two facts about this page life, each with one job. Module state, so both
+// survive client-side navigation and reset on a reload or a new visit.
+//
+// `ranThisPageLife`: a search has run — a commit (Search, Enter, a Filters
+// Apply), a profile load, or an active query arriving on the URL. A stored
+// query is held (see `runHeld`) while it is unset: on the page life's first
+// load, and on any later mount while nothing has run. Once it is set a later
+// mount runs the stored query — the hub mounts a fresh provider per route, and
+// `/` → `/ask` must keep grounding in the search the visitor just ran there. A
+// filter-only link does not set it: it shows its results, but it ran no query.
+//
+// `askedThisPageLife`: the visitor has asked — a search ran, or a link carries
+// a query or a filter (`urlAsks`). The results area shows nothing until it is
+// set (`searchedThisPageLife`, its reactive copy): until the visitor asks, the
+// page is the bar, the intro and the footer.
let ranThisPageLife = false;
+let askedThisPageLife = false;
function loadSelection(): string[] {
if (typeof window === "undefined") return [];
@@ -257,6 +268,22 @@ function urlHasAnyFilterParam(): boolean {
return FILTER_URL_KEYS.some((k) => p.has(k));
}
+// A link that carries a query (`qt`, legacy `q` / `m`) or a filter (`tg`, the
+// share-link keys, the legacy filter keys) is the visitor asking: it shows its
+// results on load, as a Search would (`askedThisPageLife`). A video (`v`, `t`)
+// or a chart's shape (`view`, `cs`) alone is not a search.
+const ASKING_URL_KEYS: readonly string[] = [
+ "qt",
+ "q",
+ "tg",
+ ...FILTER_URL_KEYS,
+ ...FILTER_URL_KEYS_V1,
+];
+
+function urlAsks(params: URLSearchParams): boolean {
+ return ASKING_URL_KEYS.some((k) => params.has(k));
+}
+
// Resolve a stored snapshot's per-channel deltas (plus group defaults) into
// the concrete set of EXCLUDED channel names.
function snapshotToExcluded(
@@ -457,18 +484,14 @@ function useSearchSessionState() {
const [view, setView] = useState<"results" | "chart">("results");
const [chartShape, setChartShape] = useState<ChartShape | null>(null);
- // Tracks whether the user has committed at least once this session. Used
- // by the placeholder copy below — until the user has searched, we show a
- // "tip" hint rather than the "no matching videos" empty state.
- const [, setSearchExecuted] = useState<boolean>(() => {
- if (typeof window === "undefined") return true;
- const params = new URLSearchParams(window.location.search);
- return !(
- params.has("v") &&
- !params.has("qt") &&
- (params.get("q") ?? "") === ""
- );
- });
+ // The reactive copy of `askedThisPageLife`, which stays the source of truth
+ // across remounts: a mount later in the page life starts from it, and every
+ // place that sets it sets this too. False until the visitor asks, and the
+ // results area renders nothing until then. False on the server, where the
+ // module variable is never set, so the first client render matches.
+ const [searchedThisPageLife, setSearchedThisPageLife] = useState(
+ () => askedThisPageLife,
+ );
// Defer rendering of the QueryBuilder to the client. The builder's leaf
// IDs come from a module-scoped counter that's necessarily out of sync
@@ -809,8 +832,9 @@ function useSearchSessionState() {
[committedTags, postScopeSlugs],
);
- // False while a restored query is held (see `runHeld`): the results area
- // shows the browse listing under the restored filters, and nothing fetches.
+ // False while a restored query is held (see `runHeld`): nothing fetches, and
+ // the results area is empty until the visitor asks — or, when a filter-only
+ // link asked, it lists every video under the link's filters.
const hasActiveQuery = useMemo(
() => !runHeld && isNodeActive(committedRoot),
[committedRoot, runHeld],
@@ -1273,9 +1297,13 @@ function useSearchSessionState() {
setDraftRoot(resolvedRoot);
setCommittedRoot(resolvedRoot);
if (holdRestored) setRunHeld(true);
- else setSearchExecuted(true);
- if (rootFromUrl && isNodeActive(rootFromUrl)) ranThisPageLife = true;
}
+ // After `holdRestored` is decided. Only an active URL query counts as a
+ // run; a filter-only link asks (its results show) without releasing a
+ // stored query on this mount or a later one.
+ if (rootFromUrl && isNodeActive(rootFromUrl)) ranThisPageLife = true;
+ if (ranThisPageLife || urlAsks(params)) askedThisPageLife = true;
+ setSearchedThisPageLife(askedThisPageLife);
setDraftExcludedChannels(initialExcluded);
setDraftNov(initialNov);
@@ -1388,9 +1416,10 @@ function useSearchSessionState() {
}, [persistUiCollapse]);
const commitSearch = () => {
- setSearchExecuted(true);
setRunHeld(false);
ranThisPageLife = true;
+ askedThisPageLife = true;
+ setSearchedThisPageLife(true);
setCommittedRoot(draftRoot);
const nextSnapshot = buildDraftSnapshot();
const current = loadStoredState() ?? emptyStoredState();
@@ -1455,6 +1484,8 @@ function useSearchSessionState() {
// Loading a profile commits it, which runs it — as it always has.
setRunHeld(false);
ranThisPageLife = true;
+ askedThisPageLife = true;
+ setSearchedThisPageLife(true);
applyDraftSnapshot(snapshot);
const excluded = snapshotToExcluded(
snapshot,
@@ -2040,6 +2071,9 @@ function useSearchSessionState() {
hitBatchSize,
setHitLimit,
// ── Results ──
+ // False until the visitor asks in this page life; nothing in the results
+ // area renders until then (see `askedThisPageLife`).
+ searchedThisPageLife,
hasActiveQuery,
resultGroups,
totalHits,
diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md
@@ -7,6 +7,7 @@
- **Two grounds, Light and Dark, and each site in its own accent.** The third ground, the warm paper one, is gone: the header's toggle cycles System, Light and Dark. A reader who had chosen it gets Light, before the page first paints and with no other ground on the way, and the stored choice becomes Light (the old paper theme's `archive` + `light` too). The theme menu's accent picker is gone from the header and the slide-out menu: every page wears the site's own accent (`site.json` `accent`), and a reader's stored pick from before is not read and is left in storage. Needs a rebuild and deploy of each site.
- **The header carries the operator's social links and one theme toggle, keeps the site's name on a small screen, and links to the Archilyzer home in place of the sites menu.** Every site's header and the hub's end with the social icons (the site's `socialLinks`, else `settings.json`'s) followed by the theme toggle, all 36 px keys (44 px on a touch screen) with a focus ring. From 520 px wide the header shows every link, up to four (with more, the ones marked **Keep in header on small screens** first, then the last of the rest); below 520 px it shows only the marked ones (none marked → none) and keeps the site's name beside them. The switch is 32.5rem, so at a larger text size it comes later. With one marked link, every current site's name shows in full from 360 px wide on a touch screen. The footer keeps every link, in the same keys (its icons were 20 px and turned the accent on hover; they now turn the text colour), and wraps them rather than widen the page. The **Sites** dropdown and the **Hub** link are gone from the header and the slide-out menu: in their place a link, **Archilyzer**, goes to the Archilyzer home's Official Instances, in the same tab (not on the hub, which lists them itself). **Changelog** moved from the header and the menu to the footer, after Use with AI. The nav and the Archilyzer link are inline from 1024 px wide; below that they are in the slide-out menu, which now holds only them. Only as last resorts, for a very long name on a phone, does the name drop (its mark stays; the same before and after the page's font has loaded, and never with its last letter cut off) and do the icons scroll sideways in their own box. A site's `hubUrl` still loads and is no longer shown. Needs a rebuild and deploy of each site.
- **An unlisted site is not in the hub or in another site's footer.** The hub's members (`hub-sites.json`), and so its federated search, `corpus.json` and `llms.txt`, leave out a site whose `listed` is `false`; the hub's instance figures count none of the channels only it carries; and no other site's footer links it, even from a featured group. The unlisted site's own pages are unchanged.
+- **A clear screen until the first Search.** A plain visit to a site's search page, and to the hub's, shows the search bar, the page's intro and the footer: no count, no listing and no results controls, and the line under the bar, "Press Enter or click Search to apply", says what to do. Search with the box empty lists every video, as before. A link that carries a query or a filter (`qt=`, `q=`, `tg=`, a share link, the older filter keys) still shows its results on load. Within one visit the results stay: going to Ask AI or another page and coming back keeps them. A reload starts over, and shows results at once only when the address carries a query or a filter. A query restored from the last visit waits in the box until Search, over the clear screen or under a filter link's results, and it still waits after another page and Back. Needs a rebuild and deploy of each site and the hub.
## [0.10.0] - 2026-09-28
- **A video whose recheck failed shows as possibly missing rather than available.** When a video drops out of its channel's listing it is marked "Missing?" until a recheck says why. A recheck that could not reach the video — a blocked request or a network error — used to clear the mark as if the video had been found. It now leaves "Missing?" in place until a recheck actually reaches the video. Needs a rebuild and deploy of every export site.
diff --git a/export/e2e-hub/federated-search.spec.ts b/export/e2e-hub/federated-search.spec.ts
@@ -4,6 +4,7 @@ import {
LIVE_CHAT_MISSING,
NO_ARCHIVES_IN_SCOPE,
} from "../app/ask/hubScopeCopy";
+import { showAll } from "../e2e/helpers";
// The hub's federated search, per archive: two official members (hub-sites.json)
// served by route mocks WITH CORS, each with one video. Proves the scope chips
@@ -26,6 +27,10 @@ import {
// later; a member whose live chat cannot be read says so on its chip
// (LIVE_CHAT_MISSING), with a Retry for the live chat alone that keeps its
// videos in the search while it runs.
+// Release 14 (S1): the results — the listing and the "N of M archives
+// answered" line with it — show only after the first Search of the page life,
+// so a spec that reads them presses Search first (`showAll`); the chips carry
+// each archive's state before that.
const ORIGIN_A = "http://localhost:4598";
const ORIGIN_B = "http://localhost:4599";
@@ -199,6 +204,14 @@ test.describe("hub federated search — scope, per-archive state, attribution",
"aria-pressed",
"true",
);
+ // Both archives are in, and nothing has been asked: no results area yet,
+ // and the bar says what to do.
+ await expect(page.getByTestId("results-summary")).toHaveCount(0);
+ await expect(resultFrom(page, A)).toHaveCount(0);
+ await expect(resultFrom(page, B)).toHaveCount(0);
+ await expect(page.getByText("Press Enter or click Search to apply")).toBeVisible();
+ await showAll(page);
+ await expect(page.getByTestId("results-summary")).toHaveText("All videos (2)");
// Browse listing: one card per archive, each naming its source in text.
const b = resultFrom(page, B);
await expect(b).toHaveCount(1);
@@ -216,6 +229,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
}) => {
const mocks = await setup(page);
await page.goto("/");
+ await showAll(page);
await expect(resultFrom(page, B)).toHaveCount(1);
await expect(resultFrom(page, A)).toHaveCount(1);
@@ -234,6 +248,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
await page.reload();
await expect(chip(page, A)).toHaveAttribute("data-status", "ready");
await expect(chip(page, B)).toHaveAttribute("data-status", "off");
+ await showAll(page);
await expect(resultFrom(page, A)).toHaveCount(1);
await expect(resultFrom(page, B)).toHaveCount(0);
expect(mocks.b.requests).toEqual([]);
@@ -251,6 +266,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
const mocks = await setup(page);
mocks.b.pages = "abort";
await page.goto("/");
+ await showAll(page);
await expect(chip(page, B)).toHaveAttribute("data-status", "failed");
await expect(chip(page, B)).toContainText("failed");
@@ -314,6 +330,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
mocks.b.pages = "abort";
mocks.c!.pages = "hold";
await page.goto("/");
+ await showAll(page);
await expect(chip(page, B)).toHaveAttribute("data-status", "failed");
await expect(chip(page, C)).toHaveAttribute("data-status", "loading");
@@ -335,6 +352,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
const mocks = await setup(page);
mocks.b.subs = "missing";
await page.goto("/");
+ await showAll(page);
// Ready means every feed of the archive is in, subs included. A 404 is an
// empty subs manifest, at once. It used to be an error, retried after ~1 s,
@@ -354,6 +372,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
// transcripts first) lists B then A. Neither sets an accent.
await setup(page, [A, B], { summary: [B, A] });
await page.goto("/");
+ await showAll(page);
const cards = page.getByTestId("shelf-spine");
await expect(cards).toHaveCount(2);
@@ -521,6 +540,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
const mocks = await setup(page);
mocks.b.subs = "error";
await page.goto("/");
+ await showAll(page);
// Live chat is the auxiliary layer: after one retry the error counts as
// settled, so Origin B is ready — its videos searched, without live chat —
@@ -544,6 +564,7 @@ test.describe("hub federated search — scope, per-archive state, attribution",
const mocks = await setup(page);
mocks.b.subs = "error";
await page.goto("/");
+ await showAll(page);
await expect(chip(page, B)).toHaveAttribute("data-status", "ready");
await expect(resultFrom(page, B)).toHaveCount(1);
diff --git a/export/e2e/browse-all.spec.ts b/export/e2e/browse-all.spec.ts
@@ -5,11 +5,12 @@ import {
VIDEO_CHAT_SMALL,
VIDEO_TRANSCRIPT_ONLY,
} from "./fixtures/data";
-import { installRoutes } from "./helpers";
+import { installRoutes, showAll } from "./helpers";
// Browse-all e2e: with no search query active the results section lists every
-// video passing the committed filters (no hits). Typing a query narrows it;
-// clearing it returns to the full browse list.
+// video passing the committed filters (no hits) — once the visitor has pressed
+// Search; until then the page shows no results area at all (first-search.spec).
+// Typing a query narrows it; clearing it returns to the full browse list.
const TRANSCRIPT_ONLY_SLUG = `${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`;
const CHAT_SMALL_SLUG = `${CHANNEL_SLUG}/${VIDEO_CHAT_SMALL}`;
@@ -31,8 +32,20 @@ test.describe("browse all (no query)", () => {
await installRoutes(page);
});
- test("lists every video on load with no query", async ({ page }) => {
+ test("a clear screen on load, then every video on an empty Search", async ({
+ page,
+ }) => {
await page.goto("/");
+ await page.getByTestId("query-builder").waitFor();
+ // Give the session time to hydrate (it waits for the manifest) and to
+ // show a listing if it were going to.
+ await page.waitForTimeout(1_000);
+ await expect(page.getByTestId("results-section")).toHaveCount(0);
+ await expect(page.getByTestId("results-summary")).toHaveCount(0);
+ await expect(page.getByTestId("browse-hint")).toHaveCount(0);
+ await expect(page.locator("[data-card-header]")).toHaveCount(0);
+
+ await page.getByTestId("search-submit").click();
await expect(page.getByTestId("results-section")).toBeVisible();
await expect(page.getByTestId("results-summary")).toHaveText(
"All videos (3)",
@@ -43,6 +56,7 @@ test.describe("browse all (no query)", () => {
test("filters apply to the browse list on Search", async ({ page }) => {
await page.goto("/");
+ await showAll(page);
await expectResultSlugs(page, ALL_SLUGS);
// All fixture videos are non-livestream, so unchecking "Videos" excludes
@@ -62,6 +76,7 @@ test.describe("browse all (no query)", () => {
page,
}) => {
await page.goto("/");
+ await showAll(page);
await expectResultSlugs(page, ALL_SLUGS);
const leafInput = page.locator('input[data-testid^="leaf-query-"]').first();
diff --git a/export/e2e/charts.spec.ts b/export/e2e/charts.spec.ts
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
-import { installChartRoutes, urlParams } from "./helpers";
+import { installChartRoutes, showAll, urlParams } from "./helpers";
import { over, painted, rgbOf } from "../../common/testing/chartPixels";
// Charts are now a VIEW MODE of the search page: a "Results | Chart" toggle
@@ -74,6 +74,7 @@ test.describe("charts (search view mode)", () => {
test("Chart toggle with no query plots metadata", async ({ page }) => {
await page.goto("/");
+ await showAll(page);
await expect(page.getByTestId("results-summary")).toContainText("All videos");
await openChart(page);
await expect(page.locator(".recharts-surface")).toBeVisible({
@@ -106,6 +107,7 @@ test.describe("charts (search view mode)", () => {
page,
}) => {
await page.goto("/");
+ await showAll(page);
await expect(page.getByTestId("results-summary")).toContainText("All videos");
await openChart(page);
const opts = page.getByTestId("chart-options");
@@ -122,6 +124,7 @@ test.describe("charts (search view mode)", () => {
page,
}) => {
await page.goto("/");
+ await showAll(page);
await expect(page.getByTestId("results-summary")).toContainText("All videos");
await openChart(page);
const opts = page.getByTestId("chart-options");
@@ -150,6 +153,7 @@ test.describe("charts (search view mode)", () => {
page,
}) => {
await page.goto("/");
+ await showAll(page);
await expect(page.getByTestId("results-summary")).toContainText("All videos");
await openChart(page);
await expect(page.locator(".recharts-surface")).toBeVisible({
@@ -231,6 +235,7 @@ test.describe("charts (search view mode)", () => {
page,
}) => {
await page.goto("/");
+ await showAll(page);
await expect(page.getByTestId("results-summary")).toContainText("All videos");
await openChart(page);
const opts = page.getByTestId("chart-options");
@@ -272,6 +277,7 @@ test.describe("charts (search view mode)", () => {
test(`${width} px: a stacked area paints its true total and every band`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 });
await page.goto("/");
+ await showAll(page);
await expect(page.getByTestId("results-summary")).toContainText("All videos");
await openChart(page);
const opts = page.getByTestId("chart-options");
diff --git a/export/e2e/first-search.spec.ts b/export/e2e/first-search.spec.ts
@@ -0,0 +1,194 @@
+import { expect, test, type Page } from "@playwright/test";
+import {
+ CHANNEL,
+ CHANNEL_SLUG,
+ TAG_TOPIC,
+ VIDEO_CHAT_LARGE,
+ VIDEO_CHAT_SMALL,
+ VIDEO_TRANSCRIPT_ONLY,
+} from "./fixtures/data";
+import { installRoutes, installTagRoutes, openFilters, showAll } from "./helpers";
+
+// Release 14 (S1): a clear screen until the first Search. A plain visit shows
+// the search bar, the page's intro and the footer — no count, no controls, no
+// listing — and the bar's line says what to do. The first Search of the page
+// life (Enter, the button, a Filters Apply, a profile load) shows the results;
+// an empty one lists every video, exactly as before. A link that carries a
+// query (`qt=`) or a filter (`tg=`, `ch=`, a share link) is the visitor asking
+// and shows its results on load. The gate is once per page life: client-side
+// navigation keeps the listing, a reload clears it.
+
+const STORAGE_KEY = "ytdlp-tb:export-filters";
+const HINT = "Press Enter or click Search to apply";
+
+const ALPHA_TREE = { k: "g", o: "AND", c: [{ k: "l", q: "alpha", s: "transcripts" }] };
+const qt = (tree: unknown) => encodeURIComponent(JSON.stringify(tree));
+
+const leafInput = (page: Page) =>
+ page.locator('input[data-testid^="leaf-query-"]').first();
+
+const card = (page: Page, id: string) =>
+ page.locator(`[data-result-slug="${CHANNEL_SLUG}/${id}"]`);
+
+// The bar has mounted; then give the session time to hydrate (it waits for the
+// manifest) and to show a listing, were it going to.
+async function settle(page: Page) {
+ await page.getByTestId("query-builder").waitFor();
+ await page.waitForTimeout(1_000);
+}
+
+async function expectClearScreen(page: Page) {
+ await expect(page.getByTestId("results-section")).toHaveCount(0);
+ await expect(page.getByTestId("results-summary")).toHaveCount(0);
+ await expect(page.getByTestId("view-toggle")).toHaveCount(0);
+ await expect(page.getByTestId("selection-toolbar")).toHaveCount(0);
+ await expect(page.getByTestId("browse-hint")).toHaveCount(0);
+ await expect(page.locator("[data-card-header]")).toHaveCount(0);
+ await expect(page.getByText(HINT)).toBeVisible();
+}
+
+async function expectAllVideos(page: Page) {
+ await expect(page.getByTestId("results-summary")).toHaveText("All videos (3)");
+ await expect(page.getByTestId("browse-hint")).toBeVisible();
+ await expect(page.locator("[data-card-header]")).toHaveCount(3);
+}
+
+test.describe("a clear screen until the first Search", () => {
+ test.beforeEach(async ({ page }) => {
+ await installRoutes(page);
+ });
+
+ for (const { width, height } of [
+ { width: 1280, height: 800 },
+ { width: 390, height: 844 },
+ ]) {
+ test(`${width}×${height}: on load, the bar, the intro and the footer — no results`, async ({
+ page,
+ }) => {
+ await page.setViewportSize({ width, height });
+ await page.goto("/");
+ await settle(page);
+ await expectClearScreen(page);
+ // The page's intro stays: the transcript count.
+ await expect(page.getByRole("heading", { level: 1 })).toContainText("transcripts");
+ // The footer is on the first screen, whole.
+ await expect(page.getByRole("contentinfo")).toBeInViewport({ ratio: 1 });
+ });
+ }
+
+ test("Enter on an empty box shows every video", async ({ page }) => {
+ await page.goto("/");
+ await settle(page);
+ await expectClearScreen(page);
+ await leafInput(page).press("Enter");
+ await expectAllVideos(page);
+ // The line has done its job.
+ await expect(page.getByText(HINT)).toHaveCount(0);
+ });
+
+ test("the Search button shows every video", async ({ page }) => {
+ await page.goto("/");
+ await settle(page);
+ await page.getByTestId("search-submit").click();
+ await expectAllVideos(page);
+ });
+
+ test("changing a filter before the first Search does not show the listing", async ({
+ page,
+ }) => {
+ await page.goto("/");
+ await settle(page);
+ await openFilters(page);
+ await page.getByRole("checkbox", { name: "Videos" }).uncheck();
+ await expectClearScreen(page);
+ await page.getByTestId("search-submit").click();
+ await expect(page.getByTestId("results-summary")).toHaveText("All videos (0)");
+ });
+
+ test("/ → /ask → / keeps the listing", async ({ page }) => {
+ await page.goto("/");
+ await showAll(page);
+ await expectAllVideos(page);
+ const nav = page.getByTestId("workspace-nav");
+ await nav.getByRole("link", { name: "Chat" }).click();
+ // A dev server compiles /ask on its first visit.
+ await expect(page).toHaveURL(/\/ask\/?$/, { timeout: 20_000 });
+ await expect(page.getByPlaceholder(/Ask about the transcripts/)).toBeVisible();
+ await nav.getByRole("link", { name: "Search" }).click();
+ await expect(page).not.toHaveURL(/\/ask/);
+ await expectAllVideos(page);
+ });
+
+ test("leaving the search page and coming Back keeps the listing", async ({ page }) => {
+ await page.goto("/");
+ await showAll(page);
+ await expectAllVideos(page);
+ // A route outside the workspace: the search session unmounts with it.
+ await page
+ .getByRole("banner")
+ .getByRole("link", { name: "Use with AI", exact: true })
+ .click();
+ await expect(page).toHaveURL(/\/use-with-ai\/?$/);
+ await page.goBack();
+ await expect(page).not.toHaveURL(/use-with-ai/);
+ await expectAllVideos(page);
+ });
+
+ test("a reload clears it", async ({ page }) => {
+ await page.goto("/");
+ await showAll(page);
+ await expectAllVideos(page);
+ await page.reload();
+ await settle(page);
+ await expectClearScreen(page);
+ });
+
+ test("a qt= link shows its results on load", async ({ page }) => {
+ await page.goto(`/?qt=${qt(ALPHA_TREE)}`);
+ await expect(page.getByTestId("results-summary")).toContainText("Matching videos");
+ await expect(page.locator("[data-card-header]")).toHaveCount(3);
+ await expect(page.getByText(HINT)).toHaveCount(0);
+ });
+
+ test("a filter-only link shows its results on load", async ({ page }) => {
+ await installTagRoutes(page);
+ await page.goto(`/?tg=${TAG_TOPIC}`);
+ await expect(page.getByTestId("results-summary")).toHaveText("All videos (1)");
+ await expect(card(page, VIDEO_CHAT_LARGE)).toBeVisible();
+ await expect(card(page, VIDEO_CHAT_SMALL)).toHaveCount(0);
+
+ // A legacy channel link: this one leaves the only channel out.
+ await page.goto(`/?ch=${encodeURIComponent(CHANNEL)}`);
+ await expect(page.getByTestId("results-summary")).toHaveText("All videos (0)");
+ await expect(page.getByText("No videos match the current filters.")).toBeVisible();
+ });
+
+ test("a restored query shows the clear screen and the filled form", async ({ page }) => {
+ await page.goto("/");
+ await page.evaluate(
+ ({ key, value }) => window.localStorage.setItem(key, value),
+ {
+ key: STORAGE_KEY,
+ value: JSON.stringify({
+ v: 1,
+ working: {
+ channels: { included: [], excluded: [] },
+ nol: true,
+ query: JSON.stringify(ALPHA_TREE),
+ },
+ profiles: {},
+ activeProfileName: null,
+ }),
+ },
+ );
+ await page.goto("/");
+ await expect(leafInput(page)).toHaveValue("alpha");
+ await expect(page.getByTestId("search-submit")).toHaveAttribute("data-dirty", "true");
+ await settle(page);
+ await expectClearScreen(page);
+ // The visitor runs it.
+ await leafInput(page).press("Enter");
+ await expect(page.getByTestId("results-summary")).toContainText("Matching videos");
+ await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toBeVisible();
+ });
+});
diff --git a/export/e2e/helpers.ts b/export/e2e/helpers.ts
@@ -156,6 +156,17 @@ export async function openFilters(page: Page) {
await profiles.waitFor();
}
+// The search page shows nothing under the bar until the first Search of the
+// page life (a link that carries a query or a filter shows its results on
+// load). A spec that wants the listing of every video presses Search, as a
+// visitor does. It waits for the query builder first, which renders only once
+// the bar has mounted, so the click is never lost to the pre-hydration window.
+export async function showAll(page: Page) {
+ await page.getByTestId("query-builder").waitFor();
+ await page.getByTestId("search-submit").click();
+ await page.getByTestId("results-summary").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/responsive.spec.ts b/export/e2e/responsive.spec.ts
@@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "./fixtures/data";
-import { expectModalOpen, installRoutes } from "./helpers";
+import { expectModalOpen, installRoutes, showAll } from "./helpers";
import { INSTANCES_URL } from "../../common/lib/project";
// The phone. Every other spec in this suite runs at the project's 1440×1200
@@ -109,6 +109,7 @@ test.describe("phone layout", () => {
page,
}) => {
await page.goto("/");
+ await showAll(page);
const summary = page.getByTestId("results-summary");
await expect(summary).toContainText("All videos (3)");
@@ -146,6 +147,7 @@ test.describe("phone layout", () => {
page,
}) => {
await page.goto("/");
+ await showAll(page);
const toolbar = page.getByTestId("selection-toolbar");
// Nothing selected: it is an ordinary row above the cards, as the 1440
// specs see it.
diff --git a/export/e2e/restore-no-refire.spec.ts b/export/e2e/restore-no-refire.spec.ts
@@ -1,5 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
-import { installRoutes } from "./helpers";
+import { TAG_TOPIC } from "./fixtures/data";
+import { installRoutes, installTagRoutes } from "./helpers";
// Release 8 slice E. The search session restored from localStorage loads the
// query and the filters but does NOT run the search on first load: a restored
@@ -7,6 +8,10 @@ import { installRoutes } from "./helpers";
// shards (up to 8 MB each on a cold device) for a search the visitor had not
// asked for this time. The visitor runs it — Search / Enter. A query on the
// URL (`qt=`) is a shared link, i.e. the visitor asking, and still runs.
+// Release 14: until then the page shows no results area at all — the held
+// query no longer sits over a browse listing. A link that carries only a
+// filter (`tg=`, `ch=`) shows its results but runs no query, so a stored one
+// stays held on that load AND on every later mount in the same visit.
const STORAGE_KEY = "ytdlp-tb:export-filters";
const SHARD = /\/transcripts\/[^/]+\/page-\d+\.json$/;
@@ -51,6 +56,24 @@ async function seedStoredSearch(page: Page) {
);
}
+// A route outside the workspace unmounts the search session; coming back
+// mounts it again in the same page life.
+async function leaveForUseWithAi(page: Page) {
+ await page
+ .getByRole("banner")
+ .getByRole("link", { name: "Use with AI", exact: true })
+ .click();
+ await expect(page).toHaveURL(/use-with-ai/, { timeout: 20_000 });
+}
+
+async function expectHeld(page: Page) {
+ await expect(leafInput(page)).toHaveValue("alpha");
+ await expect(page.getByTestId("search-submit")).toHaveAttribute(
+ "data-dirty",
+ "true",
+ );
+}
+
test.describe("restored search waits for the visitor", () => {
test.beforeEach(async ({ page }) => {
await installRoutes(page);
@@ -78,14 +101,14 @@ test.describe("restored search waits for the visitor", () => {
await expect(
page.getByText("Press Enter or click Search to apply"),
).toBeVisible();
- // The results area is the browse listing under the restored filters.
- await expect(page.getByTestId("results-summary")).toHaveText(
- "All videos (3)",
- );
- await expect(page.getByTestId("browse-hint")).toBeVisible();
// Give a would-be pipeline every chance to start.
await page.waitForTimeout(1_500);
expect(shards.n).toBe(0);
+ // Nothing has been asked in this page life, so there is no results area
+ // at all: no count, no listing under the restored filters.
+ await expect(page.getByTestId("results-summary")).toHaveCount(0);
+ await expect(page.getByTestId("browse-hint")).toHaveCount(0);
+ await expect(page.locator("[data-card-header]")).toHaveCount(0);
// The visitor runs it.
const fetched = page.waitForRequest(
@@ -122,4 +145,54 @@ test.describe("restored search waits for the visitor", () => {
"false",
);
});
+
+ test("a filter-only link shows its results and leaves a stored query held, then and after Back", async ({
+ page,
+ }) => {
+ await installTagRoutes(page);
+ const shards = countShards(page);
+ await seedStoredSearch(page);
+
+ shards.n = 0;
+ await page.goto(`/?tg=${TAG_TOPIC}`);
+ // The link asked: its results show, under its filter…
+ await expect(page.getByTestId("results-summary")).toHaveText("All videos (1)");
+ // …and the stored query is in the box, held.
+ await expectHeld(page);
+
+ await leaveForUseWithAi(page);
+ await page.goBack();
+ await expect(page).not.toHaveURL(/use-with-ai/);
+ // A second mount in the same visit: still held, still the listing.
+ await expectHeld(page);
+ await expect(page.getByTestId("results-summary")).toHaveText("All videos (1)");
+ await page.waitForTimeout(1_500);
+ expect(shards.n).toBe(0);
+ });
+
+ test("a filter-only link, then the header's Search link: the stored query stays held", async ({
+ page,
+ }) => {
+ const shards = countShards(page);
+ await seedStoredSearch(page);
+
+ shards.n = 0;
+ // Under a filter link the stored session is not read at all.
+ await page.goto(`/?ch=${encodeURIComponent("Nobody")}`);
+ await expect(page.getByTestId("results-summary")).toContainText("All videos");
+ await expect(leafInput(page)).toHaveValue("");
+
+ await leaveForUseWithAi(page);
+ await page
+ .getByRole("banner")
+ .getByRole("link", { name: "Search", exact: true })
+ .click();
+ await expect(page).not.toHaveURL(/use-with-ai/, { timeout: 20_000 });
+ // The plain search page reads the stored session: the query is held, and
+ // the listing shows because the visitor asked earlier in this visit.
+ await expectHeld(page);
+ await expect(page.getByTestId("results-summary")).toHaveText("All videos (3)");
+ await page.waitForTimeout(1_500);
+ expect(shards.n).toBe(0);
+ });
});
diff --git a/export/e2e/tag-chips.spec.ts b/export/e2e/tag-chips.spec.ts
@@ -1,5 +1,5 @@
import { expect, test, type Page } from "@playwright/test";
-import { installRoutes, installTagRoutes } from "./helpers";
+import { installRoutes, installTagRoutes, showAll } from "./helpers";
import {
CHANNEL_SLUG,
TAG_COLLAB,
@@ -146,6 +146,7 @@ test.describe("curated tag chips", () => {
test("a tagged card shows its tags; an untagged one shows none", async ({
page,
}) => {
+ await showAll(page);
await expect(card(page, VIDEO_CHAT_LARGE).getByTestId("card-tag")).toHaveCount(2);
await expect(
card(page, VIDEO_CHAT_LARGE).locator('[data-testid="card-tag"][data-tag-id="' + TAG_TOPIC + '"]'),
@@ -161,6 +162,7 @@ test.describe("curated tag chips", () => {
page,
}) => {
// All three videos before any tag filter.
+ await showAll(page);
await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toBeVisible();
await chip(page, TAG_TOPIC).click();
@@ -378,6 +380,7 @@ test.describe("curated tag chips", () => {
await installRoutes(page);
await page.goto("/");
await waitForHydration(page);
+ await showAll(page);
await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toBeVisible();
await expect(chipRow(page)).toHaveCount(0);
diff --git a/export/e2e/workspace-shell.spec.ts b/export/e2e/workspace-shell.spec.ts
@@ -5,7 +5,7 @@ import {
VIDEO_CHAT_SMALL,
VIDEO_TRANSCRIPT_ONLY,
} from "./fixtures/data";
-import { installRoutes } from "./helpers";
+import { installRoutes, showAll } from "./helpers";
// The unified workspace shell: `/` (results) and `/ask` (chat) share one layout,
// so the persistent search bar and its committed search survive navigation
@@ -61,9 +61,10 @@ test.describe("workspace shell", () => {
"aria-current",
"page",
);
- // Wait for the results pane to hydrate (browse mode lists every video) before
- // clicking — a client Link click lost to a pre-hydration window would leave us
- // stranded on `/`.
+ // Wait for the results pane to hydrate (an empty Search lists every video)
+ // before clicking — a client Link click lost to a pre-hydration window would
+ // leave us stranded on `/`.
+ await showAll(page);
await expect(page.locator("[data-card-header]").first()).toBeVisible();
await nav.getByRole("link", { name: "Chat" }).click();
await expect(page.getByPlaceholder(/Ask about the transcripts/)).toBeVisible();
diff --git a/plans/export-header-first-search.md b/plans/export-header-first-search.md
@@ -330,6 +330,16 @@ Owns `common/components/theme*` (`themeConfig.ts`, `ThemeProvider.tsx`, `ThemeSc
## Slice S1 — a clear screen until the first Search (branch `r14/first-search`)
+As built (2026-09-29, on the rulings of that day): S1 only, the summaries download not deferred
+(S2 stays a candidate). A link carrying only a filter (`tg=`, a share link, the legacy filter keys),
+like one carrying `qt=`/`q=`, runs on load and shows its results. It runs no query, so a stored
+one stays held (release 8), on that load and on later mounts in the visit: the gate and the hold
+are two page-life flags (review M1). Step 0: the hub renders
+`SearchResults` (`HubHome` → `TranscriptSearch`), so it has the clear screen and `e2e:hub` joined
+the gate; Back restores the listing (measured in `release-14.md`, "Slice S1, as shipped"). Beyond
+the four specs this section owns, the ones that read the listing at load were `responsive` (two
+tests), `tag-chips` (three) and `e2e-hub/federated-search` (eight).
+
Owns `common/components/{SearchSessionContext,SearchResults,SearchBar}.tsx`,
`export/app/(workspace)/WorkspaceView.tsx`, `export/e2e/{browse-all,workspace-shell,charts,
restore-no-refire}.spec.ts`, a NEW `export/e2e/first-search.spec.ts`, `export/e2e/helpers.ts`
diff --git a/plans/release-14.md b/plans/release-14.md
@@ -21,7 +21,7 @@ slice HP added to it on the operator's ruling of the same day. Rules:
| HP | `homepage/social-visible` | The homepage's social row and one theme toggle in the header at every width, the wordmark's text dropped first on a very small screen and the row scrolling only as the last resort, one shared `SocialLinks` component, larger keys with a focus ring; Changelog in the footer only; the instance cards' names as the site's wordmark; the social icon checked by an allowlist on save and at render; a sized SVG with no viewBox gets one; `featured` ("Show in header") on a social link | `common/components/{SocialLinks,SocialScroll,ThemeRadios,ThemeToggle,ThemeScript,ThemeProvider,Wordmark}.tsx` + `themeConfig.ts`, `common/bin/doctor.ts`, the growth chart, `common/lib/{socialSvg,socialLinks}.ts` + tests, `common/lib/settingsSchema.ts` (the social-link type, parser, docs; the normalizer moved to `socialSvg.ts`), `common/lib/normalizeSocialSvg.test.ts`, `common/lib/{settings,site,homepage}.ts` (the save errors), `common/lib/{homepageSummary,siteColor}.ts`, `homepage/app/components/{Header,Footer,ArchiveCards}.tsx`, `homepage/app/lib/{nav,summary}.ts`, `homepage/app/not-found.tsx`, `homepage/e2e/**` (the fixtures, `helpers.ts`, the new and the rewritten specs), `homepage/playwright.config.ts`, `export/app/components/{MobileMenu,Footer}.tsx` (the `ThemeRadios` swap; the footer's read path), `editor/app/components/SocialLinksField.tsx` + `socialLinksJson{,.test}.ts`, `editor/app/{settings,sites}/actions.ts` (the save errors), `editor/e2e/settings.spec.ts`, `SETTINGS.md`, `SITE.md` |
| Lows, chart gap, T1, H1 (H3 folded in), H2 | `r14/two-grounds-headers` | The final review's Lows; the charts' surface gap; two grounds and each site in its own accent; the export and hub headers carry the social row and the toggle as one group, with an Archilyzer link to the homepage's `#instances` in place of the sites dropdown and the hub link; Changelog to the footer; after its review, the narrow header keeps the name and shows only the marked links (every header) | `common/components/{ThemeProvider,ThemeScript,ThemeToggle,SocialScroll}.tsx` + `themeConfig.ts` (and the deleted `ThemeMenu`, `ThemeRadios`), `common/components/charts/{ChartView,CrossSiteChart,surfaceGap}`, `common/styles/tokens.css`, `common/lib/{brand,accent,siteColor,paths,project,socialSvg,siteSchema,settingsSchema}.ts` + tests, `scripts/next-build-trace.test.mjs`, `export/app/components/{Header,MobileMenu,Footer}.tsx` (and the deleted `SiblingSwitcher`), `export/app/{layout.tsx,globals.css,changelog/page.tsx,lib/brand.ts}`, `export/e2e{,-hub}/**` (the theme, header and branding specs), `export/playwright.config.ts`, `editor/app/{layout.tsx,globals.css,sites/components/SiteForm.tsx}`, `editor/e2e/theme.spec.ts`, `homepage/app/{page.tsx,layout.tsx,globals.css,lib/*,changelog/page.tsx,components/{Header,ArchiveGrowthChart,ArchiveCards}.tsx}`, `homepage/e2e/**`, `homepage/content/docs/operate.md`, `SETTINGS.md`, `SITE.md` |
| HS | `r14/hidden-sites` | Hidden sites: `site.json` `listed` (absent = listed). An unlisted site builds and deploys as before, and is left off the homepage (cards, chart, `/stats`), the hub (members, federated search, `corpus.json`, `llms.txt`), every other site's footer and the published id lists (`channel-sites.json`, the pooled `stats/`); the channels only it exposes count in no public total. One checkbox in the site form | `common/lib/{siteSchema,site,homepageSummary}.ts` + tests, `common/controller/{poolSummary,buildStats}.ts` + tests, `common/bin/{compose-homepage,compose-hub}.ts` + `compose-hub.test.ts`, `editor/app/sites/{actions.ts,components/SiteForm.tsx}`, `editor/e2e/{helpers.ts,sites-crud.spec.ts}`, `homepage/e2e/{fixture-summary.ts,unlisted-site.spec.ts}`, `SITE.md`, `plans/FACTS.md` (Naming hazards) |
-| S1 | per the plan | per the plan | per the plan |
+| S1 | `r14/first-search` | A clear screen until the first Search, on every site's search page and the hub's: no results area until the visitor asks (a Search, a profile load, or a link that carries a query or a filter), with the bar's "Press Enter or click Search to apply" line meanwhile; two page-life flags, the hold of release 8 unchanged and the gate new | `common/components/{SearchSessionContext,SearchResults,SearchBar}.tsx`, `export/e2e/first-search.spec.ts` (new), `export/e2e/{browse-all,workspace-shell,charts,restore-no-refire,responsive,tag-chips}.spec.ts`, `export/e2e/helpers.ts` (`showAll`), `export/e2e-hub/federated-search.spec.ts`, `export/CHANGELOG.md`, `plans/export-header-first-search.md` |
**Order:** HP → `r14/two-grounds-headers` → HS (`r14/hidden-sites`) and S1 (`r14/first-search`),
siblings. The shared files are the three changelogs' `[Unreleased]` sections and this record.
@@ -1222,6 +1222,194 @@ Counts (files holding the string / occurrences, `grep -rF`):
| An unlisted site's own footer still links its listed siblings | an unlisted site shows no related-sites footer |
| The checkbox sits after Public URL, with a hint naming what it removes | at the end of the form, or without a hint |
+### Slice S1, as shipped — a clear screen until the first Search (2026-09-29)
+
+Branch `r14/first-search` off `main` `99d4d76a` (`r14/two-grounds-headers` merged), worktree
+`~/Projects/plans-export-header-first-search` (block #4: export e2e 3420, hub e2e 3441, editor test
+3411), one Opus implementer. Scratch files `s1-*` in the job's `tmp`.
+
+The rulings, 2026-09-29, not re-opened:
+1. S1 only. The summaries download is not deferred; S2 stays a candidate.
+2. A link carrying only a filter (`tg=`, `fv=`, `ch=`), like one carrying `qt=`/`q=`, runs on
+ load and shows its results.
+
+The parent's directions for the build (the plan's S1 steps):
+- The gate is in `SearchResults`, which the hub mounts too, so the hub gets the same behaviour and
+ `e2e:hub` joins the gate.
+- The module-level `ranThisPageLife` feeds a reactive `searchedThisPageLife`; the dead
+ `searchExecuted` goes. As built after the review, the flag the view reads is a second module
+ flag, `askedThisPageLife`, and `ranThisPageLife` keeps `main`'s rule (review M1, below).
+- A URL with `qt`, `q` or any filter key counts as asked, at hydration.
+- `SearchResults` renders nothing until the flag is set; `resultGroups` is still computed. The
+ page's intro (the transcript count, the Welcome card) stays.
+- The bar's "Press Enter or click Search to apply" line also shows before the first Search.
+
+My own choices are under "Decisions the operator could overturn".
+
+| sha | what |
+|---|---|
+| `93d72a80` | `common:` the results area renders nothing until the visitor asks in this page life; the bar's line shows meanwhile; `searchedThisPageLife`; `searchExecuted` deleted |
+| `90043b78` | `export:` `first-search.spec.ts`; one `showAll` helper, pressed where the export and hub specs read the listing at load |
+| `dbdaa31b` | `plans:` this record; the plan's S1 as built; the export changelog |
+| `9b01068c` | `common:` two page-life flags, one job each; a filter-only link no longer releases a stored query (review M1); the held-query comment (L1) |
+| `41dc1b4b` | `export:` `restore-no-refire` covers a second mount after a filter-only link (M1) |
+| _this_ | `plans:` the review's fixes recorded (L3, M1, L2 left); the S1 row of the slices table and the Rollout's checks (L4) |
+
+- **What a plain visit shows**, on a site's `/` and on the hub's:
+ - the search bar with its line, "Press Enter or click Search to apply";
+ - the page's intro (on a site the transcript count and the Welcome card; on the hub the shelf,
+ the figures and the archive chips);
+ - the footer, whole on the first screen at 1280×800 and 390×844 (the spec asserts it).
+ - No count, no Results/Chart toggle, no Copy for AI, no selection toolbar, no chart, no listing,
+ no `browse-hint`, and on the hub no "N of M archives answered" line.
+- **What counts as the first Search:** Enter, the Search button, a Filters Apply (all through
+ `commitSearch`), a profile load or Revert (`applySnapshot`), and a URL that asks.
+ - An empty Search shows "All videos (N)" and the listing, as before.
+ - A filter changed before it shows nothing; the line was already there for an unapplied edit.
+- **A URL that asks** (`urlAsks`, `SearchSessionContext.tsx`): any of `qt`, `q`, `tg`, the legacy
+ filter keys (`ch nov nol naa nar nav nd nu m tk`) or the share-link keys
+ (`fv fc ft fa fav fk fdf fdt`).
+ - Presence counts, not a valid value: `?tg=Not%20An%20Id` shows every video, which is what
+ `tag-chips.spec` already asserts.
+ - A video (`v`, `t`), a chart's shape (`view`, `cs`), `re` and `vm` do not ask. The Share button
+ always writes `fv=`, so a shared chart does run.
+- **Two flags, one job each** (module state: they survive client-side navigation and reset on a
+ reload):
+ - `ranThisPageLife` is the hold, exactly as on `main` (release 8). It is set by a commit, a
+ profile load, or an active URL query. While it is unset, a stored query is held, on this
+ mount and on every later one.
+ - `askedThisPageLife` is the gate: `ranThisPageLife`, or a URL that asks. `searchedThisPageLife`
+ starts from it and is set wherever it is set: at hydration, in `commitSearch` and in
+ `applySnapshot`.
+ - So a filter-only link shows its results and runs no query. A stored query stays held on that
+ load, and on a later mount in the same visit, which shows the listing under the held query.
+ - On the server neither module variable is set, so the first client render matches the static
+ HTML. The exported `index.html` now has the line and no results section.
+- **Step 0, settled:**
+ - **The hub renders `SearchResults`:** `HubHome.tsx` → `TranscriptSearch` (common) →
+ `SearchResults`. So the hub has the clear screen, and `e2e:hub` ran in the gate.
+ - **Back restores the listing.** Measured with a probe (not committed) on the 120-video fixture
+ at 1440×1200, scrolled to 3,000 px before leaving:
+
+ | Path | After Back | Scroll before → after |
+ |---|---|---|
+ | empty Search → **Use with AI** (header) → Back | the listing, "All videos (120)" | 2,838 → 2,676 px (the first card drawn: `0024` → `0025`) |
+ | `qt=` link → **Use with AI** → Back | the results | 2,952 → 2,904 px |
+ | empty Search or `qt=` → **Chat** (workspace nav) → Back | the listing | → 0 (the top) |
+ | a card's video (the modal) → Back | leaves the page | — |
+
+ - A route outside the workspace unmounts the session; Back remounts it with the flag already
+ set, and the scroll comes back to within a card.
+ - `/ask` hides the search pane in place, and the window is at the top when Back shows it again.
+ - The modal opens with `replaceState`, so there is no entry of the page's own to go Back to.
+ - The last two are unchanged by S1.
+- **Tests:**
+ - `first-search.spec.ts`, new (11):
+ - on load, no results area, the line, the intro and the whole footer, at 1280×800 and 390×844;
+ - Enter on an empty box shows every video, and the line goes;
+ - the Search button shows every video;
+ - a filter changed before the first Search shows nothing;
+ - `/` → `/ask` → `/` keeps the listing;
+ - Back from Use with AI keeps it;
+ - a reload clears it;
+ - a `qt=` link shows its results on load;
+ - `tg=` and `ch=` links show theirs;
+ - a restored query shows the clear screen and the filled form, and runs on Enter.
+ - `showAll(page)` in `export/e2e/helpers.ts`: wait for the query builder, press Search, wait for
+ `results-summary`.
+ - Where specs read the listing at load:
+ - `browse-all`: the first test rewritten to assert the clear screen, then the listing;
+ - `charts` (7 tests), `workspace-shell` (1), `responsive` (the filters sheet and the selection
+ toolbar), `tag-chips` (3; its `?tg=` tests unchanged);
+ - `restore-no-refire`: the held query now shows no results area. Two new tests cover a second
+ mount after a filter-only link, and each holds with no shard fetched: `?tg=` → Use with AI →
+ Back, and `?ch=` → Use with AI → the header's Search link;
+ - the hub's `federated-search` (8): its first test asserts the clear screen with both archives
+ in, then the listing.
+
+#### Gates (at `90043b78`; logs `$T/s1-*.log`)
+
+- **tsc** was clean before each commit and at the tip (36–38 s). The specs commit's own run
+ timed out at 100 s under the machine's load; it was run again on that commit, clean.
+- **Unit:** common **2,210/2,210** (75 s). Editor unit, `test:scripts` and mcp were not run: S1
+ touches no editor, mcp or scripts file, and the editor imports none of these components.
+- **Builds**, each capped at 5 GB with no swap, from a clean `.next`:
+
+ | Build | Time | Max RSS |
+ |---|---|---|
+ | export, site (the worktree's default) | 36 s | 957 MB |
+ | export, hub | 28 s | 1,034 MB |
+
+- **e2e**, each detached and queued:
+
+ | Suite | Passed | Failed | Time |
+ |---|---|---|---|
+ | export, the specs above first | 58 | 1 | 3.4 min |
+ | hub, full | 36 | 0 | 1.7 min |
+ | export, full | 254 | 0 | 11.4 min |
+ | `e2e:2origin` (`E2E_TWO_ORIGIN_REBUILD=1`, the `export/public` links in place) | 3 | 0 | 49 s |
+
+ The one failure in the first run was the new `/` → `/ask` test: that run's first visit to `/ask`,
+ compiled by the dev server, took longer than 5 s. The test now waits 20 s for the URL. After
+ `e2e:2origin`, the primary's `export/public` files kept their 2026-09-28 mtimes.
+- **Numbers tool:** none.
+
+#### Found and left
+
+- **Back from `/ask`** shows the listing at the top, not where the reader left it (the table
+ above). This is as before S1.
+- **A card's video opens without a history entry**, so Back from the modal leaves the site. This is
+ as before S1.
+- **Enter before the session hydrates (review L2).** Until the manifest is in, a `qt=` link shows the
+ line and not yet its results. Enter in that window commits the empty draft, and `commitSearch`
+ strips `qt` before hydration reads it. The race is on `main` too; the line now shows during it.
+ Gating the line's first-Search half on hydration would close it, at the cost of the line in the
+ static HTML. Left for the operator.
+
+#### Decisions the operator could overturn
+
+| What I assumed | The alternative |
+|---|---|
+| The line also shows under the bar on a fresh `/ask` (the bar is shared, and Search there applies the grounding) | show it only on the search view |
+| A link with only a chart's shape (`view=chart&cs=…`, copied from the address bar after an empty Search) opens on the clear screen | count `view`/`cs` as asking |
+| A video link (`?v=`) opens the video over the clear screen | count `v` as asking |
+| On the hub, the "N of M archives answered" line waits for the first Search with the rest of the results; each failed archive's chip says so, with its Retry, before that | show the line above the gate |
+| After a filter-only link, a later mount in the same visit (a page outside the workspace and back, the header's Search link) shows the listing, with the stored query held in the box: the gate is once per page life | the clear screen again on that mount |
+| A key's presence asks, not a valid value (`?tg=Not%20An%20Id`) | count only the keys the page applied |
+| The Search button keeps its outline look before the first Search; only the line says what to do | fill it as for an unapplied edit |
+
+#### Review (verdict SHIP AFTER FIXES; `$T/s1-review.md`)
+
+The reviewer also ran the editor suite's specs that drive the export's bar, results and `?v=`
+modal (`export-search`, `export-player-platform-cache`): 21 passed, 0 failed.
+
+| Finding | Fix |
+|---|---|
+| M1: a filter-only link set `ranThisPageLife`, so on a later mount in the same visit (a page outside the workspace and Back; the hub's `/` → `/ask`) a query stored by an earlier visit ran and fetched shards, where `main` held it. The reviewer's probe: `?tg=` → Use with AI → Back fetched 1 shard and showed "Matching videos"; `?ch=` → Use with AI → home did the same | `9b01068c`: two module flags. `ranThisPageLife` is set at hydration only by an active URL query, as on `main`, and `askedThisPageLife` (a run, or `urlAsks`) drives the gate. `41dc1b4b`: `restore-no-refire` seeds a stored query and checks both paths: 0 shards, the box filled, Search marked, and "All videos" |
+| L1: the held-query comment still said "the browse listing" | `9b01068c` |
+| L2: Enter before hydration on a `qt=` link commits the empty draft (a race on `main`; the line now shows during it) | left: "Found and left" |
+| L3: the rulings list mixed the rulings with the plan's steps and my choices | this commit: the rulings, the parent's directions, and my decisions, apart |
+| L4: the slices table's S1 row, and the Rollout's live checks | this commit: the row; three S1 checks under "Live checks" |
+
+#### Gates after the fixes (at `41dc1b4b`; logs `$T/s1-*2.log`, `$T/s1-fix-e2e.log`)
+
+The fix changes only which flag each place sets, so the full suites were not re-run.
+- **tsc** clean before each fix commit (182 s under the machine's load).
+- **common** 2,210/2,210.
+- **e2e**, detached and queued (spec lists in `$T/s1-fix-specs.txt`, `$T/s1-fix-hub-specs.txt`):
+
+ | Suite | Specs | Passed | Failed | Time |
+ |---|---|---|---|---|
+ | export | `first-search`, `restore-no-refire`, `browse-all`, `workspace-shell` | 20 | 0 | 5.1 min |
+ | hub | `federated-search` | 12 | 1 | 2.5 min |
+ | hub | `federated-search`, again | — | — | the dev server did not answer in 120 s |
+ | hub | `federated-search`, again | 13 | 0 | 1.7 min |
+
+ The hub's first run failed on its first test's first line, the archive chip still "loading"
+ after 5 s. That line is unchanged from `main` and comes before anything S1 changed; the machine's
+ load average was 33. On the second attempt the dev server did not start within 120 s. The third
+ run passed 13/13.
+
## Rollout
Release 14 is slice HP (merged, `bfa1ff3c`), `r14/two-grounds-headers` and slice HS
@@ -1279,3 +1467,12 @@ hub goes before the sites, because in basic mode they share `export/out`. The si
- Every site's settings in the editor show **List on the Archilyzer homepage and hub**, ticked. With
none unticked, the homepage, the hub and every footer list the same sites as before, and
`https://archilyzer.pages.dev/homepage-summary.json` reads `"version":6`.
+- **S1, a clear screen until the first Search**, is in every site's build and the hub's, so steps 3
+ and 4 above (every site and the hub rebuilt and deployed) carry it. On one site:
+ - a plain visit to `/` shows the search bar, the transcript count and the whole footer, with no
+ scrolling and no listing;
+ - a `qt=` link shows its results on load;
+ - on a site that publishes tags (Anilyzer): Search for a word, then open a `tg=` link in the
+ same tab. Its listing shows with the word held in the box. After another page and Back the
+ word is still held: the box is filled, the Search button is marked, and the results say "All
+ videos", not "Matching videos".