commit 0e59ad100944b060f3942fc36028263249a0fdf5
parent 43a9646e8a9df09de6ea1b7522ee5f5fc502f09f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 30 Sep 2026 17:44:18 -0400
common: Search in after the review — a "Posts" leaf is not the row's, no posts copy under a tag filter, the decision waits for the manifests, the refusal is described and the saves disabled
M2: nop no longer gates the global scope, so a leaf whose scope is "Posts"
reads posts with Posts unticked, as ruled; the box governs only the plain
query's posts copy. Under a curated-tag filter no posts copy is made
(searchInUnderTags: posts carry no tags), unless posts are all the row
reads, where the copy stays and reads nothing. L4: the SearchData providers
say when the posts and subs manifests have settled (manifestsSettled; the
hub's is summariesReady), and until then Search is not refused and the
query does not run, so a stored row with Transcripts unticked never reads
transcripts first. L1: the row's status line is always mounted and empty
unless refused; the Search button is described by an always-mounted copy in
the bar. L2: Save and Save as are disabled while Search is refused.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
7 files changed, 137 insertions(+), 38 deletions(-)
diff --git a/common/components/FiltersPanel.tsx b/common/components/FiltersPanel.tsx
@@ -27,6 +27,7 @@ import { clearAdvanced } from "./exportAdvancedStorage";
import { ymdToInput, inputToYmd } from "../lib/ymd";
import {
useSearchSession,
+ SEARCH_IN_REFUSAL,
DEFAULT_MAX_HITS,
DEFAULT_FETCH_CONCURRENCY,
DEFAULT_FLUSH_INTERVAL_MS,
@@ -186,6 +187,7 @@ export default function FiltersPanel({
onDelete={handleDeleteProfile}
onRevert={handleRevertProfile}
onShareCurrentSearch={handleShareCurrentSearch}
+ saveDisabled={draftSearchInEmpty}
/>
{channelOptions.length > 0 && (
@@ -446,15 +448,18 @@ export default function FiltersPanel({
Live chat
</label>
)}
- {draftSearchInEmpty && (
- <span
- role="status"
- data-testid="search-in-empty"
- className="basis-full text-xs text-warning"
- >
- Search in: pick at least one
- </span>
- )}
+ {/* Always mounted, empty unless Search is refused: a live region
+ is announced when its content changes, which several screen
+ readers miss for a region that appears with its text. The
+ Search button is described by the bar's own copy of these
+ words (this panel may be a closed sheet). */}
+ <span
+ role="status"
+ data-testid="search-in-empty"
+ className={cn("text-xs text-warning", draftSearchInEmpty && "basis-full")}
+ >
+ {draftSearchInEmpty ? SEARCH_IN_REFUSAL : ""}
+ </span>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
@@ -661,6 +666,7 @@ function ProfilesRow({
onDelete,
onRevert,
onShareCurrentSearch,
+ saveDisabled,
}: {
profileNames: string[];
activeProfileName: string | null;
@@ -673,6 +679,9 @@ function ProfilesRow({
onDelete: () => void;
onRevert: () => void;
onShareCurrentSearch: () => void;
+ // A save commits the draft, so it is refused when Search is ("Search in"
+ // has nothing ticked).
+ saveDisabled: boolean;
}) {
const showRevert = activeProfileName != null && profileDirty;
return (
@@ -711,7 +720,7 @@ function ProfilesRow({
variant="link"
size="sm"
onClick={onSave}
- disabled={!activeProfileName || !profileDirty}
+ disabled={!activeProfileName || !profileDirty || saveDisabled}
className="text-xs text-muted-foreground"
>
Save
@@ -721,6 +730,7 @@ function ProfilesRow({
variant="link"
size="sm"
onClick={onSaveAs}
+ disabled={saveDisabled}
className="text-xs text-muted-foreground"
>
Save as…
diff --git a/common/components/SearchBar.tsx b/common/components/SearchBar.tsx
@@ -18,7 +18,7 @@ import { Button } from "./ui/button";
import QueryBuilder, { compactLayerActions } from "./QueryBuilder";
import { FiltersTrigger } from "./FiltersContainer";
import { useMediaQuery } from "../lib/useMediaQuery";
-import { useSearchSession } from "./SearchSessionContext";
+import { SEARCH_IN_REFUSAL, useSearchSession } from "./SearchSessionContext";
export default function SearchBar({ nav }: { nav?: ReactNode }) {
const {
@@ -104,9 +104,12 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
data-testid="search-submit"
data-dirty={queryDirty || filtersDirty ? "true" : "false"}
// "Search in" with nothing ticked reads nothing; commitSearch refuses it
- // too, for Enter and the sheet's Apply.
+ // too, for Enter and the sheet's Apply. The reason is its description,
+ // from the always-mounted copy below (not only a `title`, which neither
+ // touch nor a keyboard reaches).
disabled={draftSearchInEmpty}
- title={draftSearchInEmpty ? "Search in: pick at least one" : undefined}
+ aria-describedby="search-in-refusal"
+ title={draftSearchInEmpty ? SEARCH_IN_REFUSAL : undefined}
variant={queryDirty || filtersDirty ? "default" : "outline"}
className="shrink-0 h-11 sm:h-9"
>
@@ -172,6 +175,13 @@ export default function SearchBar({ nav }: { nav?: ReactNode }) {
{!compact && (
<div className="flex items-center gap-2 flex-wrap">{submit}</div>
)}
+ {/* The Search button's description. Here rather than pointing at the
+ Filters panel's line, which below xl lives in a sheet that is not
+ mounted while closed. Not a live region: the panel's line is the
+ one announced, where the box was unticked. */}
+ <span id="search-in-refusal" className="sr-only">
+ {draftSearchInEmpty ? SEARCH_IN_REFUSAL : ""}
+ </span>
{/* The chip row. Rendered OUTSIDE the `mounted` gate so the workspace
nav exists as plain anchors before hydration — a nav click lost to
diff --git a/common/components/SearchDataContext.tsx b/common/components/SearchDataContext.tsx
@@ -58,6 +58,13 @@ export type SearchDataValue = {
// many). Merged across origins in hub mode. Null / empty channel list when
// this site ships no posts corpus.
postsManifest: PostsManifest | null;
+ // The two manifests above have answered (or failed), so a null or an empty
+ // one means "this site has none", not "not answered yet". The "Search in"
+ // row waits for it before it refuses a Search or runs one: until then a
+ // stored row cannot be told apart from an empty one. Hub mode: the merge
+ // covers the ready archives only, and an archive is ready only once both
+ // its manifests have settled, so it is `summariesReady`.
+ manifestsSettled: boolean;
// The channel selection model, resolved mode-appropriately.
channels: ChannelOption[];
groups: ChannelGroup[];
@@ -147,8 +154,17 @@ export function useSearchData(): SearchDataValue {
// share links, and saved profiles round-trip exactly as before.
export function SingleSiteDataProvider({ children }: { children: ReactNode }) {
const summariesState = useSummaries("");
- const subsManifest = useSubsManifest("").data ?? null;
- const postsManifest = usePostsManifest("").data ?? null;
+ const subsQuery = useSubsManifest("");
+ const postsQuery = usePostsManifest("");
+ const subsManifest = subsQuery.data ?? null;
+ const postsManifest = postsQuery.data ?? null;
+ // Settled at the first failure, not after the retry: a site with no live
+ // chat ships no subs manifest, and its 404 must not hold a search for the
+ // retry's second. A retry that does answer later still arrives (the row then
+ // re-runs), as a late manifest always did.
+ const manifestsSettled =
+ (subsQuery.data !== undefined || subsQuery.isError || subsQuery.failureCount > 0) &&
+ (postsQuery.data !== undefined || postsQuery.isError || postsQuery.failureCount > 0);
const aliases = useSearchAliases("");
const curatedTags = useCuratedTags("");
const manifest = summariesState.manifest;
@@ -192,6 +208,7 @@ export function SingleSiteDataProvider({ children }: { children: ReactNode }) {
summariesState,
subsManifest,
postsManifest,
+ manifestsSettled,
channels,
groups,
defaultGroupId,
@@ -204,6 +221,7 @@ export function SingleSiteDataProvider({ children }: { children: ReactNode }) {
summariesState,
subsManifest,
postsManifest,
+ manifestsSettled,
channels,
groups,
defaultGroupId,
@@ -815,6 +833,7 @@ export function MultiSiteDataProvider({
summariesState,
subsManifest,
postsManifest,
+ manifestsSettled: summariesState.summariesReady,
channels,
groups,
defaultGroupId,
diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx
@@ -43,6 +43,7 @@ import {
parseRoot,
rootFromLegacy,
searchInReadsNothing,
+ searchInUnderTags,
stringifyRoot,
type GroupNode,
type LayerScope,
@@ -183,6 +184,10 @@ export type LeafInfo = {
// Advanced-pipeline defaults, surfaced so the bar's Advanced-options controls
// (and their "Reset to defaults" button) share the exact same constants.
export const DEFAULT_MAX_HITS = 500;
+
+// The words the Filters panel's "Search in" row and the Search button's
+// description say while nothing the site offers is ticked.
+export const SEARCH_IN_REFUSAL = "Search in: pick at least one";
export const DEFAULT_FETCH_CONCURRENCY = 6;
export const DEFAULT_FLUSH_INTERVAL_MS = 120;
@@ -396,9 +401,9 @@ function useSearchSessionState() {
>(() => new Set());
const [committedNov, setCommittedNov] = useState(false);
const [committedNol, setCommittedNol] = useState(false);
- // Third media kind: social posts. Videos and livestreams are a binary split
- // (isLivestream ? nol : nov); a post is neither, so it needs its own toggle
- // or the existing two would silently drop the whole posts corpus.
+ // Posts, the "Search in" row's second box (it was a third media kind in the
+ // Type row until release 16): unticked, a plain query reads no posts. A leaf
+ // of scope "Posts" reads them whatever it says.
const [committedNop, setCommittedNop] = useState(false);
// The rest of the "Search in" row (release 16) beside `nop`: what a plain
// query — a "transcripts" leaf — reads. Stored as off-the-default booleans
@@ -521,6 +526,7 @@ function useSearchSessionState() {
summariesState,
subsManifest,
postsManifest,
+ manifestsSettled,
channels: channelModel,
groups: manifestGroups,
channelKeyOf,
@@ -539,19 +545,11 @@ function useSearchSessionState() {
const hasPostsCorpus = (postsManifest?.channels.length ?? 0) > 0;
// ─── Search in ────────────────────────────────────────────────────────────
- // What the row reads, as far as this site can: Posts counts only where the
- // site ships posts and Live chat only where it has live chat (the panel
- // offers neither box otherwise), so a stored `lc` on a site without chat
- // asks for no manifest that is not there.
- const committedSearchIn = useMemo<SearchIn>(
- () => ({
- transcripts: !committedNotr,
- posts: !committedNop && hasPostsCorpus,
- chat: committedLc && hasSubs,
- }),
- [committedNotr, committedNop, committedLc, hasPostsCorpus, hasSubs],
- );
- const draftSearchIn = useMemo<SearchIn>(
+ // What the row ticks, as far as this site can honour it: Posts counts only
+ // where the site ships posts and Live chat only where it has live chat (the
+ // panel offers neither box otherwise), so a stored `lc` on a site without
+ // chat asks for no manifest that is not there.
+ const draftOffered = useMemo<SearchIn>(
() => ({
transcripts: !draftNotr,
posts: !draftNop && hasPostsCorpus,
@@ -559,9 +557,31 @@ function useSearchSessionState() {
}),
[draftNotr, draftNop, draftLc, hasPostsCorpus, hasSubs],
);
+ // What the rewrite reads: the same, less a posts copy under a tag filter,
+ // which no post can pass (`searchInUnderTags`).
+ const committedSearchIn = useMemo<SearchIn>(
+ () =>
+ searchInUnderTags(
+ {
+ transcripts: !committedNotr,
+ posts: !committedNop && hasPostsCorpus,
+ chat: committedLc && hasSubs,
+ },
+ committedTags.size > 0,
+ ),
+ [committedNotr, committedNop, committedLc, hasPostsCorpus, hasSubs, committedTags],
+ );
+ const draftSearchIn = useMemo<SearchIn>(
+ () => searchInUnderTags(draftOffered, draftTags.size > 0),
+ [draftOffered, draftTags],
+ );
// Nothing ticked that this site offers: Search, Enter, Apply and the profile
- // saves all refuse, and the panel says which row to fix.
- const draftSearchInEmpty = searchInReadsNothing(draftSearchIn);
+ // saves all refuse, and the panel says which row to fix. Only once the
+ // manifests have answered: until then Posts and Live chat read as not
+ // offered, and a stored row with Transcripts unticked would read as empty.
+ // The run waits for them too (below), so the decision is held, not guessed.
+ const draftSearchInEmpty =
+ manifestsSettled && searchInReadsNothing(draftOffered);
// The committed tree as it RUNS: every "transcripts" leaf rewritten to read
// the kinds ticked. `committedRoot` stays the tree the visitor built — the
// builder, the result cards' leaf sections and `qt=` read that one, and the
@@ -870,12 +890,17 @@ function useSearchSessionState() {
// passesFilter gives an untagged video. See tagFilteredPostScope below:
// the DEFAULT scope is only half of it, because a `posts`-scope leaf
// carries its own slug set past this.
- if (!committedNop && committedTags.size === 0) {
+ //
+ // Not gated on `nop` (release 16, review M2): the Posts box governs the
+ // plain query's posts copy, through `applySearchIn`, and a leaf of scope
+ // "Posts" was asked for by name and reads posts whatever the row says.
+ // `postScopeSlugs` is empty unless some leaf reads posts.
+ if (committedTags.size === 0) {
for (const slug of postScopeSlugs) out.push(slug);
}
return out;
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [transcripts, filterKey, postScopeSlugs, committedNop]);
+ }, [transcripts, filterKey, postScopeSlugs]);
// The posts slug set as the PIPELINE sees it. A `posts`-scope leaf is fed
// from here and not from the global scope above, so gating only the global
@@ -913,6 +938,10 @@ function useSearchSessionState() {
return;
}
if (!transcripts) return;
+ // "Search in" counts Posts and Live chat only once the site's manifests
+ // say it has them; a run before that would read the wrong kinds (with
+ // Transcripts unticked, transcripts) and run again. Hold it.
+ if (!manifestsSettled) return;
if (needsChatManifests && !subsManifestReady) return;
if (needsPostsManifests && !postsManifestReady) return;
@@ -945,6 +974,7 @@ function useSearchSessionState() {
committedTreeHash,
hasActiveQuery,
transcripts,
+ manifestsSettled,
filterKey,
needsChatManifests,
subsManifestReady,
@@ -1110,7 +1140,9 @@ function useSearchSessionState() {
const indexLoading =
hasActiveQuery &&
- (!summariesReady || (needsChatManifests && !subsManifestReady));
+ (!summariesReady ||
+ !manifestsSettled ||
+ (needsChatManifests && !subsManifestReady));
// Browse mode still needs the summaries before it can list anything.
const summariesLoading = !summariesReady;
const searching = hasActiveQuery && totalToProcess > 0;
diff --git a/common/components/exportFilterStorage.ts b/common/components/exportFilterStorage.ts
@@ -31,8 +31,9 @@ export type FilterSnapshot = {
// before the row existed reads transcripts and posts, as it always did.
// notr — Transcripts unticked (the transcript cues are not read)
// nop — Posts unticked (the social-post corpus; before the row this was
- // the Posts box in the Type row, and it still keeps the posts
- // corpus out of the scope a "Posts" leaf reads)
+ // the Posts box in the Type row, and it also emptied a leaf of
+ // scope "Posts" — since release 16 it does not: a leaf asked for by
+ // name reads what it names)
// lc — Live chat ticked (the live_chat track is read; off by default)
notr?: boolean;
nop?: boolean;
diff --git a/common/lib/searchQuery.test.ts b/common/lib/searchQuery.test.ts
@@ -9,6 +9,7 @@ import {
newGroup,
newLeaf,
searchInReadsNothing,
+ searchInUnderTags,
stringifyRoot,
type GroupNode,
type LeafNode,
@@ -182,3 +183,19 @@ test("two transcripts leaves each get their own copies", () => {
["a", "a", "b", "b"],
);
});
+
+test("under a tag filter the posts copy is left out, unless posts are all the row reads", () => {
+ const all = { transcripts: true, posts: true, chat: true };
+ assert.equal(searchInUnderTags(all, false), all, "no tag filter: unchanged");
+ assert.deepEqual(searchInUnderTags(all, true), { ...all, posts: false });
+ assert.deepEqual(
+ searchInUnderTags({ transcripts: false, posts: true, chat: true }, true),
+ { transcripts: false, posts: false, chat: true },
+ );
+ // Posts alone stay: the copy reads an empty scope (no results), rather than
+ // the row reading nothing and the leaf falling back to its transcripts.
+ const postsOnly = { transcripts: false, posts: true, chat: false };
+ assert.equal(searchInUnderTags(postsOnly, true), postsOnly);
+ const noPosts = { transcripts: true, posts: false, chat: false };
+ assert.equal(searchInUnderTags(noPosts, true), noPosts);
+});
diff --git a/common/lib/searchQuery.ts b/common/lib/searchQuery.ts
@@ -499,6 +499,16 @@ export function searchInReadsNothing(s: SearchIn): boolean {
return !s.transcripts && !s.posts && !s.chat;
}
+// Under a curated-tag filter no post can match — a post carries no curated
+// tags — so a posts copy would read an empty scope and cost a posts-manifest
+// load for nothing. It is left out, unless posts are all the row reads: then
+// it stays (and reads nothing, which is the answer) so that the leaf does not
+// fall back to reading its transcripts.
+export function searchInUnderTags(s: SearchIn, tagFilterOn: boolean): SearchIn {
+ if (!tagFilterOn || !s.posts || (!s.transcripts && !s.chat)) return s;
+ return { ...s, posts: false };
+}
+
export type SearchInTree = {
root: GroupNode;
// A per-kind copy's leaf id → the id of the "transcripts" leaf it stands