commit b06598203b622a6b6ea5855c27599c3cb0675bac
parent 57247cbfce83bfbe37e06f2b9e481fafb4acf09a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 13 Sep 2026 12:11:44 -0400
channels: record the rack redesign
A CHANGELOG entry for the /channels rework, and a Shipped section on the plan
carrying the commit range, the before/after screenshot paths, the rationale
(rack + docked deck + meter bridge), the measurements the rendered page gave
back, the e2e counts, and the six places the build diverged from the plan.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
2 files changed, 76 insertions(+), 0 deletions(-)
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
+- **`/channels` is a rack now, with one selection deck and a meter bridge.** The page had two selection bars for one selection — a floating one for Tier and Focus, and a second block below sixty-seven rows for Move media, both saying "N selected" and both offering Clear. There is **one deck**: it docks under the table when you tick a row, carries **Tier**, **Focus** and **Media** side by side, and unmounts when you untick. The destination root lives in its own box beside the button (the button used to carry it in its label, where it truncated to *Move media to…* and you could not read where the files were going). **The table stops spilling off the screen.** It lives in one scroll region: the column headers pin to its top, the checkbox and slug cells pin to its left, and a section's name pins under the headers — so the identity column and the meter bridge header stay on screen while sixteen columns scroll sideways. The six pipeline columns read as **one block** rather than six loose dashes: a shared *Pipeline* eyebrow, a surface behind them, a rule at each end. **Rows are 41 px instead of ~90.** The tier cell is one line, and being held by a focus is a small **held** chip rather than the same orange sentence repeated on sixty-one rows — the sentence is stated once, with a count, on the focus line above the table, and each chip still carries the full reason for a screen reader and on hover. Opening a row's *Advanced* overlays the rows below instead of pushing them down. **The page's caveat is at the top.** The note saying every number here is read from each channel's last report, and how old the oldest one is, used to be the last thing on the page in 11 px type under a floating bar; it is the subtitle beside the title now, with the channel count. The band legend and the Names·A / Names·T explainer moved above the table too, beside *Group by section*. In the header, *Sync every channel*, *Full sweep every channel* and *Update all reports* are outlines under an **Every channel** eyebrow that says what they sweep, and **New channel** is the only filled button. Nothing on disk moved and no control changed its name.
- **A channel's media can live on another drive.** A channel page has a **Storage** panel: where its media actually is, how much audio is on disk, how much room is free on the volume holding it, and **Move media to…** — give it a directory on another disk, press *Preview* to see the bytes and the free space there, and the move copies, **verifies**, and only then swaps `data/` for a link to the new location and records it. **Move back in place** reverses it. The source is never touched until the copy has verified, so a cancelled or crashed move leaves everything where it was and the partial copy resumable; re-running finishes it. Nothing else changes: every page, every job, yt-dlp and the search index read the channel exactly as before, because the path they use is unchanged. **The point is what happens when the drive is not mounted.** `data/` reads as empty then, and an empty `data/` means "nothing has been downloaded" to the download runner — an instruction to re-fetch the entire channel onto the disk that was too full to hold it. So an unreachable channel is **refused rather than guessed at**: its media jobs will not start, the four lane runners skip it (and keep running every other channel — this is not a lane stop), its report will not regenerate over an empty directory, and a red **Media unreachable** badge names the path on `/channels`, on the dashboard and on the channel itself. A relocated-and-reachable channel gets a neutral badge saying where; a channel in place gets none. The low-disk floor now measures **the volume the bytes are actually going to** rather than always the corpus disk, and holds each volume separately — a full SSD no longer pauses downloads landing on the platter. The **Media location** line on a channel's Configure form is read-only on purpose: it is a record of what is on disk, written only by a move that succeeded. The cold drive is typed **once**: **Settings → Default media root** seeds the root box in every channel's Storage panel, and `/channels` rows can now be ticked — select several and **Move media to…** queues one job per channel on that channel's own queue, so they serialize instead of fanning out, each one running its own space check at run time rather than at enqueue time (a root that fills partway through refuses the remainder cleanly, and a channel already on that root is skipped rather than failed). The default is a default and nothing more: it is never read by the move itself, which always takes an explicit root, and a relocated channel is not thereby deprioritized. **Nothing moves on its own, and nothing on disk changes until you move a channel.**
- **Channels have priorities now, and the auto-queue's rules are generated from them.** Focusing on one group of channels — "finish Jeralyzer, hold the rest" — used to mean hand-editing four rule trees, and the only per-channel switch on `/channels` was **Sync included / excluded**, which gated sync and nothing else. Every channel row now carries a **tier** — *Normal*, *Low* or *Paused* — plus a corpus-wide **focus**: pick channels and press *Focus these*, or focus a whole site, and every lane runs the focused channels until they have nothing left, then falls through to the rest and retakes the lane the moment new focused work arrives. A focus is one fact, not four: the download, transcription, digest and speaker lanes are all held by it, and each lane's console carries a banner saying what is focused, how much of it is pending there, how many channels are held behind it, and **End focus**. Behind the disclosure on each row, any single operation can be pinned to its own tier — "keep this channel's playlist current but stop downloading it" is a *download* pin, and *Sync only* is a preset for it. A paused channel is dropped from the automatic lanes and from the sync scheduler, and **still runs from every Run button**: a hold is not a stop. Its row dims and its Build toggle is untouched, because publishing is a different question from scheduling. The four rule trees are **generated** from all of this: the policy editor on an operation's page shows them read-only with a link back to `/channels`, keeps editing everything that is not generated (enable, workers, order, the replace-auto-captions lane), and the channel leaves you had are replaced by the compiled ones. **Sync included / excluded is gone**, and it is the same statement said better: the 15 channels that carried it become *paused for sync alone* and keep every lane they were on. **Run the migration before you first start this version.** `Sync included / excluded` is a deleted field, and until the migration has moved those 15 channels to *paused for sync*, the editor reads them as having said nothing about sync — so they are back in the schedule, back in **Sync every channel**, back in each group's **Sync**, and shown as auto-sync eligible. Nothing downloads or transcribes differently, and the automatic tick only fires if your scheduler heartbeat is on, but a *Sync all* click in that window sweeps channels you had excluded. The order is: **stop the editor → `pnpm -C common exec tsx bin/migrate-channel-priority.ts` → start it again.** Run it with `--dry-run` first to see exactly what it would write, per channel, and what each row was derived from; the real run takes its own timestamped backup of `settings.json` beside the file, so there is nothing to copy by hand. After that it is a no-op — run it twice and the second run changes nothing. Your rule trees survive either way: they are what the migration reads the channel order out of, and if you set a tier before running it, the first save seeds itself from those same trees rather than replacing the order you hand-built.
- **Every pipeline is dispatched by one thing now: its lane’s runner. The two corpus sweeps and the arbiter are gone.** Digest and Speaker work were driven by a *sweep* — a corpus walk armed by its own switch, with its own scope, its own order and its own console — while Download and Transcription were driven by the auto-queue runner, with rules, a claim ladder, a next-up and a pick log. Two mechanisms, two vocabularies, two sets of bugs. There is one: **each of the four lanes has a runner, a rule list, and Start / Drain / Stop beside its pause**, on the operation’s own page. Arming a corpus pass is switching the lane on; scoping it to particular channels or operations is a *rule*, written the same way auto-transcribe’s have been written since it shipped. The dashboard and the widget keep a one-click switch per lane — **Run every channel** / **Stop the lane** where they said *Sweep every channel* / *Stop sweeping* — and the scope lives on the lane’s page, where you can see what it would do next. **Your armed scope is carried over, and no lane is switched on that was not.** The ten settings fields the sweeps used (`digest.sweepEnabled`, `sweepChannels`, `recencyOrder`, `recencyReach`; `backfill.sweepEnabled`, `sweepKinds`, `sweepChannels`, `order`, `reach`, `weight`) are read once and written into the lane’s rules the first time the editor starts: a sweep armed on three channels becomes three rules, an unscoped one becomes a single *every channel* rule, and a disarmed sweep becomes a switched-off lane. What is retired rather than migrated: **Reach**, because a rule already orders every video it claims across every channel — which rule goes first is the rule list’s job; the digest **order**, whose real meaning was always *newest day first, shortest video within a day* and which the lane spells as **Shortest first** (pick *Newest first* there if you want the date order alone); and the backfill lane’s **Resource share**, which was one number answering two different questions. A lane now stands aside for transcription when it would actually compete for the graphics card, and keeps its slots when it would not — so speaker-naming over an LLM endpoint no longer parks itself behind a transcription it was not competing with. **The arbiter, which never ran a single unit in production, is deleted**; the runner is what dispatches an operation-named rule. **Nothing on disk changes**, and the retired keys are left in `settings.json` — harmless, ignored, and yours to delete.
diff --git a/plans/editor-channels-rack.md b/plans/editor-channels-rack.md
@@ -299,3 +299,78 @@ No server action, no `actions.ts`, no `common/` change, no e2e spec renamed. `Me
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.
+
+## Shipped
+
+Branch `editor-channels-rack`, forked from `main` @ `a1058d0`. Commit range
+`64b5573..<tip>` (`64b5573` the plan, `4bb2a0a` step 1, `278d446` step 2,
+`76a1e1e` step 3, `b57b59b` step 4, `662fdf1` step 5, `75b7f37` step 6,
+`20ee34d` the polish pass the rendered page called for, plus the record commit).
+
+**Rationale.** The page's problem was never that it showed too much — it is a
+console for a 67-channel corpus and every column earns its place — but that it
+had no instrument. Sixteen columns spilled off the right of a 1440px screen
+because the wrapper was `overflow-visible`, one selection had two bulk bars in
+two places, rows were ~90px because the tier cell was a four-deep stack, and the
+page's own caveat sat 6,000px below the numbers it qualified. So: a **rack** —
+one scroll region whose column headers pin to its top and whose checkbox and slug
+cells pin to its left, so the identity column and the meter-bridge header never
+leave the screen while the bands scroll sideways; a **docked deck** — one panel
+under the rack carrying Tier, Focus and Media at once, appearing with a selection
+and unmounting without one, so there is one place to act on what you ticked; and
+a **meter bridge** — the six band columns drawn as one block, with a shared
+`Pipeline` eyebrow, a surface behind them and a rule at each end, which is the one
+place the page spends its boldness. Everything else went quiet: the focus is a
+line, not a card; the held sentence is stated once above the table with a count
+and each row keeps a chip (full reason on `title` and for a screen reader); the
+three pool-wide buttons are outlines under an eyebrow naming what they sweep, and
+`New channel` is the only fill.
+
+**Measured.** Rows 41px (was ~90): 13 channels on screen at 1440×900 where 7 fit
+before. The deck is 48px tall at 1440 and 176px at 390. `prefers-reduced-motion:
+reduce` computes `animation-name: none` on the deck (`enter` otherwise). Tab order
+runs header buttons → focus line → table → deck, with the deck last in the
+document's focus order and the sticky cells trapping nothing.
+
+**Screenshots.** Before (production editor, 67 channels):
+`~/reports/editor-channels-rack/before/` — `ch-1440-full.png`,
+`ch-1440-sel-viewport.png`, `ch-1440-sel-bottom.png`, `ch-1440-advanced.png`,
+`ch-390-full.png`, `ch-390-sel-viewport.png`. After (worktree dev server on 3201
+against a synthesized 34-channel corpus with a live site focus, never
+`transcripts/`): `~/reports/editor-channels-rack/after/` —
+`ch-1440-viewport.png`, `ch-1440-sel-viewport.png`, `ch-1440-sel-bottom.png`,
+`ch-1440-scrolled-right.png`, `ch-1440-advanced.png`, `ch-1440-grouped.png`,
+`ch-1440-sel-reduced-motion.png`, `ch-390-viewport.png`,
+`ch-390-sel-viewport.png`, `ch-390-sel-bottom.png`, plus the `-full` variants.
+
+**Verification.** `pnpm -r exec tsc --noEmit` clean at every commit;
+`pnpm --filter yt-dlp-transcript-common test` 1159 passed (unchanged);
+`pnpm --filter editor exec next build` clean. e2e from this worktree: the channel
+set (`channels`, `channels-sort`, `channels-counts`, `channels-actions`,
+`channel-priority`, `channel-storage`, `channel-groups`, `channel-work`,
+`channel-build-toggle`, `site-scope`, `navigation`, `perf-budget`) **60 passed /
+0 failed** in 3.5 min; the FULL suite **533 passed / 0 failed** in 30.3 min,
+which is `main`'s baseline exactly and includes the two known flakes
+(`video-page.spec.ts:216`, `backfill.spec.ts:457`) passing first time. A fresh
+worktree needs a composed fixture site copied into `export/public` first or the
+export webServer 500s and Playwright dies at 120s (FACTS.md:3295) — that bit the
+first attempt.
+
+**Divergences from the plan, and why.**
+
+- `min-w-32` on the tier cell was too narrow: select + chips + `Advanced` want
+ ~200px, so the cell wrapped and rows measured 63px. `min-w-52` gets them to 41.
+- The checkbox cell renders ~29px wide, so the Slug cell's `left-8` left a
+ sub-pixel gap that showed the scrolled columns through it. The checkbox cell is
+ `w-9` and the Slug cell paints over the overlap (`z-20` over `z-10`) instead.
+- The band `<th>`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 `<tr>` drops `bg-muted/40` and its `<th>` 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.