# `/channels` rethink: one instrument, one deck Pinned to `main` @ `a1058d0` (2026-09-13). ## Context `/channels` is the editor's channel console: 67 rows, 16 columns, three bulk mechanisms and a pipeline band per operation per row. Two features landed on it in the 2026-09 integration without a design pass, and the operator's first use of the merged page found it messy: - **Two selection bars.** `ChannelBulkBar` (priority) is `sticky bottom-0` and floats at the viewport edge; `ChannelStorageBulkBar` is a plain block after the 67-row table, reachable only at the very bottom. Both say "N selected", both have Clear. On `` is `sticky top-0` and the checkbox + Slug cells are `sticky left-0`, so the identity column and the meter bridge header never leave the screen while 16 columns scroll horizontally. On ``, `group-name`, `rowheader`, `Group by section`, `sync group {name}` etc., `sync every channel` / `sync all result`, `update all reports` / `update all reports result`, `New channel` link, `channels-freshness`, `Active site`, the h1 `Channels`, and the eight fixed header strings Slug, Name, Handling, Build, Tier, Playlist, Last sync, Report. - `channel priority bulk` and `bulk media root` must have **count 0 with an empty selection** and the root box must carry `settings.storage.mediaRoot` as soon as anything is selected — so the deck unmounts when empty and shows all three verb groups at once (no mode switch). - The storage verb keeps **no preview gate** and one root for the batch (`ChannelStorageBulkBar.tsx:8-20`); `Queued N · skipped M` with reasons on `title`. - Tier is a `` + **Apply tier**; Focus = **Focus these**; Media = `` (`font-mono w-72 md:w-80`, seeded from `defaultRoot`) + button `aria-label="move media for selected channels"` whose **visible text is `Move media`** (the root is in the box beside it; the old `Move media to {root}` label is what truncated) + `Queueing…` while pending; then `ml-auto` **Clear**. - Row 2 (only when there is something to say): the storage result span (`aria-label="bulk media move result"`, `Queued N · skipped M · view jobs`, reasons on `title`), the storage error (`role="alert" aria-label="bulk media move error"`), the priority error (`role="alert" aria-label="priority bulk error"`). - Behaviour moved verbatim from the two old components: `setChannelTierAction` and `focusChannelsAction` call `onClear` on success; the storage move does not; the storage header comment about "no preview gate" moves with it. - Delete `ChannelStorageBulkBar.tsx` and the `ChannelBulkBar` export from `ChannelBulkBar.tsx` (keep `ChannelFocusBar` there, or move it to its own file — see step 4). ### 2. `components/ChannelsTable.tsx` — the rack - **Structure.** The outer wrapper becomes `flex flex-col min-h-0` (it is inside the page's flex column, step 5). Order: instrument bar → scroll region → deck. The deck is a sibling of the scroll region, never inside the `overflow` element. - **Scroll region.** `div.relative.min-h-0.flex-1.overflow-auto.md:rounded-md.md:border .border-border` (border-y only below `md`). Drop `md:overflow-hidden` from the `` — it would become the sticky ancestor and defeat the sticky header; rounded corners come from the region. - **Sticky header.** ``. Group header rows (`ChannelGroupHeaderRow`) get `sticky top-[var(--thead-h)] z-10` on `md+` so a section's name stays visible while its rows scroll (measure `--thead-h` once with a ref; fall back to a fixed `top-9`). - **Sticky identity.** The checkbox `` drops `bg-muted/40` and its `
/` `sticky left-0 z-10` and the Slug cells `sticky left-8 z-10`, both with `bg-muted` (head) / `bg-background` (body) and a `shadow-[1px_0_0_var(--color-border)]` right edge so they read as pinned when the rest scrolls. Rows keep `border-t border-border`; selected rows get `bg-accent/60`. - **Row density.** Cells `px-2 py-1.5 align-middle` (was `px-3 py-2`); target row height 40 px with a closed Tier cell. `Td` for dates: `whitespace-nowrap tabular-nums text-xs text-muted-foreground`, formatted `toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" })` (`Sep 11, 2026, 10:24 AM`). Report cell keeps `stale`/`missing` in `text-warning`. Playlist `text-right tabular-nums`. Handling becomes a mono micro-tag (`font-mono text-[11px] text-muted-foreground`), same text. - **Meter bridge.** The pipeline ``s: a `` is NOT added (it would change `columnheader` semantics); instead each band header keeps its own sortable button and the FIRST band header carries the eyebrow `PIPELINE` above its label and the legend swatches render inline in the instrument bar (step 3). Body: each band `` gets `bg-surface`, the first `border-l border-border`, the last `border-r border-border`; width `w-24 min-w-20`. `PipelineCell` unchanged (sr-only count + `StateBand size="strip"`). - **Instrument bar** (the row above the region): `flex items-center justify-between gap-3 px-1 pb-2 text-xs text-muted-foreground`: left = the existing `Group by section` checkbox (when sections exist); right = `` plus, behind a `
` labelled `Names·A / Names·T`, the two-route explainer paragraph (text unchanged). - **The deck** replaces lines 446–454; the legend block at 455–464 is dissolved into the instrument bar. `colSpan` stays `10 + columns.length`. - **`` (h-7, text-xs), `Focused` chip (`data-testid="focused-{slug}"`, unchanged), `N pinned` chip (unchanged), the **held chip**, the `Advanced` `
` summary (unchanged label/aria; its open panel is `absolute z-30 mt-1 rounded-md border border-border bg-popover p-2 shadow-md w-64` inside a `relative` wrapper so opening it does not reflow the row — it overlays the rows below). - **Held chip.** Replaces the orange `role="note"` line. Keeps `role="note"` and `aria-label="held reason for {slug}"`; visible text is a warning pill (`rounded-full border border-warning/30 bg-warning-soft text-warning px-1.5 text-[10px] uppercase tracking-wide`) reading `held`, plus an `sr-only` span holding the **full reason text** (`Held — focus: site Focusable`) and the same on `title`. `textContent` therefore still contains the pinned substring. Rendered only when `heldReason` is set (so `toHaveCount(0)` holds). - The error span stays (`text-destructive text-xs`), wrapping to a second line only on error. ### 4. Focus line — `ChannelFocusBar` (in `ChannelBulkBar.tsx`, or moved to `ChannelFocusBar.tsx`) - Keeps `aria-label="channel focus"`, the `focus site` select, **Focus site**, **End focus**, `focus error`, and the `Focus: {label}` / `No focus` text (the spec asserts `toContainText("Focus: site Focusable")` and `"No focus"`). - New prop `heldCount` (computed in `ChannelsTable` as the number of rows whose `priority.heldReason` is set): renders ` · {n} held` after the label when a focus is active. - Chrome: a single line, `flex items-center gap-3 px-1 py-1.5 text-sm` with an eyebrow `FOCUS` first; no card border (it is a statement, not a panel); `border-b border-border` under it. Buttons `h-7 text-xs`. ### 5. `page.tsx` — header hierarchy and the honesty line - Page root: `flex flex-col gap-3 md:h-[calc(100vh-3rem)] md:min-h-0` (main has `py-6`; the sidebar is `md:h-screen`, so the content column matches). Below `md` no fixed height. - Header: left = `

