commit 7811b9d555af81729d0021919ceac30c9394b534
parent c9461ecfc74d5a21a96964b39b1f68e48161c117
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 13 Sep 2026 11:09:06 -0400
plans: /channels rack redesign plan
Copy the approved rack redesign plan for the editor /channels page into
the repo so the implementation commits have a tracked reference.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
1 file changed, 301 insertions(+), 0 deletions(-)
diff --git a/plans/editor-channels-rack.md b/plans/editor-channels-rack.md
@@ -0,0 +1,301 @@
+# `/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 `<md` the sticky bar pins to
+ the table's `overflow-x-auto` box, not the screen. The storage bar's root input truncates and
+ its button label (`Move media to {root}`) is cut to "Move media to…".
+- **Rows are ~90 px.** The Tier cell is a four-line stack (select, chips, held note, Advanced),
+ and with a corpus-wide focus active the orange `Held — focus: omnimirror` note repeats on 60+
+ rows: the most repeated element carries the least per-row information. 67 rows ≈ 6,000 px.
+- **The table overflows the viewport.** At 1440 px (1216 px of content beside the sidebar) the
+ six band columns and the Actions column sit off-screen; the wrapper is `overflow-visible` on
+ `md+`, so it spills rather than scrolls.
+- **The header has no hierarchy.** Four dark pills ("Sync every channel", "Full sweep every
+ channel", "Update all reports", "New channel") share one weight.
+- **The page's honesty lives in its footer.** The band legend, the Names·A/T explainer and the
+ report-age note (`data-testid="channels-freshness"`) are stacked in 11 px type under the
+ floating bar.
+
+Decisions taken with the operator 2026-09-13: full-page rethink; a corpus-wide focus is stated
+**once** above the table and each held row shows a small chip with the full reason as
+screen-reader text.
+
+## The design plan
+
+**Subject.** An operator's console for a 67-channel corpus. Its single job: see every channel's
+state in one glance and act on a few. The vocabulary already in the product is the instrument:
+"transit line", "stations", "bands", "rail/strip", "runner", "lane". The page should read as a
+**rack** — a scrolling column of channel strips under a fixed meter-bridge header, with the
+selection controls docked below like a console's master section — not a document with widgets
+stapled to its top and bottom.
+
+**Colour.** No new colour. The editor runs the neutral base family from
+`common/styles/tokens.css`; the page keeps it and keeps the band rule that `reachable`
+(`--info` #2563eb) is the **only** saturated colour. The palette in use:
+
+| role | token |
+|---|---|
+| chrome (header, toolbar, deck) | `bg-card/95 backdrop-blur border-border` — the channel page's sticky-header idiom (`[slug]/layout.tsx:43`) |
+| rack ground | `bg-background`, rows `border-t border-border`, meter bridge `bg-surface` (#f7f7f8 / #141416) |
+| identity | `text-foreground` slug in `font-mono`; name in `text-muted-foreground` |
+| the one accent | `--info`, via `StateBand` only |
+| attention | `--warning` for held chips, stale/missing report; `--destructive` only for unreachable media |
+
+**Type.** Source Sans 3 body, Source Code Pro for slugs, counts and the editor's signature
+micro-caps eyebrow (`font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground`,
+already the nav-group and lane-strip label style). Eyebrows are the structural device on this
+page: they name the *verb groups* in the deck (Tier · Focus · Media), the *corpus actions* in
+the header, and the *pipeline* block in the table head. They encode something true: these are
+groups of controls, not decoration. Tabular numerals (`tabular-nums`) on every count and date.
+
+**Layout.** On `md+` the page stops scrolling the document. It is a flex column the height of
+the viewport: header → focus line → instrument bar → **a scroll region holding the table** →
+deck (only with a selection). Inside the region the `<thead>` 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 `<md` the document scrolls as
+today and the deck is `sticky bottom-0` **outside** the overflow wrapper, so it pins to the
+screen.
+
+```
+┌ sidebar ┐┌────────────────────────────────────────────────────────────────────────┐
+│ ││ Channels EVERY CHANNEL │
+│ ││ 67 channels · oldest report Aug 27, 5:58 PM [Sync every] [Full sweep]│
+│ ││ (channels-freshness, moved up) [Update reports] ▮New ch.▮│
+│ ││────────────────────────────────────────────────────────────────────────│
+│ ││ FOCUS omnimirror · 61 held [site ▾] [Focus site] [End focus] │
+│ ││────────────────────────────────────────────────────────────────────────│
+│ ││ [x] Group by section PIPELINE ▪ done ▮ can run ░ blocked … │
+│ ││┌─────────────────────────────────────────────────────────────────────┐│
+│ │││☐ Slug Name Handling Build Tier Playlist Last sync … ││ sticky thead
+│ │││☐ chibi-rev… Chibi R… youtube ●Incl. Normal▾ 9658 Sep 11 10:24 ││
+│ │││☑ chrissie-m Chrissie youtube ●Incl. Normal▾ [held] 3528 … ││ 40px rows
+│ │││ … (scrolls vertically; ☐ + Slug sticky left; bands scroll right) ││
+│ ││└─────────────────────────────────────────────────────────────────────┘│
+│ ││┌ deck (docked, only with a selection) ───────────────────────────────┐│
+│ │││ 2 selected │ TIER [Normal▾] [Apply tier] │ FOCUS [Focus these] ││
+│ │││ │ MEDIA [/mnt/platter/archilyzer-media] [Move media] ││
+│ │││ Queued 1 · skipped 1 · view jobs [Clear] ││
+│ ││└─────────────────────────────────────────────────────────────────────┘│
+└─────────┘└────────────────────────────────────────────────────────────────────────┘
+```
+
+Mobile (`<md`):
+
+```
+│ Channels │
+│ 67 channels · oldest report … │
+│ EVERY CHANNEL │
+│ [Sync every channel] │
+│ [Full sweep every channel] │
+│ [Update all reports] ▮New channel▮│
+│ FOCUS omnimirror · 61 held │
+│ [site ▾] [Focus site] [End focus] │
+│ ┌ table, overflow-x-auto ───────┐ │
+│ │☐ slug (sticky left) | … → │ │
+│ └───────────────────────────────┘ │
+│ ┌ deck sticky bottom-0 ─────────┐ │ ← outside the overflow wrapper
+│ │ 2 selected [Clear] │ │
+│ │ TIER [Normal▾] [Apply tier] │ │
+│ │ FOCUS [Focus these] │ │
+│ │ MEDIA [root……] [Move media] │ │
+│ └───────────────────────────────┘ │
+```
+
+**Signature.** The **meter bridge**: the six band columns are one block, not six loose grey
+dashes. Their header cells share one eyebrow (`PIPELINE`) with the legend swatches inline; their
+body cells sit on `bg-surface` with a 1 px `border-l border-border` on the first, so the
+strips read as one instrument across the row. Nothing about `StateBand` changes (no new fill,
+no animation at `strip` scale). This is the one place the page spends its boldness; the deck,
+header and rows are quiet.
+
+**Self-critique before building.** The generic answer to "messy table page" is cards, a
+search box, a filter chip row and a floating action button. None of that: the IA record rules
+out cards (columns must stay locked for cross-group comparison), there is no search or filter
+on the page today and none is asked for, and a FAB would be a third selection mechanism. The
+rack-with-docked-deck is specific to this page's real problem (16 columns × 67 rows plus two
+kinds of bulk verb) and reuses the product's own instrument vocabulary. The eyebrows are the
+editor's existing signature move, not an import. The one risk taken (the bridge) is layout,
+not colour, so it survives all eight token families.
+
+## Rules this must honour (from the plans, verified)
+
+- Every e2e accessible name on the page is **unchanged**; only DOM position and styling move.
+ The pinned set: `select all channels`, `select {slug}`, `channel priority bulk`, `bulk tier`,
+ `Apply tier`, `Focus these`, `channel focus`, `focus site`, `Focus site`, `End focus`,
+ `bulk media root`, `move media for selected channels`, `bulk media move result` (+ `title`),
+ `bulk media move error`, `priority bulk error`, `held reason for {slug}` (must **contain the
+ full text** `Held — focus: …`; `toHaveCount(0)` when not held), `focused-{slug}`,
+ `tier for {slug}`, `advanced priority for {slug}`, `{op} override for {slug}`,
+ `sync only for {slug}`, `report age for {slug}`, `playlist count for {slug}`,
+ `{op} count for {slug}` (sr-only text beside the band), `toggle build inclusion for {slug}`,
+ `sort by {label}` + `aria-sort` on the `<th>`, `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 `<select>`; the five per-operation overrides stay behind the `Advanced` disclosure;
+ one writer (`saveChannelPriorityAction`); focus is corpus-wide and needs no selection.
+- `MediaLocationBadge` appearances unchanged (nothing on in-place rows).
+- Row dimming keys off `excludeFromBuild || tier === "paused"` only.
+- Selection is a `Set` of slugs intersected with what is on screen; survives sort/group toggle.
+- `StateBand`: exactly one saturated colour, fill pattern first, no animation at `strip`.
+- The freshness note is an obligation, not decoration; the Names·A/T explainer stays.
+- No sort/group state in the URL.
+
+## Changes, by file
+
+All under `editor/app/channels/` unless noted. New components go in `components/`. Add by
+path, commit per step, tsc green at every commit.
+
+### 1. `components/ChannelSelectionDeck.tsx` (new) — replaces both bars
+
+- Props: `slugs`, `onClear`, `defaultRoot`. Returns `null` when `slugs.length === 0`.
+- One root element `aria-label="channel priority bulk"` (keeps the spec's handle; the storage
+ controls are inside it, addressed by their own labels). Chrome:
+ `border-t md:border md:rounded-md border-border bg-card/95 backdrop-blur shadow-lg
+ motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2`.
+- Row 1, `flex flex-wrap items-center gap-x-4 gap-y-2`: `{n} selected` (`font-medium
+ tabular-nums`), then three groups each `flex items-center gap-2 pl-4 border-l border-border`
+ with an eyebrow (`TIER`, `FOCUS`, `MEDIA`): Tier = `<select aria-label="bulk tier">` +
+ **Apply tier**; Focus = **Focus these**; Media = `<input aria-label="bulk media root">`
+ (`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 `<table>` —
+ it would become the sticky ancestor and defeat the sticky header; rounded corners come from
+ the region.
+- **Sticky header.** `<thead className="sticky top-0 z-20 bg-muted">`. 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 `<th>/<td>` `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 `<th>`s: a `<th colSpan={columns.length}>` 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 `<td>` 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 = `<BandLegend />` plus, behind a `<details>` 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`.
+- **`<md`.** The region is `overflow-x-auto -mx-4` as today (no fixed height; the document
+ scrolls); the deck sits after the wrapper with `sticky bottom-0 z-20`.
+
+### 3. `components/ChannelTierSelect.tsx` — one line
+
+- Root becomes `flex flex-wrap items-center gap-1.5` (not a vertical stack); `min-w-36` →
+ `min-w-32`. Order: `<select aria-label="tier for {slug}">` (h-7, text-xs), `Focused` chip
+ (`data-testid="focused-{slug}"`, unchanged), `N pinned` chip (unchanged), the **held chip**,
+ the `Advanced` `<details>` 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 = `<h1>` (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 `<md` (still the same element and label).
+
+### 7. Nothing else moves
+
+No server action, no `actions.ts`, no `common/` change, no e2e spec renamed. `MediaLocationBadge`,
+`ChannelBuildToggle`, `ChannelGroupHeaderRow`, `ChannelGroupLine`, `PipelineCell`, `StateBand`,
+`BandLegend` are reused as they are (only classes on their host cells change).
+
+## Verification
+
+1. `pnpm -r exec tsc --noEmit` clean; `pnpm --filter yt-dlp-transcript-common test` unchanged
+ (1159 on `main`); `pnpm --filter editor exec next build` clean.
+2. Editor e2e, from a worktree (never the primary checkout), the channel set first:
+ `channels.spec.ts`, `channels-sort.spec.ts`, `channels-counts.spec.ts`,
+ `channels-actions.spec.ts`, `channel-priority.spec.ts`, `channel-storage.spec.ts`,
+ `channel-groups.spec.ts`, `channel-work.spec.ts`, `channel-build-toggle.spec.ts`,
+ `site-scope.spec.ts`, `navigation.spec.ts`, `perf-budget.spec.ts`; then the FULL suite
+ (533 on `main`; known flakes `video-page.spec.ts:216`, `backfill.spec.ts:457`). Detached
+ behind the queue lock, watched with Monitor.
+3. Visual check against the "before" PNGs: re-run the screenshot script against the
+ worktree's dev server (its own port block; a copy of the e2e fixture corpus, never
+ `transcripts/`) at 1440×900 and 390×844, with and without a selection, plus one row's
+ Advanced open. Confirm: one deck; rows ≈ 40 px; header and identity column stay put while
+ the bands scroll; the deck reachable on mobile without scrolling the table; keyboard focus
+ rings visible on every control; `prefers-reduced-motion` disables the deck's entry animation.
+4. Accessibility: tab order header → focus line → instrument bar → table → deck; the held
+ chip's accessible name reads the full reason; the sticky cells do not trap focus.
+5. Record: `editor/CHANGELOG.md` entry; a "Shipped" section appended to this file with the
+ before/after screenshots' paths and the one-paragraph rationale (rack + docked deck +
+ meter bridge), and the e2e counts.