commit 52d377e6956caf633b1b48f63ca943af6324cfe5
parent d009376e0169c908958531a7d0017f226d1ed258
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 13:12:56 -0400
tags S2.9: the chip row, the card chips, and "Tags" -> "Keywords"
The filter panel's tag row is built ONLY from this site's /tags.json and is
absent entirely when that document is a 404 — which is every site until
someone curates a tag, and every site built before corpus spec 4. There is
no static list in the component, which is the operator's decision: a tag
defined corpus-wide but matching nothing HERE does not get a chip here.
selectableTags re-checks the count on the reader side so a stale or
hand-written document cannot put a chip that finds nothing in front of
someone.
Chips group by groupLabel ("Eva: Collab · In chat · Discussed"), each
carries this site's count, OR across the selection, and a selected chip
clears on click — the row is its own reset, so there is no second control
to keep in sync. A group wraps as a unit, so a phone gets one group per
line rather than a torn row.
On a card the record's tags render as quiet, NON-interactive spans: the card
header is already one big <Button>, and a clickable chip inside it would be
a control inside a control. A tag this site does not publish still shows —
by its id. The fact is on the record either way.
The yt-dlp keyword scope is now labelled "Keywords". The code token, the URL
and the MCP `scopes` enum stay "tags": renaming those would break every
saved query and share link to fix a word on a screen.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Diffstat:
3 files changed, 158 insertions(+), 4 deletions(-)
diff --git a/common/components/FiltersPanel.tsx b/common/components/FiltersPanel.tsx
@@ -13,7 +13,7 @@
// to be the outer one. It also doubles as the inline collapse, driven by the
// session's persisted `filtersCollapsed`.
-import { Fragment, useCallback } from "react";
+import { Fragment, useCallback, useMemo } from "react";
import { ChevronRightIcon } from "lucide-react";
import {
MISSING_STATES,
@@ -32,6 +32,10 @@ import {
DEFAULT_FLUSH_INTERVAL_MS,
} from "./SearchSessionContext";
import { useSearchData } from "./SearchDataContext";
+import {
+ groupPublishedTags,
+ selectableTags,
+} from "../lib/publishedTags";
export default function FiltersPanel({
// Inside the sheet the sheet itself IS the disclosure, so the wrapper stays
@@ -401,6 +405,7 @@ export default function FiltersPanel({
</div>
</div>
)}
+ <TagChipRow />
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<span className="text-xs uppercase tracking-wide text-muted-foreground">
Type
@@ -735,6 +740,108 @@ function ProfilesRow({
);
}
+// The curated-tag chip row (lib/curatedTags.ts) — "Eva: Collab · In chat ·
+// Discussed" beside the channel-group chips.
+//
+// Built ONLY from this site's published /tags.json, which is the operator's
+// decision and the reason there is no static list anywhere in this file: a tag
+// defined corpus-wide but matching nothing on THIS site does not appear here,
+// and neither does a hidden one, because compose-site never wrote it into this
+// site's document. The whole row is absent when the site publishes nothing —
+// which is every site until someone curates a tag, and every site built before
+// corpus spec 4.
+//
+// OR across the chips: selecting Collab and In chat is every stream with
+// either, never only the ones with both. A selected chip clears on click, so
+// the row is its own reset and needs no second control.
+function TagChipRow() {
+ const { curatedTags } = useSearchData();
+ const { draftTags, toggleDraftTag } = useSearchSession();
+
+ // selectableTags is the belt to compose-site's braces: a stale or
+ // hand-written document must not be able to offer a chip that finds nothing.
+ const groups = useMemo(
+ () => groupPublishedTags(selectableTags(curatedTags)),
+ [curatedTags],
+ );
+ if (groups.length === 0) return null;
+
+ return (
+ <div
+ className="flex flex-col gap-2 w-full"
+ data-testid="tag-chip-row"
+ >
+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
+ <span className="text-xs uppercase tracking-wide text-muted-foreground">
+ Tags
+ </span>
+ <span className="text-xs text-muted-foreground">
+ {draftTags.size === 0
+ ? "any"
+ : `${draftTags.size} selected — a video with ANY of them`}
+ </span>
+ </div>
+ {/* One wrapping flow, phone-first: a group is a label plus its chips and
+ wraps as a unit, so a narrow viewport gets one group per line rather
+ than a torn row. */}
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-2">
+ {groups.map((group) => (
+ <div
+ key={group.id || "ungrouped"}
+ data-testid="tag-chip-group"
+ data-group-id={group.id}
+ className="flex flex-wrap items-center gap-1.5 min-w-0"
+ >
+ {group.label && (
+ <span className="text-xs text-muted-foreground shrink-0">
+ {group.label}:
+ </span>
+ )}
+ {group.tags.map((tag) => {
+ const selected = draftTags.has(tag.id);
+ return (
+ <button
+ key={tag.id}
+ type="button"
+ aria-pressed={selected}
+ data-testid="tag-chip"
+ data-tag-id={tag.id}
+ title={
+ selected
+ ? `Clear "${tag.label}"`
+ : `Keep only videos tagged "${tag.label}"`
+ }
+ onClick={() => toggleDraftTag(tag.id)}
+ className={cn(
+ "flex items-center gap-1.5 rounded border px-2.5 py-1.5 text-sm select-none min-w-0 transition-colors",
+ selected
+ ? "border-primary bg-primary/10 text-foreground"
+ : "border-border bg-card/60 hover:bg-accent hover:text-accent-foreground",
+ )}
+ >
+ {tag.color && (
+ <span
+ aria-hidden="true"
+ className="inline-block size-2 shrink-0 rounded-full"
+ style={{ background: tag.color }}
+ />
+ )}
+ <span className="truncate">{tag.label}</span>
+ {/* The count is this site's, computed at index time — the
+ same number the chip's filter will produce. */}
+ <span className="text-xs text-muted-foreground shrink-0">
+ {tag.count}
+ </span>
+ </button>
+ );
+ })}
+ </div>
+ ))}
+ </div>
+ </div>
+ );
+}
+
function ChannelAllToggle({
channelOptions,
draftExcludedChannels,
diff --git a/common/components/QueryLeafView.tsx b/common/components/QueryLeafView.tsx
@@ -37,13 +37,19 @@ type Props = {
onWrapInGroup?: () => void;
};
+// LABELS ONLY. The `tags` scope searches yt-dlp KEYWORDS from
+// metadata.info.json (TranscriptSummary.tags) and is shown as "Keywords",
+// because "Tags" now means the operator's curated per-video vocabulary
+// (lib/curatedTags.ts) — the chip row in the filter panel. The code token, the
+// URL and the MCP `scopes` enum stay `"tags"`: renaming those would break every
+// saved query and share link to fix a word on a screen.
const SCOPE_LABELS: Record<LayerScope, string> = {
transcripts: "Transcripts",
chat: "Live chat",
posts: "Posts",
metadata: "Title / channel",
description: "Description",
- tags: "Tags",
+ tags: "Keywords",
};
export default function QueryLeafView({
@@ -155,8 +161,8 @@ export default function QueryLeafView({
: "Search video descriptions..."
: leaf.scope === "tags"
? leaf.useRegex
- ? "Tags regex..."
- : "Search video tags..."
+ ? "Keywords regex..."
+ : "Search video keywords..."
: leaf.scope === "chat"
? leaf.useRegex
? "Live chat regex..."
diff --git a/common/components/SearchResults.tsx b/common/components/SearchResults.tsx
@@ -40,6 +40,8 @@ import {
type LeafInfo,
type ResultGroup,
} from "./SearchSessionContext";
+import { useSearchData } from "./SearchDataContext";
+import type { PublishedTag } from "../lib/curatedTags";
// Rough first-paint guess for one full result card (header + a couple of
// leaf sections + a handful of hits). After mount, ResizeObserver measures
@@ -496,6 +498,17 @@ const ResultCard = memo(function ResultCard({
expanded: boolean;
onToggleExpand: (slug: string) => void;
}) {
+ // The site's published tag vocabulary, for labelling this record's
+ // `curatedTags`. A tag the site does not publish (hidden here, zero-count
+ // here, or defined after this page was built) is still shown — by its id.
+ // The fact is on the record either way, and a raw id is more honest than
+ // silently dropping a tag someone put there.
+ const { curatedTags: publishedTags } = useSearchData();
+ const tagById = useMemo<ReadonlyMap<string, PublishedTag>>(
+ () => new Map(publishedTags.map((t) => [t.id, t])),
+ [publishedTags],
+ );
+
// Other copies of this video elsewhere in the archive.
//
// FEDERATION GUARD: in hub mode a group's slug may be an origin-qualified id,
@@ -624,6 +637,34 @@ const ResultCard = memo(function ResultCard({
{group.hits.length > 0 &&
` · ${group.hits.length} hit${group.hits.length === 1 ? "" : "s"}`}
</span>
+ {/* Curated tags this record carries. Non-interactive spans: the card
+ header is already one big <Button>, so a clickable chip here would
+ be a control inside a control. */}
+ {group.curatedTags && group.curatedTags.length > 0 && (
+ <span className="basis-full sm:basis-auto flex flex-wrap items-center gap-1 shrink-0">
+ {group.curatedTags.map((id) => {
+ const def = tagById.get(id);
+ return (
+ <span
+ key={id}
+ data-testid="card-tag"
+ data-tag-id={id}
+ title={def?.groupLabel ? `${def.groupLabel}: ${def.label}` : undefined}
+ className="flex items-center gap-1 rounded border border-border bg-muted/60 px-1.5 py-0.5 text-[10px] text-muted-foreground"
+ >
+ {def?.color && (
+ <span
+ aria-hidden="true"
+ className="inline-block size-1.5 shrink-0 rounded-full"
+ style={{ background: def.color }}
+ />
+ )}
+ {def?.label ?? id}
+ </span>
+ );
+ })}
+ </span>
+ )}
</Button>
</div>
<button