` (unchanged) + the freshness paragraph **moved here** as the subtitle (`data-testid="channels-freshness"`, same text, `text-xs text-muted-foreground`, prefixed with `{count} channels · `). Right = a cluster: eyebrow `EVERY CHANNEL` above `SyncAllChannelsButton` + `RefreshAllReportsButton` restyled as **outline** buttons (`h-8 text-xs border border-border bg-transparent hover:bg-accent`), then the `New channel` link as the **only** primary (`bg-primary text-primary-foreground`). Button names and result spans unchanged (`sync all result`, `update all reports result`). - The empty state (`No channels yet.`) unchanged. - The freshness paragraph is removed from the bottom (it moved, not duplicated). ### 6. Header buttons — `SyncAllChannelsButton.tsx`, `RefreshAllReportsButton.tsx` - Accept a `variant` or simply take the outline classes; the inline result span moves to a `title`-less line under the cluster on ``s carry the block's opening and closing rules too, not only the body cells — otherwise the bridge's vertical rules start below the header and the block reads as broken. - `ChannelGroupHeaderRow`'s `

` takes solid `bg-muted`: a sticky cell cannot be translucent or the rows scroll visibly through it. - The header result spans keep their `title` (the skip reasons are an obligation, not decoration); the cluster wraps them to their own line rather than stripping the attribute. - `ChannelFocusBar` moved to its own file and exports `useBarAction`, which the deck shares — the plan allowed either. - The header is `md:flex-nowrap` with the SUBTITLE shrinking, because the freshness paragraph is three lines and not a tagline: at its `max-w-3xl` ceiling it claimed 768px of a 1216px column, the header wrapped, and the corpus-wide cluster dropped onto a band of its own — the undifferentiated row of pills this change exists to remove. The left block is `md:flex-1 min-w-0` so the paragraph rewraps into what is left (the cap stays a ceiling, never a floor) and the cluster keeps its intrinsic width top-right. Header 80px at 1440 instead of ~150. Below md it still wraps and stacks.