Archilyzer · Source

archilyzer

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

commit aa017eafdec85e0152a6cb928c7ea036d71d3e15
parent a95751bd705523e73648fbece4fe7dd024d636f5
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 22 Jul 2026 10:18:04 -0400

Channel-group chips v2: uniform responsive chips + per-group inline flag

Layout: collapsed group chips share a uniform min width (sm:min-w-48),
never wrap their own text (names ellipsize with a title tooltip, counts
right-align), stack one per line below sm and pack into rows above.

Inline groups: a group can be flagged `inline` (editor checkbox ->
site.json -> manifest -> viewer) to render its member channels as loose
individual chips — checkbox + name, no expand affordance — flowing among
the normal group chips and excluded from the default-collapse heuristic.
FALLBACK_GROUP seeds the flag on, so no-groups sites' "All channels"
bucket now renders loose chips; manually added and channel-form-created
groups default off. Hub-synthesized site-groups never set it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/components/SearchSessionContext.tsx | 15+++++++++++----
Mcommon/components/WorkspaceSearchBar.tsx | 53++++++++++++++++++++++++++++++++++++++++++++++-------
Mcommon/lib/channelGroups.ts | 11++++++++++-
Meditor/CHANGELOG.md | 1+
Meditor/app/channels/lib/siteMemberships.ts | 4+++-
Meditor/app/sites/components/SiteForm.tsx | 26+++++++++++++++++++++++++-
Meditor/e2e/sites-crud.spec.ts | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/CHANGELOG.md | 3+++
Mexport/e2e/channel-group-chips.spec.ts | 2+-
Aexport/e2e/inline-channel-chips.spec.ts | 240+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
10 files changed, 389 insertions(+), 15 deletions(-)

