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:
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'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);
+ });
+});