Archilyzer · Source

archilyzer

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

commit df03d3151dd543329bfd4b341285c10764d3a85e
parent eb7a38d3fd0b4746330adbc3a5f26f21265a4397
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 30 Sep 2026 18:18:01 -0400

plans: slice CK after the review — the rulings, the fixes and the gates; the standing choice says what main did; FACTS; the changelog

The review (SHIP AFTER FIXES): M1 an empty scope settles, and no posts copy
under a tag filter; M2 a "Posts" leaf is not the row's; L1 the refusal line
always mounted and the button described; L2 the saves disabled; L3 case 9
checks the "Posts" leaf; L4 the decision waits for the manifests; I1 the
per-copy hit cap recorded. The standing-choices paragraph now says what a
plain query read on main (transcript cues only). Gates: tsc; common 2,380;
the export build; the five specs 56/56; the full export suite 267/267; the
hub 36/36. The changelog gains the empty-scope fix (a "Posts" layer under a
tag chip, a "Live chat" layer with no chat in scope, never finished on main
either) and says Posts unticked no longer empties a "Posts" layer.

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

Diffstat:
Mexport/CHANGELOG.md | 3++-
Mplans/FACTS.md | 49++++++++++++++++++++++++++++++++++---------------
Mplans/release-16.md | 123+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
3 files changed, 137 insertions(+), 38 deletions(-)

diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,7 +1,8 @@ # Changelog ## [Unreleased] -- **A search reads what the visitor ticks under "Search in": Transcripts, Posts and Live chat.** The Filters panel has a new row, **Search in**, beside Type. **Transcripts** and **Posts** are ticked by default and **Live chat** is not; Posts is offered only on a site that has posts, and Live chat only on a site with live chat. The row decides what a plain query reads: with Posts ticked, a plain query now finds posts as well as videos (before, a post was found only by a layer whose scope was "Posts"); with Live chat ticked, it finds live-chat messages too, shown in the same video's card beside the transcript hits, each marked "live chat"; with Transcripts unticked it reads no transcripts. A layer whose scope is picked by name in the query builder ("Live chat", "Posts", "Title / channel", …) reads what it names, whatever the row says. An empty query still lists every video the Type row keeps. With nothing ticked, Search and Apply filters are disabled and the row says "Search in: pick at least one". The Posts box moved here from the Type row. The row is remembered, and saved with a profile; a shared link does not carry it, so it opens with the reader's own row. A live-chat hit now wears its "live chat" badge wherever it is shown, and the hint under the search bar says to tick Live chat under Search in. Posts unticked is now also remembered after a reload and restored with a profile, which it was not. Needs a rebuild and deploy of each site and the hub. +- **A search with a layer that has nothing to read finishes.** A "Posts" layer under a tag chip, or a "Live chat" layer where no video in the selection has live chat, read "searched N/M…" for ever and never said "No matching videos."; it now finishes at once, having matched nothing. Needs a rebuild and deploy of each site and the hub. +- **A search reads what the visitor ticks under "Search in": Transcripts, Posts and Live chat.** The Filters panel has a new row, **Search in**, beside Type. **Transcripts** and **Posts** are ticked by default and **Live chat** is not; Posts is offered only on a site that has posts, and Live chat only on a site with live chat. The row decides what a plain query reads: with Posts ticked, a plain query now finds posts as well as videos (before, a post was found only by a layer whose scope was "Posts"); with Live chat ticked, it finds live-chat messages too, shown in the same video's card beside the transcript hits, each marked "live chat"; with Transcripts unticked it reads no transcripts. A layer whose scope is picked by name in the query builder ("Live chat", "Posts", "Title / channel", …) reads what it names, whatever the row says. An empty query still lists every video the Type row keeps. With nothing ticked, Search and Apply filters are disabled and the row says "Search in: pick at least one". The Posts box moved here from the Type row, and unticking it no longer empties a layer whose scope is "Posts". Under a tag chip a plain query reads no posts, since a post carries no tags. The row is remembered, and saved with a profile; a shared link does not carry it, so it opens with the reader's own row. A live-chat hit now wears its "live chat" badge wherever it is shown, and the hint under the search bar says to tick Live chat under Search in. Posts unticked is now also remembered after a reload and restored with a profile, which it was not. Needs a rebuild and deploy of each site and the hub. ## [0.11.0] - 2026-09-30 - **The charts count every transcript, once the site is rebuilt.** A transcript that arrived after its video was first indexed was missing from the charts' transcript and cue counts and from "Transcribed over time", and a video with YouTube captions alone had no transcription date. Both are counted now, and a captioned video is dated by when its captions arrived. diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -7877,37 +7877,56 @@ source mirror (homepage)". Anchors are at the branch. `E2E_SOURCE_PUBLIC_DIR` while it holds a manifest (`sourcePublicDir`, `:69`; never in a production build). -## Search in (verified 2026-09-30, branch `r16/search-in`) +## Search in (verified 2026-09-30, branch `r16/search-in`, after its review) - **The row governs "transcripts"-scope leaves only.** The Filters panel's "Search in" row - (`common/components/FiltersPanel.tsx:419`, `data-testid="search-in-row"`) says what a leaf of + (`common/components/FiltersPanel.tsx:421`, `data-testid="search-in-row"`) says what a leaf of scope `"transcripts"` reads — the default leaf, `newLeaf({scope: "transcripts"})` in `emptyRoot()` (`common/lib/searchQuery.ts:86`), and every plain query. A leaf asked for by name ("Live chat", "Posts", "Title / channel", "Description", "Keywords") is not the row's - business. The browse listing (an empty query) is the Type row's. + business — including "Posts" with Posts unticked: `nop` no longer gates the global scope + (`SearchSessionContext.tsx:898` checks only the tag filter). The browse listing (an empty + query) is the Type row's. - **The mechanism is a rewrite of the committed tree just before it runs**, never of the tree - itself: `applySearchIn(root, {transcripts, posts, chat})` (`searchQuery.ts:534`) turns an active + itself: `applySearchIn(root, {transcripts, posts, chat})` (`searchQuery.ts:544`) turns an active transcripts leaf into itself (transcripts only), itself with another scope and the same id (one other kind), or an OR group `<id>~in` over copies `<id>~transcripts` / `~posts` / `~chat` (two or three kinds; a negated leaf is a negated one-child AND `<id>~not` around the OR). The session - (`SearchSessionContext.tsx:569`) runs the rewrite and folds the progress back with - `foldSearchIn` (`common/lib/search/searchIn.ts:18`; called at `SearchSessionContext.tsx:931`): + (`SearchSessionContext.tsx:589`) runs the rewrite and folds the progress back with + `foldSearchIn` (`common/lib/search/searchIn.ts:18`; called at `SearchSessionContext.tsx:960`): copies' hits are re-filed under the visitor's leaf id, their states folded into one (the count is the OR group's union once every copy has started). `committedRoot`, its canonical hash and `qt=` stay the visitor's tree. `lib/search/` has a seventh module for this, `searchIn.ts`. + Each copy has its own hit cap, so a plain leaf reading two or three kinds can show up to 2–3× + "Max hits" before "Load more", as an explicit OR of leaves always could. - **Posts, as read by a plain query, is new in release 16.** On `main` before it a transcripts leaf never read posts: posts joined the global scope only when a posts leaf was in the tree (`needsPostsManifests`), and a non-posts leaf subtracts the posts slugs - (`searchEval.ts:337`). `nop` still also gates the global scope - (`SearchSessionContext.tsx:873`), so an unticked Posts also empties a leaf of scope "Posts" — - as it always did. -- **What counts as ticked is what the site can honour** (`SearchSessionContext.tsx:546`): Posts - only where `postsManifest.channels` is non-empty, Live chat only where - `subsManifest.liveChatTotalCount > 0` — the same conditions the panel offers the boxes on. - Nothing ticked (`draftSearchInEmpty`, `:564`) disables Search and `Apply filters`, and - `commitSearch` and the two profile saves refuse it. + (`searchEval.ts:337`). The row reads them through a posts copy of the leaf. +- **Under a curated-tag filter no posts copy is made** (`searchInUnderTags`, `searchQuery.ts:507`): + a post carries no tags. Unless posts are all the row reads — then the copy stays and reads an + empty scope, so the leaf does not fall back to its transcripts. +- **An empty scope settles.** Each streaming driver in `lib/search/leafPipeline.ts` finalizes when + no worker is running after its first `ensureWorkers` and after a raised cap (`settleIfIdle`, + `:199`, `:342`, `:528`); before, zero slugs never reported done and the query read "searching" + for ever. `searchEval.runLeaf` also answers an empty effective scope at once, with no cache + lookup (`searchEval.ts:353`). +- **What counts as ticked is what the site can honour** (`SearchSessionContext.tsx:552`, `:562`): + Posts only where `postsManifest.channels` is non-empty, Live chat only where + `subsManifest.liveChatTotalCount > 0` — the same conditions the panel offers the boxes on. **It + waits for the manifests:** `SearchDataValue.manifestsSettled` (`SearchDataContext.tsx:67`; the + single site's settles at each manifest's first answer or first failure, `:165`; the hub's is + `summariesReady`, since an archive is ready only once both its manifests have settled). Until + then Search is not refused and the query does not run (`SearchSessionContext.tsx:944`). + Nothing ticked (`draftSearchInEmpty`, `:583`) disables Search, `Apply filters`, Save and Save + as…, and `commitSearch` and the two profile saves refuse it. The row's line (`FiltersPanel.tsx:462`, + `aria-live="polite"`, not `role="status"`: from xl the panel is always mounted, and the video + modal's own status is found by role) is always mounted, empty unless refused; the Search button is described + by an always-mounted copy in the bar (`SearchBar.tsx:182`, `id="search-in-refusal"`), since the + panel below xl is a sheet that is not mounted while closed. The words are + `SEARCH_IN_REFUSAL` (`SearchSessionContext.tsx:190`). - **The keys:** `notr` (Transcripts unticked) and `lc` (Live chat ticked) beside `nop` in - `FilterSnapshot` (`common/components/exportFilterStorage.ts:37-39`), in the working snapshot and + `FilterSnapshot` (`common/components/exportFilterStorage.ts:38-40`), in the working snapshot and the profiles of `ytdlp-tb:export-filters`, each written only off its default. **Share links do not carry them** (nor `nop`): no share-v1 key, no `ShareSelection` field. The row is read from the stored snapshot on hydration whatever the URL carries, so a `qt=` or share link reads with diff --git a/plans/release-16.md b/plans/release-16.md @@ -6,9 +6,11 @@ slice's prompt carries its ruling, and this record carries what was built. Rules **The standing choices** (not re-opened): - **A search reads transcripts and posts unless the visitor says otherwise; live chat is read only - when asked.** Today a plain query already reads the transcript cues and the posts corpus, and - reads live chat only through a query-builder leaf whose scope is "Live chat". That stays the - default; the three become one row of toggles. + when asked.** On `main` before this release a plain query read the transcript cues only: posts + only through a query-builder leaf whose scope is "Posts", live chat only through one whose scope + is "Live chat". A plain query now reads transcripts and posts by default; the three become one + row of toggles. (Corrected after slice CK found the first wording, "a plain query already reads + … the posts corpus", untrue of the code.) - **The toggles say what a query reads, not which records list.** "Type: Videos, Livestreams" keeps saying which records are shown; an empty query with Transcripts off still lists videos. - **A slice that needs another slice's file stops and says so**; it does not edit it. @@ -76,8 +78,12 @@ only a "Posts" leaf, as before. - with nothing ticked, the leaf unchanged (see the refusal below). Posts are not left on the global-scope path, as the prompt proposed: that path reads posts only - for a "Posts" leaf, so the rewrite makes one (the posts copy) and the path then feeds it (`nop` - still gates the global scope). A leaf of any other scope, and an empty leaf, are untouched. + for a "Posts" leaf, so the rewrite makes one (the posts copy) and the path then feeds it. `nop` + acts only here, by leaving the posts copy out; it no longer gates the global scope (review M2), + so a leaf whose scope is "Posts" reads posts with the box unticked, as ruled. Under a curated-tag + filter no posts copy is made either (`searchInUnderTags`; a post carries no tags), unless posts + are all the row reads — then the copy stays and reads an empty scope, and the leaf does not fall + back to its transcripts. A leaf of any other scope, and an empty leaf, are untouched. (b) would have run up to three pipelines per leaf inside `runLeaf`, each with its own cache key, controller and streaming merge; (a) touches the evaluator not at all. - **The fold.** `foldSearchIn(progress, tree)` (`common/lib/search/searchIn.ts`, new) files the @@ -90,7 +96,12 @@ only a "Posts" leaf, as before. - **The session** (`SearchSessionContext.tsx`). What counts as ticked is what the site can honour: Posts only with a posts corpus, Live chat only with live chat — the panel's own conditions — so a stored `lc` on a site without chat asks for no subs manifest (see "Found and left" for what a - "Live chat" leaf does there). The committed tree runs as `applySearchIn(committedRoot, …)` and its progress + "Live chat" leaf does there). The session waits for the two manifests before it decides anything + from the row (review L4): `SearchDataValue.manifestsSettled` — the single site's settles at each + manifest's first answer or first failure (a 404 subs manifest is not held for the retry), the + hub's is `summariesReady`, since an archive is ready only once both its manifests have settled. + Until then Search is not refused and the query does not run, so a stored row with Transcripts + unticked never reads transcripts first and then re-runs. The committed tree runs as `applySearchIn(committedRoot, …)` and its progress is folded before `setTreeProgress`; `needsChatManifests` and `needsPostsManifests` read the rewritten trees (draft and committed), so Live chat ticked loads the subs manifests and passes `chatScopeSlugs` exactly as a "Live chat" leaf does (the prompt's `|| committedLiveChat`, but only @@ -111,11 +122,16 @@ only a "Posts" leaf, as before. against what it had just loaded; `applySnapshot` did not commit it, so a loaded profile with Posts unticked searched posts until the next Search. - **Nothing ticked.** When nothing the site offers is ticked (`draftSearchInEmpty`), Search is - disabled (with a title naming the row), `Apply filters` in the sheet is disabled - (`FiltersContainer.tsx`, a new `applyDisabled` prop), and `commitSearch` (where Enter and Apply - arrive) and both profile saves (which commit the draft) refuse. The row shows one line, "Search in: - pick at least one" (`data-testid="search-in-empty"`, `role="status"`), and the bar's "Press Enter - or click Search to apply" is withheld while Search is refused. A tree committed with nothing ticked + disabled, `Apply filters` in the sheet is disabled (`FiltersContainer.tsx`, a new `applyDisabled` + prop), Save and Save as… are disabled (`ProfilesRow`'s `saveDisabled`, review L2), and + `commitSearch` (where Enter and Apply arrive) and both profile saves (which commit the draft) + refuse. The row's line, "Search in: pick at least one" (`data-testid="search-in-empty"`, + `aria-live="polite"`), is always mounted and empty unless Search is refused, so the change is + announced (review L1; a live region, not `role="status"`, which the first cut used and which gave + the page a second "status" beside the modal's — `modal-digest.spec` finds that one by role); the Search button is described (`aria-describedby`) by an always-mounted `sr-only` + copy of the words in the bar (`id="search-in-refusal"`), because below xl the panel is a sheet + that is not mounted while closed; its `title` stays for the mouse. The bar's "Press Enter or click + Search to apply" is withheld while Search is refused. A tree committed with nothing ticked some other way (a hand-edited profile) reads its transcripts rather than matching nothing. - **Display.** The `hit.track !== "live_chat"` suppression in `SearchResults.tsx` is gone: every track hit wears its `TrackBadge`, so a "Live chat" leaf's hits now carry the badge too (no spec asserted @@ -145,7 +161,12 @@ leaves, and `search-in.spec.ts` reads both corpora with a regex `alpha|kappa` in | `5699a1e7` | `export(e2e):` `search-in.spec.ts`; the fixture's posts get their own words; `posts-search` and `tag-chips` follow them. | | `861baaa0` | `plans:` this section; FACTS "Search in"; the export changelog. | | `1eb089da` | Merge `main` (`0fe719b1`, the 0.11.0 cut, which renamed `[Unreleased]` while this branch added to it): the bullet goes under a fresh `[Unreleased]` above `[0.11.0]`. Changelogs only; no code moved on `main`, so no gate was re-run. | -| this commit | `plans:` the two rows above. | +| `26c63ccb` | `plans:` the two rows above. | +| `6980ac75` | `common:` review M1 — the three streaming drivers settle when no worker starts (and after a raised cap); `runLeaf` answers an empty scope at once; tests through the real drivers. | +| `b22198d5` | `common:` review M2, L1, L2, L4 and M1's tag rule — `nop` no longer gates the global scope; `searchInUnderTags`; `manifestsSettled` on both SearchData providers and the held decision; the always-mounted refusal line and the button's description; Save and Save as… disabled. | +| `aaf3accd` | `export(e2e):` `search-in` cases 9 (the "Posts" leaf), 10 and 11, the refusal's description and Save as…; `posts-search` for M2. | +| `76deaac7` | `common:` the refusal line is a polite live region, not a second `role="status"` (it broke `modal-digest.spec`'s `getByRole("status")`). | +| this commit | `plans:` the review, its rulings and gates in this section; the standing choice amended; FACTS; the changelog. | **Tests** (unit) @@ -155,7 +176,7 @@ leaves, and `search-in.spec.ts` reads both corpora with a regex `alpha|kappa` in | `lib/search/searchIn.test.ts` (8, new) | Through the real `runQueryTree` with a fake leaf runner: Transcripts alone reads the cues only; Transcripts + Posts returns the video and the post, the post hit filed under the visitor's leaf with scope `posts`; Live chat ticked adds the chat-only video, its hit under the leaf with `track: "live_chat"`, the leaf's state folded (only `a` left, count 2 = the union, not active); Transcripts off + Live chat on: a cue-only word finds nothing; a "Live chat" leaf ignores the row; NOT reads NOT of the union and still counts what it matched. The fold alone: before every copy has started, the largest copy and active (not the 30,000 the evaluator reports); nothing rewritten returns the same progress. | | `components/exportFilterStorage.test.ts` (5, new) | `notr`, `nop`, `lc` survive a JSON round trip; absent reads as the default and a pre-row profile equals one spelling the defaults; a non-boolean is dropped; `snapshotsEqual` tells each apart; the working snapshot and a profile keep the row through `saveStoredState`/`loadStoredState`. | -**e2e** (`export/e2e/search-in.spec.ts`, new, 9 cases): (1) the defaults — Transcripts and Posts +**e2e** (`export/e2e/search-in.spec.ts`, new, 11 cases after the review): (1) the defaults — Transcripts and Posts ticked, Live chat not, exactly one checkbox named "Posts" on the page, Videos and Livestreams still in Type, and the bar's hint, gone once Live chat is ticked; (2) a plain "kappa" returns the two posts under a section named "Posts", and none with Posts unticked; (3) one plain regex leaf `alpha|kappa` @@ -163,12 +184,16 @@ returns 3 videos and 2 posts, "5 videos, 5 hits"; (4) "message" (only in one vid nothing by default, and with Live chat ticked finds that video, "1 video, 30 hits", every shown hit badged "live chat", the section named "Live chat", and `qt=` still the visitor's one transcripts leaf; (5) Transcripts off + Live chat on: "line" (only in cues) finds nothing, "message" finds the -chat video, Transcripts back on finds all three; (6) nothing ticked: Search disabled, the line -shown, the "Press Enter" line withheld, Enter commits nothing (no `qt=`, no results), Live chat or -Posts re-enables it; (7) the row survives a reload, is saved with "Save as…", a diverging Search +chat video, Transcripts back on finds all three; (6) nothing ticked: Search disabled and described +by the words, Save as… disabled, the line shown (always mounted, empty otherwise), the "Press Enter" +line withheld, Enter commits nothing (no `qt=`, no results), Live chat or Posts re-enables it; (7) the row survives a reload, is saved with "Save as…", a diverging Search leaves the profile, and loading the profile brings the row back with no unsaved-changes dot and its results; storage holds `notr` and `lc` and no `nop`; (8) an empty query with Transcripts off lists -"All videos (3)"; (9) a "Live chat" leaf from `qt=` reads the chat with the row at its default. +"All videos (3)"; (9) a "Live chat" leaf from `qt=` reads the chat with the row at its default, and +a "Posts" leaf reads its two posts with Transcripts and Posts unticked; (10) a plain query under a +tag chip finishes with its two tagged videos, and with posts alone ticked says "No matching +videos." and finishes; (11) with Videos and Livestreams unticked a plain "kappa" reads the two posts +and finishes. `posts-search.spec.ts`: unticking Posts leaves a "Posts" leaf's two posts. #### Gates (logs `$T/ck-*.log`) @@ -207,11 +232,17 @@ results; storage holds `notr` and `lc` and no `nop`; (8) an empty query with Tra runs its own tree), `/ask`'s retrieval (`export/app/lib/askRetrieval.ts`, its own OR of keywords — though the grounding it is handed from the search page is the session's results, which the row governs), and the MCP (no row). -- **A restart when a manifest answers late.** The row counts Posts and Live chat only once the posts - and subs manifests say the site has them. A query that runs before they answer (a `qt=` link, when - the summaries come first) runs once without posts or chat and again when they arrive; the first - run's transcript fetches are memoized, so the second is cheap. The same race was already there for - a "Posts" leaf. +- **A late manifest after a failure.** The single site's manifests count as settled at their first + failure (so a site with no live chat, whose subs manifest 404s, is not held for the retry). A + manifest whose retry does answer arrives later and the row re-runs, as a late manifest always + did. +- **A draft tick restarts the committed run** (review I2, left): the manifests load on the draft's + need or the committed one's, and the run waits on both, so ticking Posts or Live chat in the + draft cancels a committed run in flight, which re-runs once the manifests are in (cheaply, from + the layer cache). The same was already so for a draft "Live chat" leaf. +- **Hub mode is covered by reasoning** (review I3): no hub or two-origin fixture ships posts or + live chat, so their suites see a lone Transcripts box and no rewrite. The rewrite never touches + slugs, and the posts and chat scopes are built from the merged, origin-qualified manifests. - **A lone Transcripts box.** On a site with neither posts nor live chat (the hub's e2e fixture) the row is one box, whose only effect unticked is to refuse Search. Shown as ruled (Transcripts is not conditional). @@ -245,3 +276,51 @@ results; storage holds `notr` and `lc` and no `nop`; (8) an empty query with Tra | The Filters chip counts the row off its default as one, Live chat ticked included | Only unticks count, as narrowing | | The fixture's posts get words of their own, rather than every spec that searches "alpha" or "gamma" growing two post cards (16 failures in four specs by 174 of 265 tests) | Keep the posts' "alpha" and add the posts to each expectation | | The hint goes once Live chat is ticked | Always shown where live chat exists | +| Each copy of a plain leaf has its own hit cap, so one reading two or three kinds can show up to 2–3× "Max hits" before "Load more" (as an explicit OR of leaves can); the header counts each video once | One cap shared by the copies | +| After the review: no posts copy under a tag filter, unless posts are all the row reads | A posts copy that reads an empty scope | +| After the review: the single site's manifests settle at their first failure | At the end of the retry (about a second more on a site with no live chat) | +| After the review: the Search button is described by a copy of the words in the bar | Described by the panel's line (no description while the sheet is closed) | + +#### Review + +**Verdict: SHIP AFTER FIXES** (`ck-review.md` in the job's scratch). Rulings (parent, 2026-09-30): + +| Finding | Ruling | Where | +|---|---|---| +| M1: a copy with nothing to read never finished — none of the three streaming drivers finalized for zero slugs, and the rewrite makes such scopes from a plain query (a posts copy under a tag chip or a channel selection with no posts, a chat copy where no video in scope has chat, a transcripts copy when Type keeps no video), so the query read "searched N/M…" for ever | The drivers settle when idle after their first `ensureWorkers` and after a raised cap; `searchEval.runLeaf` answers an empty scope at once. No posts copy under a tag filter (unless posts are all the row reads). Tests through the real drivers; two spec cases | `6980ac75`, `b22198d5`, `aaf3accd` | +| M2: `nop` still emptied a leaf of scope "Posts", against the ruling | `nop` no longer gates the global scope; it only leaves out the plain query's posts copy. `posts-search`, FACTS, the storage comment, this record and the changelog follow | `b22198d5`, `aaf3accd`, this commit | +| L1: the refusal line appeared with its text, and the Search button's reason was only a `title` | The line is always mounted; the button is described by an always-mounted copy in the bar | `b22198d5`, `aaf3accd` | +| L2: Save and Save as… stayed enabled and silently did nothing | Disabled like Search and Apply | `b22198d5`, `aaf3accd` | +| L3: case (9) claimed a "Posts" leaf it did not check | Checked, with Transcripts and Posts unticked | `aaf3accd` | +| L4: a stored row with Transcripts unticked read as empty until the manifests answered, and a `qt=` link could read transcripts first | The decision waits for `manifestsSettled`: no refusal, no run | `b22198d5` | +| I1: the hit cap applies per copy | Recorded (decisions table) | this commit | +| I2: a draft tick restarts the committed run | Left, recorded ("Found and left") | this commit | +| I3: hub mode covered by reasoning only | Recorded ("Found and left") | this commit | +| I4: the standing-choices paragraph said a plain query already read posts | Amended to what `main` did | this commit | + +The empty-scope settling also ends a hang that `main` had for a leaf asked for by name: a "Posts" +leaf under a tag chip, or a "Live chat" leaf where no video in scope has chat, never finished +there either (its own changelog bullet). + +**Tests after the review:** `lib/searchQuery.test.ts` 12 (+1: `searchInUnderTags`); +`lib/search/searchIn.test.ts` 15 (+7: each of the transcripts, posts, chat and description drivers +settles an empty scope and settles again after a raised cap; a plain query finishes through +`runQueryTree` with the real drivers, with the posts scope null, an empty posts set, and an empty +chat set, and with posts alone and none to read). All seven new cases fail on the code before the +fix; the drivers' fix alone passes all 15, the `runLeaf` short-circuit alone passes the three +`runQueryTree` cases. + +#### Gates after the review (logs `$T/ck-*.log`) + +- **tsc** (all workspaces): clean, 70 s before the fix commits and 42 s at `76deaac7` (common and + export alone before that commit). +- **common:** **2,380/2,380**, 79 s (+8). +- **Build:** `pnpm --filter export exec next build` — exit 0, 33 s; no dangling `export/public` link. +- **e2e:** + + | Run | At | Specs | Result | + |---|---|---|---| + | 8 | `aaf3accd` | `search-in`, `posts-search`, `query-tree`, `tag-chips`, `live-chat` | **56 passed**, 0 failed, 2.5 min | + | 9 | `aaf3accd` | the full export suite | stopped at 214 of 267: 213 passed, **1 failed** — `modal-digest`'s `getByRole("status")` found the always-mounted refusal line beside the modal's status; fixed in `76deaac7` | + | 10 | `76deaac7` | the full export suite | **267 passed**, 0 failed, 13.5 min | + | 11 | `76deaac7` | the hub suite | **36 passed**, 0 failed, 1.5 min |