diff --git a/common/components/SearchSessionContext.tsx b/common/components/SearchSessionContext.tsx @@ -916,10 +916,17 @@ function useSearchSessionState() { if (stored?.collapsedGroups !== undefined) { // User preference honored (a stored [] — all groups open — stays). setCollapsedGroups(new Set(stored.collapsedGroups)); - } else if (channelGroupings.length > 1) { - // Collapse state never touched on a multi-group site: start compact. - // The chips still show selection state, so nothing important is hidden. - setCollapsedGroups(new Set(channelGroupings.map((g) => g.group.id))); + } else { + // Inline groups render as loose chips with no collapse affordance, so + // they're excluded from both the default-collapse set and the >1 + // heuristic (a lone collapsible group among inline chips stays open). + const collapsible = channelGroupings.filter((g) => !g.group.inline); + if (collapsible.length > 1) { + // Collapse state never touched on a multi-group site: start compact. + // The chips still show selection state, so nothing important is + // hidden. + setCollapsedGroups(new Set(collapsible.map((g) => g.group.id))); + } } const search = typeof window !== "undefined" ? window.location.search : ""; const params = new URLSearchParams(search); diff --git a/common/components/WorkspaceSearchBar.tsx b/common/components/WorkspaceSearchBar.tsx @@ -6,6 +6,7 @@ // (and keeps its committed search) across `/` ⇄ `/ask`. All of its state comes // from the shared SearchSession. +import { Fragment } from "react"; import { ChevronRightIcon } from "lucide-react"; import { Button } from "./ui/button"; import { Checkbox } from "./ui/checkbox"; @@ -237,6 +238,36 @@ export default function WorkspaceSearchBar() { </div> <div className="flex flex-wrap items-start gap-1.5"> {channelGroupings.map(({ group, channels }) => { + if (group.inline) { + // Inline group: no collapsible box — each member channel + // renders as its own loose chip flowing among the group + // chips, styled like a collapsed group chip. + return ( + <Fragment key={group.id}> + {channels.map((key) => { + const label = channelLabelByKey.get(key) ?? key; + return ( + <label + key={key} + title={label} + data-testid="inline-channel-chip" + className="flex items-center gap-2 rounded border border-border bg-card/60 px-2.5 py-1.5 text-sm cursor-pointer select-none min-w-0 max-w-full w-full sm:w-auto sm:min-w-48" + > + <span className="flex items-center shrink-0"> + <Checkbox + checked={!draftExcludedChannels.has(key)} + onCheckedChange={(value) => + setSelected([key], value === true) + } + /> + </span> + <span className="truncate">{label}</span> + </label> + ); + })} + </Fragment> + ); + } const isOpen = !collapsedGroups.has(group.id); const selectedCount = channels.reduce( (n, name) => @@ -248,8 +279,8 @@ export default function WorkspaceSearchBar() { key={group.id} open={isOpen} className={cn( - "rounded border border-border bg-card/60 min-w-0", - isOpen && "w-full", + "rounded border border-border bg-card/60 min-w-0 max-w-full w-full", + !isOpen && "sm:w-auto sm:min-w-48", )} > <summary @@ -260,7 +291,7 @@ export default function WorkspaceSearchBar() { e.preventDefault(); toggleGroupCollapsed(group.id, !isOpen); }} - className="cursor-pointer select-none flex items-center gap-2 px-2.5 py-1.5 text-sm" + className="cursor-pointer select-none flex items-center gap-2 px-2.5 py-1.5 text-sm min-w-0" title={!isOpen ? group.description : undefined} > <ChevronRightIcon @@ -277,7 +308,7 @@ export default function WorkspaceSearchBar() { e.preventDefault(); e.stopPropagation(); }} - className="flex items-center" + className="flex items-center shrink-0" > <Checkbox checked={ @@ -294,7 +325,10 @@ export default function WorkspaceSearchBar() { /> </span> {group.name && ( - <span className="font-medium text-foreground inline-flex items-center gap-1.5"> + <span + className="font-medium text-foreground flex items-center gap-1.5 min-w-0" + title={group.name} + > {group.accent && ( <span aria-hidden="true" @@ -302,10 +336,15 @@ export default function WorkspaceSearchBar() { style={{ background: group.accent }} /> )} - {group.name} + <span className="truncate">{group.name}</span> </span> )} - <span className="text-xs text-muted-foreground"> + <span + className={cn( + "text-xs text-muted-foreground shrink-0", + !isOpen && "ml-auto", + )} + > {selectedCount}/{channels.length} <span className="sr-only"> selected</span> </span> diff --git a/common/lib/channelGroups.ts b/common/lib/channelGroups.ts @@ -15,16 +15,22 @@ export type ChannelGroup = { // group header so origin is legible in the channel filter. Absent in // single-site mode. accent?: string; + // Render this group's channels as loose individual chips instead of a + // collapsible group chip; absent = false. + inline?: boolean; }; export const DEFAULT_GROUP_FALLBACK_ID = "default"; // Synthesized fallback used when settings has no groups configured at all, -// so the UI always has a non-empty grouping to render. +// so the UI always has a non-empty grouping to render. Inline by default: +// with no configured grouping the channels render as loose individual chips +// rather than one big collapsible "All channels" box. export const FALLBACK_GROUP: ChannelGroup = { id: DEFAULT_GROUP_FALLBACK_ID, name: "All channels", selectedByDefault: true, + inline: true, }; const ID_RE = /^[a-z0-9][a-z0-9-]*$/; @@ -51,6 +57,9 @@ export function parseChannelGroup(raw: unknown): ChannelGroup | null { if (typeof r.order === "number" && Number.isFinite(r.order)) { group.order = Math.floor(r.order); } + // Absent/false stays absent so serialized JSON stays clean and existing + // configured groups default off. + if (r.inline === true) group.inline = true; return group; } diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **Channel groups gain a "Show channels as individual chips" checkbox on the Site form.** Toggling it on marks the group `inline` in site.json, so the viewer renders each member channel as its own loose chip in the filter row instead of a collapsible group box. New sites' seeded default group ("All channels") ships with it **on**; groups added manually on the Site form or created inline from a channel form default **off**. See `editor/app/sites/components/SiteForm.tsx`, `common/lib/channelGroups.ts`, and `editor/e2e/sites-crud.spec.ts`. - **Cookies-from-browser is now configurable: three cookie modes, per-channel overrides, and a "Needs cookies" bucket.** The single global cookies value used to be hard-wired to one behavior (passed only on the download auth-retry attempt). Settings now carry a **Cookie mode** next to the value — **When required** (default; the old behavior, now also cookie-retrying a failed *metadata prefetch*, so an age-gated video succeeds on its first real attempt and reports `ok-with-cookies` with a `metadata-prefetch-auth-retry` attempt in `download-outcome.json`), **Always** (every yt-dlp invocation carries `--cookies-from-browser`: enumeration, prefetch, availability probes, downloads — for channels whose listing itself needs auth; a clean success still reports plain `ok`), and **Defer** (normal runs never use cookies: known `needs_auth` videos are excluded from sync/download-missing batches — previously they were re-attempted and re-failed on *every* run — and wait for a manual cookie run). Both the value and the mode can be overridden per channel in the channel form's Advanced section (blank/Inherit = use global). Every channel's Download stage gains a **Needs cookies (N)** card listing undownloaded videos whose recorded availability is cookie-recoverable (`needs_auth`, and also `members_only`/`private`, which batches always excluded but a subscribed/owning account's cookies can fetch) with a **Download with cookies** button that re-runs them with cookies forced on every invocation (bookmarkable, like the other bucket jobs; it warns if no cookie value is configured anywhere). Caveats: in defer mode a *manual single-video* download intentionally gets no cookies and no auth retry — the bucket button is the explicit cookie path; availability probes only attach cookies in Always mode, so needs_auth keeps being observed (defer depends on that signal). Back-compat: an existing `settings.json` without `cookieMode` behaves exactly as before (`when-required`). See `common/lib/cookiePolicy.ts`, `common/ytdlp/{downloadOneManaged,runYtdlp}.ts`, `common/controller/{channelSnapshot,checkAvailability}.ts`, and `editor/e2e/cookies-mode.spec.ts`. - **Channel forms now edit site membership directly — pick sites, groups, and create groups inline.** A channel's site membership used to be editable only from the Site form (`/sites/<id>`), and creating a channel silently appended it to the active site (a hidden `activeSite` field) with no group choice and no visibility. Both the **New channel** and channel **Configure** forms now carry a **Sites** section: every configured site listed with a membership checkbox and a compact group dropdown — `(default)`, any existing group, or **"+ New group…"**, which reveals a name input and creates the group on that site (id slugified from the name, reused if it already exists) as part of the save. On create, the active site (`?site=`) is pre-checked, reproducing the old behavior but visibly and overridably; on edit, current memberships pre-check with their groups, and unchecking removes the membership. The checked sites serialize into one hidden `siteMembershipsJson` field (the SiteForm hidden-JSON precedent); the server plans all writes up front — validating group ids, preserving other channels' entries and this channel's `order`, erroring clearly on a since-deleted group, skipping since-deleted sites, and rewriting only sites that actually changed. See the new `editor/app/channels/{lib/siteMemberships.ts,components/SiteMembershipsSection.tsx}`, `editor/app/channels/{actions.ts,components/{ChannelForm,ChannelFormClient}.tsx,new/page.tsx,[slug]/page.tsx}`, and `editor/e2e/channel-site-membership.spec.ts`. - **The monitor widget can now start a sync and shows sync freshness + scheduler health — each behind its own flag.** The widget was start-a-sync-less and said nothing about how fresh your channels were. Five new opt-in URL flags, all toggleable in the builder and the in-widget gear (all default off, so existing links are unchanged): **(1) a channel-aware Sync button** (`sync=1`) — pinned to one channel (`channel=X`) it runs that channel's streaming sync; otherwise it sweeps all channels (`syncAllChannelsAction`), briefly showing `Queued X · skipped Y`. **(2) A last-sync readout** (`lastsync=1`) — `Last full sync: …` from a new persisted `lastSyncAllAt` marker written at the end of each Sync-all sweep, plus a `Last channel sync: …` line when an individual channel synced more recently. **(3) A scheduler-status strip** (`sched=1`) — `Auto-sync on/off · next … · last run …`, derived from the same `buildScheduleView` the `/scheduler` page uses. **(4) An absolute-time toggle** (`abstime=1`) — readouts show locale timestamps instead of relative "5m ago". **(5) A Sync-all confirm** (`syncask=1`) — a `window.confirm` before a full sweep. The two readouts poll a new lightweight `/api/widget/sync` route (a few scalars, 15s floor) and, with the Sync button/controls, keep the widget from collapsing to "Idle" — sync health is exactly what you check when nothing's running. See `editor/app/widget/lib/config.ts`, `editor/app/widget/components/{MonitorWidget,WidgetControls,WidgetConfigForm}.tsx`, the new `editor/app/api/widget/sync/route.ts`, `common/jobs/syncSchedulerState.ts` (`lastSyncAllAt`), `editor/app/channels/actions.ts`, and `editor/e2e/widget.spec.ts`. diff --git a/editor/app/channels/lib/siteMemberships.ts b/editor/app/channels/lib/siteMemberships.ts @@ -92,7 +92,9 @@ export function planSiteMembershipWrites( } // Same slug as an existing group -> reuse it (idempotent resubmits). if (!groups.some((g) => g.id === id)) { - // Matches SiteForm's addGroup default: new groups start unselected. + // Matches SiteForm's addGroup default: new groups start unselected + // and without the inline flag (only the seeded default group renders + // its channels as loose chips) — deliberate, not an omission. groups = [ ...groups, { id, name: request.newGroupName, selectedByDefault: false }, diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -28,6 +28,7 @@ type GroupRow = { name: string; description: string; selectedByDefault: boolean; + inline: boolean; }; // A featured related-sites group: a label + the sibling siteIds picked into it, @@ -40,6 +41,7 @@ function toGroupRow(g: ChannelGroup): GroupRow { name: g.name, description: g.description ?? "", selectedByDefault: g.selectedByDefault, + inline: g.inline === true, }; } @@ -81,6 +83,7 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { description: g.description.trim() || undefined, selectedByDefault: g.selectedByDefault, order: idx, + inline: g.inline || undefined, })), ); const channelsJson = JSON.stringify( @@ -119,7 +122,9 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { const addGroup = () => setGroups((prev) => [ ...prev, - { id: "", name: "", description: "", selectedByDefault: false }, + // Manually added groups default off; only the seeded FALLBACK_GROUP + // starts with inline on. + { id: "", name: "", description: "", selectedByDefault: false, inline: false }, ]); const removeGroup = (idx: number) => setGroups((prev) => { @@ -470,6 +475,25 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { /> Selected by default </label> + <label className="flex items-center gap-2 text-sm"> + <input + type="checkbox" + checked={g.inline} + onChange={(e) => + setGroups((prev) => + prev.map((row, i) => + i === idx ? { ...row, inline: e.target.checked } : row, + ), + ) + } + className="accent-brand" + /> + Show channels as individual chips + </label> + <p className="text-xs text-muted-foreground"> + Renders each member channel as its own loose chip in the + viewer&apos;s filter row instead of a collapsible group box. + </p> </div> ); })} diff --git a/editor/e2e/sites-crud.spec.ts b/editor/e2e/sites-crud.spec.ts @@ -241,6 +241,55 @@ test("edit page loads an existing public URL and featured site", async ({ ).toBeChecked(); }); +test("inline-chips flag round-trips; seeded default group has it on", async ({ + page, +}) => { + await resetData("empty"); + + await page.goto("/sites/new"); + await page.getByLabel(/site id/i).fill("chipsite"); + await page.getByLabel(/site title/i).fill("Chip Site"); + await page.getByLabel(/header title/i).fill("Chip Site"); + + const chipToggles = page.getByRole("checkbox", { + name: /show channels as individual chips/i, + }); + // The seeded default group (FALLBACK_GROUP) ships with the flag on. + await expect(chipToggles).toHaveCount(1); + await expect(chipToggles.first()).toBeChecked(); + + // Manually added groups default off. + await page.getByRole("button", { name: /\+ add group/i }).click(); + await expect(chipToggles).toHaveCount(2); + await expect(chipToggles.nth(1)).not.toBeChecked(); + await page.getByLabel("ID", { exact: true }).nth(1).fill("extra"); + await page.getByLabel("Name", { exact: true }).nth(1).fill("Extra"); + + await page.getByRole("button", { name: /create site/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + + type GroupsSiteFile = { + groups: { id: string; inline?: boolean }[]; + }; + // Poll: the "Saved" status and the on-disk write can land slightly apart. + await expect(async () => { + const site = await readJson<GroupsSiteFile>( + "test-transcripts/sites/chipsite/site.json", + ); + expect(site.groups[0].inline).toBe(true); + // Flag-off groups serialize without the key at all. + expect("inline" in site.groups[1]).toBe(false); + }).toPass({ timeout: 10_000 }); + + // Reload the edit page — checkbox states restore from disk. + await page.goto("/sites/chipsite"); + await expect(chipToggles).toHaveCount(2); + await expect(chipToggles.first()).toBeChecked(); + await expect(chipToggles.nth(1)).not.toBeChecked(); +}); + test("first-run migrate button appears only when no sites exist", async ({ page, }) => { diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -1,5 +1,8 @@ # Changelog +## [Unreleased] +- **Loose channel chips for flagged groups, plus uniform chip layout.** A channel group can now be marked `inline` (site.json → manifest), which renders each of its member channels as its own loose chip in the filter row — a checkbox + name styled like a collapsed group chip, with no expand affordance — flowing in sort order among the normal group chips; inline groups are also skipped by the multi-group default-collapse heuristic, so their channels are always visible. The out-of-the-box **"All channels" bucket now behaves this way**: a site with no configured groups switches from one open full-width card to loose per-channel chips (behavior change; add an explicit group to keep the old card). The chips themselves also get a layout fix: instead of shrink-to-fit chips that could line-break their own text, all collapsed chips share a **uniform min width** (`sm:min-w-48`), never wrap internally — long names ellipsize with the full name as a tooltip — counts right-align inside the chip, and below the `sm` breakpoint chips stack one per line at full width while larger screens pack them into rows. See `common/lib/channelGroups.ts` (`ChannelGroup.inline`), `common/components/{WorkspaceSearchBar,SearchSessionContext}.tsx`, and `export/e2e/inline-channel-chips.spec.ts`. + ## [0.8.1] - 2026-07-22 - **Channel-group filters are now compact chips.** On a site with many small channel groups the filter panel used to stack every group as its own full-width card, so ten groups meant ten rows before you saw a single channel. Collapsed groups now shrink to labeled chips — a chevron, a tri-state checkbox (checked / mixed / unchecked), the group's accent dot + name, and a `selected/total` count — that wrap several per line. The chip's checkbox toggles the **whole group** on or off in one click *without* expanding it (a mixed group's click selects all), and clicking the name expands the group into the familiar full-width card with per-channel checkboxes and All/None. Groups start collapsed on multi-group sites (your own expand/collapse choices still stick); a collapsed chip shows the group's description as a tooltip. Selection stays flat per-channel — profiles, share links, and saved filters are unchanged. See `common/components/{WorkspaceSearchBar,SearchSessionContext}.tsx`, `common/components/ui/checkbox.tsx`, and `export/e2e/channel-group-chips.spec.ts`. diff --git a/export/e2e/channel-group-chips.spec.ts b/export/e2e/channel-group-chips.spec.ts @@ -163,7 +163,7 @@ test.describe("channel-group filter chips", () => { await expect(memberCheckbox(page, "Group One", "Alpha")).toBeHidden(); await expect(memberCheckbox(page, "Group Two", "Gamma")).toBeHidden(); - // Chips shrink-to-fit and pack onto a shared row. + // Uniform min-width chips pack onto a shared row. const one = await groupCard(page, "Group One") .locator("summary") .boundingBox(); diff --git a/export/e2e/inline-channel-chips.spec.ts b/export/e2e/inline-channel-chips.spec.ts @@ -0,0 +1,240 @@ +import { expect, test, type Page, type Route } from "@playwright/test"; + +// Self-contained fixtures exercising inline channel groups +// (common/components/WorkspaceSearchBar.tsx): a group flagged `inline: true` +// renders its member channels as loose individual chips — checkbox + name, +// no collapsible box — flowing among the normal group chips. + +type Chan = { name: string; slug: string; groupId: string }; + +const LONG_NAME = + "Extremely Long Channel Name That Certainly Overflows A Uniform Chip"; + +const CHANNELS: Chan[] = [ + { name: "Epsilon", slug: "epsilon", groupId: "solo" }, + { name: LONG_NAME, slug: "longname", groupId: "solo" }, + { name: "Alpha", slug: "alpha", groupId: "g1" }, + { name: "Beta", slug: "beta", groupId: "g1" }, + { name: "Gamma", slug: "gamma", groupId: "g2" }, +]; + +const GROUPS = [ + { + id: "solo", + name: "Solo", + selectedByDefault: true, + order: 0, + inline: true, + }, + { + id: "g1", + name: "Group One", + description: "First fixture group", + selectedByDefault: true, + order: 1, + }, + { + id: "g2", + name: "Group Two", + description: "Second fixture group", + selectedByDefault: true, + order: 2, + }, +]; + +function summary(c: Chan) { + return { + slug: `${c.slug}/vid-${c.slug}`, + id: `vid-${c.slug}`, + channelSlug: c.slug, + title: `Video from ${c.name}`, + uploadDate: "20250101", + date: "2025-01-01", + duration: "5:00", + channel: c.name, + isLivestream: false, + ageRestricted: false, + isDeleted: false, + isUnlisted: false, + platform: "youtube" as const, + webpageUrl: `https://example.com/vid-${c.slug}`, + }; +} + +async function fulfillJson(route: Route, body: unknown) { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(body), + }); +} + +async function installRoutes(page: Page) { + const list = CHANNELS.map(summary); + await page.route("**/summaries/manifest.json", async (route) => { + await fulfillJson(route, { + version: 3, + totalCount: list.length, + pageSize: 1000, + pageCount: 1, + generatedAt: new Date().toISOString(), + channels: CHANNELS.map((c) => ({ + name: c.name, + count: 1, + groupId: c.groupId, + })), + groups: GROUPS, + defaultGroupId: "solo", + }); + }); + await page.route("**/summaries/page-*.json", async (route) => { + await fulfillJson(route, list); + }); + // Transcripts are never fetched by these tests, but stub the routes so + // nothing falls through to the dev server. + await page.route(/\/transcripts\/[^/]+\/manifest\.json$/, async (route) => { + await fulfillJson(route, { + version: 1, + channelSlug: "unused", + pageCount: 0, + maxPageBytes: 8388608, + generatedAt: new Date().toISOString(), + slugToPage: {}, + }); + }); + await page.route(/\/transcripts\/[^/]+\/page-\d+\.json$/, async (route) => { + await fulfillJson(route, []); + }); + // No live chat. + await page.route("**/subs/manifest.json", async (route) => { + await fulfillJson(route, { + version: 4, + channels: [], + totalCount: 0, + liveChatTotalCount: 0, + generatedAt: new Date().toISOString(), + }); + }); +} + +async function waitForHydration(page: Page) { + await page.getByTestId("query-builder").waitFor(); +} + +// Group cards are <details> nested inside the outer Filters <details>. +function groupCard(page: Page, name: string) { + return page.locator("details details").filter({ hasText: name }); +} + +// A member-channel checkbox inside its group card. +function memberCheckbox(page: Page, group: string, channel: string) { + return groupCard(page, group).getByRole("checkbox", { + name: channel, + exact: true, + }); +} + +// A loose per-channel chip from an inline group. +function inlineChip(page: Page, channel: string) { + return page + .getByTestId("inline-channel-chip") + .filter({ hasText: channel }) + .first(); +} + +test.describe("inline channel chips", () => { + test.beforeEach(async ({ page }) => { + await installRoutes(page); + await page.goto("/"); + await waitForHydration(page); + }); + + test("inline group renders loose chips instead of a group chip", async ({ + page, + }) => { + // Two loose chips, one per Solo member — no <details> card for "Solo" + // and no group bulk-toggle checkbox. + await expect(page.getByTestId("inline-channel-chip")).toHaveCount(2); + await expect(groupCard(page, "Solo")).toHaveCount(0); + await expect( + page.getByRole("checkbox", { name: "Select all in Solo" }), + ).toHaveCount(0); + + // Inline members are visible on first load: the default-collapse + // heuristic only counts collapsible groups (still >1 here, so g1/g2 + // members stay hidden behind collapsed chips). + await expect( + inlineChip(page, "Epsilon").getByRole("checkbox"), + ).toBeVisible(); + await expect(memberCheckbox(page, "Group One", "Alpha")).toBeHidden(); + await expect(memberCheckbox(page, "Group Two", "Gamma")).toBeHidden(); + }); + + test("inline chip toggles its channel without expanding anything", async ({ + page, + }) => { + await expect(page.getByText("5 of 5 channels")).toBeVisible(); + + const box = inlineChip(page, "Epsilon").getByRole("checkbox"); + await expect(box).toHaveAttribute("aria-checked", "true"); + + await box.click(); + await expect(page.getByText("4 of 5 channels")).toBeVisible(); + await expect(box).toHaveAttribute("aria-checked", "false"); + // No chip expanded as a side effect of the toggle. + await expect(page.locator("details details[open]")).toHaveCount(0); + + await box.click(); + await expect(page.getByText("5 of 5 channels")).toBeVisible(); + await expect(box).toHaveAttribute("aria-checked", "true"); + await expect(page.locator("details details[open]")).toHaveCount(0); + }); + + test("uniform chip widths with long names ellipsized", async ({ page }) => { + // Short inline chip and short collapsed group chip share the uniform + // min width. + const epsilon = await inlineChip(page, "Epsilon").boundingBox(); + const g2 = await groupCard(page, "Group Two").boundingBox(); + expect(epsilon).not.toBeNull(); + expect(g2).not.toBeNull(); + expect(epsilon!.width).toBe(g2!.width); + + // Long-named chip carries the full name as a tooltip, never wraps the + // text (same height as a short chip), and stays inside the container. + const long = inlineChip(page, LONG_NAME); + await expect(long).toHaveAttribute("title", LONG_NAME); + const longBox = await long.boundingBox(); + const containerBox = await long.locator("xpath=..").boundingBox(); + expect(longBox).not.toBeNull(); + expect(containerBox).not.toBeNull(); + expect(longBox!.height).toBe(epsilon!.height); + expect(longBox!.width).toBeLessThanOrEqual(containerBox!.width); + }); +}); + +test.describe("inline channel chips on mobile", () => { + test.use({ viewport: { width: 390, height: 844 } }); + + test.beforeEach(async ({ page }) => { + await installRoutes(page); + await page.goto("/"); + await waitForHydration(page); + }); + + test("chips stack one per line at phone widths", async ({ page }) => { + const epsilon = await inlineChip(page, "Epsilon").boundingBox(); + const long = await inlineChip(page, LONG_NAME).boundingBox(); + const containerBox = await inlineChip(page, "Epsilon") + .locator("xpath=..") + .boundingBox(); + expect(epsilon).not.toBeNull(); + expect(long).not.toBeNull(); + expect(containerBox).not.toBeNull(); + + // Stacked: same left edge, different rows, each near full width. + expect(epsilon!.x).toBe(long!.x); + expect(epsilon!.y).not.toBe(long!.y); + expect(epsilon!.width).toBeGreaterThanOrEqual(containerBox!.width * 0.9); + expect(long!.width).toBeGreaterThanOrEqual(containerBox!.width * 0.9); + }); +});