Archilyzer · Source

archilyzer

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

commit a71b0fd8cbb3cd5bfea6a10791d5ecaf785beccf
parent 0241df1b65171770f5c1d1bfe198a04e74b9e69e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 22 Sep 2026 16:07:43 -0400

tags: a tag chip group folds away, and says what it still filters

The chip row is built from what a site publishes, so a site with several
curated groups pushes the results off a phone screen before anyone has
read one. Each labelled group is now a <details>, open by default and
folded by its summary — the same gesture and chevron the channel group
chips take. A folded group keeps "n selected" on its summary: a filter
that is still narrowing the list must never be invisible.

Collapse is local useState, deliberately not the persisted
`collapsedGroups` the channel groups use: this is a reading convenience,
not part of the filter shape a profile or a shared link carries, and a
vocabulary that came back folded on the next visit would hide the thing
the site is curated around.

The ungrouped bucket has no label, so it has nothing to put in a summary
and stays a plain row. inline-channel-chips.spec stubs /tags.json to a
404 now — its "nothing expanded" assertions count `details details[open]`
and the dev server's real tags.json would seed open tag disclosures.

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

Diffstat:
Mcommon/components/FiltersPanel.tsx | 186++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Mexport/e2e/inline-channel-chips.spec.ts | 11+++++++++++
Mexport/e2e/tag-chips.spec.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 218 insertions(+), 53 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, useMemo } from "react"; +import { Fragment, useCallback, useMemo, useState } from "react"; import { ChevronRightIcon } from "lucide-react"; import { MISSING_STATES, @@ -36,6 +36,7 @@ import { groupPublishedTags, selectableTags, } from "../lib/publishedTags"; +import type { PublishedTag } from "../lib/curatedTags"; export default function FiltersPanel({ // Inside the sheet the sheet itself IS the disclosure, so the wrapper stays @@ -777,6 +778,62 @@ function TagChipRow() { .sort(); }, [publishable, draftTags]); + // Collapse is per-group and LOCAL, unlike the channel groups' persisted + // `collapsedGroups`: a tag row is a handful of chips a reader folds away to + // get the results back on screen, not a filter shape worth carrying into + // every future session — and a group that came back folded would hide the + // vocabulary this site is built around. Every group starts open. + const [collapsedTagGroups, setCollapsedTagGroups] = useState<Set<string>>( + () => new Set(), + ); + const toggleTagGroup = useCallback((key: string) => { + setCollapsedTagGroups((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }, []); + + const renderChip = (tag: PublishedTag) => { + 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> + ); + }; + if (groups.length === 0 && unpublished.length === 0) return null; return ( @@ -794,62 +851,85 @@ function TagChipRow() { : `${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. */} + {/* One wrapping flow, phone-first: a group is a disclosure — its label + over its chips — and wraps as a unit, so a narrow viewport gets one + group per line rather than a torn row, and a reader on a phone can + fold a group away to get the results back on screen. */} <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)} + {groups.map((group) => { + const key = group.id || "ungrouped"; + const chips = group.tags.map(renderChip); + // The trailing ungrouped bucket has no label, so there is nothing to + // put in a summary and nothing to name what folding it away hides: + // it stays a plain wrapping row. + if (!group.label) { + return ( + <div + key={key} + data-testid="tag-chip-group" + data-group-id={group.id} + className="flex flex-wrap items-center gap-1.5 min-w-0" + > + {chips} + </div> + ); + } + const isOpen = !collapsedTagGroups.has(key); + // What the summary has to say when the chips are hidden: a folded + // group that is narrowing the results must still admit it, or the + // reader is left with a short list and no visible cause. + const selectedCount = group.tags.reduce( + (n, tag) => (draftTags.has(tag.id) ? n + 1 : n), + 0, + ); + return ( + <details + key={key} + open={isOpen} + data-testid="tag-chip-group" + data-group-id={group.id} + className="min-w-0 max-w-full" + > + <summary + onClick={(e) => { + // Drive the open state from React rather than the browser's + // default toggle — same rationale as the channel group + // chips above. + e.preventDefault(); + toggleTagGroup(key); + }} + className="cursor-pointer select-none flex items-center gap-1.5 py-1 text-xs text-muted-foreground min-w-0" + title={ + isOpen + ? `Hide the ${group.label} tags` + : `Show the ${group.label} tags` + } + > + <ChevronRightIcon + aria-hidden="true" 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 }} - /> + "size-3.5 shrink-0 transition-transform", + isOpen && "rotate-90", )} - <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 className="font-medium text-foreground truncate"> + {group.label} + </span> + {selectedCount > 0 && ( + <span + data-testid="tag-group-selected" + className="shrink-0" + > + {selectedCount} selected </span> - </button> - ); - })} - </div> - ))} + )} + </summary> + <div className="flex flex-wrap items-center gap-1.5 min-w-0 pt-1.5"> + {chips} + </div> + </details> + ); + })} {unpublished.length > 0 && ( <div data-testid="tag-chip-group" diff --git a/export/e2e/inline-channel-chips.spec.ts b/export/e2e/inline-channel-chips.spec.ts @@ -116,6 +116,17 @@ async function installRoutes(page: Page) { generatedAt: new Date().toISOString(), }); }); + // No curated tags: this fixture is about channel chips, and the tag groups + // are <details> too — a real /tags.json from the dev server's public/ would + // put open tag disclosures inside the same "details details" the + // expand-nothing assertions below count. + await page.route("**/tags.json", async (route) => { + await route.fulfill({ + status: 404, + contentType: "application/json", + body: "{}", + }); + }); } async function waitForHydration(page: Page) { diff --git a/export/e2e/tag-chips.spec.ts b/export/e2e/tag-chips.spec.ts @@ -50,6 +50,21 @@ function chip(page: Page, id: string) { return page.locator(`[data-testid="tag-chip"][data-tag-id="${id}"]`); } +function tagGroup(page: Page, groupId: string) { + return page.locator( + `[data-testid="tag-chip-group"][data-group-id="${groupId}"]`, + ); +} + +// Fold/unfold by clicking the group label in its summary — the same gesture +// the channel group chips take (channel-group-chips.spec.ts). +async function collapseTagGroup(page: Page, groupId: string, label: string) { + await tagGroup(page, groupId) + .locator("summary") + .getByText(label, { exact: true }) + .click(); +} + function card(page: Page, id: string) { return page.locator(`[data-result-slug="${slugOf(id)}"]`); } @@ -90,6 +105,44 @@ test.describe("curated tag chips", () => { await expect(chip(page, TAG_COLLAB)).toHaveAttribute("aria-pressed", "false"); }); + test("a tag group collapses and its selection count survives on the summary", async ({ + page, + }) => { + // A site can publish several groups of several chips each, and on a + // phone that row is the whole viewport before a single result. Each + // group is a <details>, open by default — folding one away must not + // hide that it is still narrowing the list, so the count moves to the + // summary. + const group = tagGroup(page, "eva"); + await expect(group).toHaveAttribute("open", ""); + await expect(chip(page, TAG_COLLAB)).toBeVisible(); + // Nothing selected: no count on the summary, not a zero. + await expect(group.getByTestId("tag-group-selected")).toHaveCount(0); + + await chip(page, TAG_COLLAB).click(); + await expect(group.getByTestId("tag-group-selected")).toHaveText( + "1 selected", + ); + + await collapseTagGroup(page, "eva", "Eva"); + await expect(group).not.toHaveAttribute("open"); + await expect(chip(page, TAG_COLLAB)).toBeHidden(); + // Still visible, still saying what it is doing to the results. + await expect(group.getByTestId("tag-group-selected")).toHaveText( + "1 selected", + ); + + // ...and the selection survives the fold, chip state included. + await collapseTagGroup(page, "eva", "Eva"); + await expect(group).toHaveAttribute("open", ""); + await expect(chip(page, TAG_COLLAB)).toHaveAttribute( + "aria-pressed", + "true", + ); + await apply(page); + await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); + }); + test("a tagged card shows its tags; an untagged one shows none", async ({ page, }) => { @@ -293,6 +346,27 @@ test.describe("curated tag chips", () => { await expect(trigger).toContainText("1"); await expect(card(page, VIDEO_TRANSCRIPT_ONLY)).toHaveCount(0); }); + + test("a group folds away inside the sheet", async ({ page }) => { + // 390px is where a multi-group row costs the most, so the fold has to + // work in the sheet and not only in the inline panel. + await installRoutes(page); + await installTagRoutes(page); + await page.goto("/"); + await waitForHydration(page); + + await page.getByTestId("filters-trigger").click(); + const group = tagGroup(page, "eva"); + await expect(chip(page, TAG_COLLAB)).toBeVisible(); + + await collapseTagGroup(page, "eva", "Eva"); + await expect(group).not.toHaveAttribute("open"); + await expect(chip(page, TAG_COLLAB)).toBeHidden(); + // The summary is still one line inside the sheet, not a torn row. + const box = await group.locator("summary").boundingBox(); + expect(box).not.toBeNull(); + expect(box!.width).toBeLessThanOrEqual(390); + }); }); test.describe("on a site that publishes none", () => {