commit 37d9a7de8446734a9529093c9fc473192869ac8c
parent c1ac1d951aae111b592e13bf648eb0c25f68ec1a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 24 Sep 2026 19:50:59 -0400
Merge one-core/phase-3-p — the /channels rack: one layer ladder, the transcribe station counts and queues by id, the audit fixes
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
17 files changed, 942 insertions(+), 84 deletions(-)
diff --git a/common/views/channelGroupSections.test.ts b/common/views/channelGroupSections.test.ts
@@ -13,11 +13,14 @@ import { normalizeBuckets } from "./pipeline/stageStatus";
import {
buildChannelGroupSections,
slugsInGroup,
+ stationWorkFor,
+ transcribeStationIds,
type ChannelGroupSection,
} from "./channelGroupSections";
-// Run from this directory:
-// cd editor/app/channels/lib && ../../../../node_modules/.bin/tsx --test channelGroupSections.test.ts
+// Run with the rest of the common suite (`pnpm --filter yt-dlp-transcript-common
+// test`), or alone from common/:
+// pnpm exec tsx --test views/channelGroupSections.test.ts
// Snapshot shape as WRITTEN TO DISK — same trick as channelFlow.test.ts.
function snapshotOf(patch: Partial<ChannelSnapshot> = {}): ChannelSnapshot {
@@ -224,7 +227,7 @@ test("download excludes members-only/deleted/private ids", () => {
assert.equal(sections[0].download.total, 1);
});
-test("a youtube-handling channel is not transcribe-eligible but is download-eligible", () => {
+test("a youtube-handling channel IS transcribe-eligible: missing auto-subs is whisper work", () => {
const sections = build(
siteOf({ channels: [{ slug: "yt" }, { slug: "whisper" }] }),
[
@@ -241,10 +244,87 @@ test("a youtube-handling channel is not transcribe-eligible but is download-elig
const { download, transcribe } = sections[0];
assert.deepEqual(download.eligible.sort(), ["whisper", "yt"]);
assert.equal(download.total, 2);
- assert.deepEqual(transcribe.eligible, ["whisper"]);
- // The youtube channel's two awaiting-whisper videos are NOT in the figure —
- // whisper will never run on them.
- assert.equal(transcribe.total, 1);
+ // Handling does not decide it — the files do. The youtube channel's two
+ // downloaded videos with no transcript at all are what its button queues.
+ assert.deepEqual(transcribe.eligible.sort(), ["whisper", "yt"]);
+ assert.equal(transcribe.total, 3);
+});
+
+test("the transcribe figure counts downloaded auto-caption-only videos too", () => {
+ const sections = build(
+ siteOf({ channels: [{ slug: "yt" }] }),
+ [
+ channel("yt", { handling: "youtube" }, snapshotOf({
+ buckets: normalizeBuckets({ downloadedAutoSubsOnly: ["a"] }),
+ })),
+ ],
+ );
+ assert.deepEqual(sections[0].transcribe.eligible, ["yt"]);
+ assert.equal(sections[0].transcribe.total, 1);
+});
+
+test("a youtube channel with nothing to transcribe is eligible at 0 (skipped at click time)", () => {
+ const sections = build(
+ siteOf({ channels: [{ slug: "yt" }] }),
+ [channel("yt", { handling: "youtube" })],
+ );
+ assert.deepEqual(sections[0].transcribe.eligible, ["yt"]);
+ assert.equal(sections[0].transcribe.total, 0);
+ assert.deepEqual(sections[0].transcribe.unknown, []);
+});
+
+test("transcribe drops excluded ids from BOTH buckets", () => {
+ const snapshot = snapshotOf({
+ buckets: normalizeBuckets({
+ downloadedNoTranscript: ["keep-1", "gone-1"],
+ downloadedAutoSubsOnly: ["keep-2", "gone-2"],
+ }),
+ excludedFromDownload: {
+ membersOnly: ["gone-1"],
+ deleted: [],
+ private: ["gone-2"],
+ },
+ });
+ const sections = build(
+ siteOf({ channels: [{ slug: "yt" }] }),
+ [channel("yt", { handling: "youtube" }, snapshot)],
+ );
+ assert.equal(sections[0].transcribe.total, 2);
+ assert.deepEqual(transcribeStationIds(snapshot), {
+ missing: ["keep-1"],
+ autoSubs: ["keep-2"],
+ });
+});
+
+test("an excluded download is neither counted nor named for the batch (the review's walk)", () => {
+ // x, y downloaded with no transcript; a downloaded with auto-captions only;
+ // y went private after it was downloaded. The figure is 2 and the two id
+ // lists the station queues BY ID are exactly [x] and [a] — never y.
+ const snapshot = snapshotOf({
+ buckets: normalizeBuckets({
+ downloadedNoTranscript: ["x", "y"],
+ downloadedAutoSubsOnly: ["a"],
+ }),
+ excludedFromDownload: { membersOnly: [], deleted: [], private: ["y"] },
+ });
+ const sections = build(
+ siteOf({ channels: [{ slug: "yt" }] }),
+ [channel("yt", { handling: "youtube" }, snapshot)],
+ );
+ assert.equal(sections[0].transcribe.total, 2);
+ assert.deepEqual(transcribeStationIds(snapshot), {
+ missing: ["x"],
+ autoSubs: ["a"],
+ });
+});
+
+test("a social channel is not transcribe-eligible, and says why", () => {
+ const { brief } = channel("posts", { sourceKind: "social", handling: "transcribe" });
+ assert.deepEqual(stationWorkFor("transcribe", brief, LANE_ON), {
+ eligible: false,
+ work: 0,
+ reason: "social account",
+ });
});
test("a social channel is eligible for sync only", () => {
diff --git a/common/views/channelGroupSections.ts b/common/views/channelGroupSections.ts
@@ -105,6 +105,23 @@ export function laneOffFor(
return false;
}
+// The two id lists the transcribe station counts and queues, after the same
+// download exclusions every other station honours. Disjoint by construction:
+// an ASR VTT makes a video "transcribed" for `downloadedNoTranscript`, so a
+// video is in at most one of them. They are two lists because they are two
+// batches — `runWhisperBatch`'s default scan drains the first, its
+// replace-auto-captions mode needs an ASR track per id for the second.
+export function transcribeStationIds(
+ snapshot: NonNullable<ChannelBrief["snapshot"]>,
+): { missing: string[]; autoSubs: string[] } {
+ const excluded = excludedDownloadIdSet(snapshot);
+ const buckets = normalizeBuckets(snapshot.buckets);
+ return {
+ missing: buckets.downloadedNoTranscript.filter((id) => !excluded.has(id)),
+ autoSubs: buckets.downloadedAutoSubsOnly.filter((id) => !excluded.has(id)),
+ };
+}
+
export type StationChannelWork = {
// Whether the operation applies to this channel at all.
eligible: boolean;
@@ -168,19 +185,17 @@ export function stationWorkFor(
}
if (station === "transcribe") {
- // A `youtube`-handling channel never runs whisper, so counting it would
- // inflate the figure on a button that would skip it anyway.
- if (config.handling !== "transcribe") {
- return { eligible: false, work: 0, reason: "not set to transcribe" };
- }
+ // HANDLING DOES NOT DECIDE THIS. Buckets are decided by FILES
+ // (channelSnapshot.ts), and a `youtube`-handling channel whose video came
+ // down with no captions at all is in `downloadedNoTranscript` exactly like a
+ // `transcribe` one — the runner drains that bucket for every channel. What
+ // the station counts is what its button queues: those, plus the videos whose
+ // only transcript is YouTube's auto-captions and whose audio is on disk
+ // (`downloadedAutoSubsOnly`, the channel page's replace-auto-captions half).
+ // `transcribeStationIds` is the one fold; the group action runs off it too.
if (!snapshot) return { eligible: true, work: null };
- const excluded = excludedDownloadIdSet(snapshot);
- return {
- eligible: true,
- work: normalizeBuckets(snapshot.buckets).downloadedNoTranscript.filter(
- (id) => !excluded.has(id),
- ).length,
- };
+ const { missing, autoSubs } = transcribeStationIds(snapshot);
+ return { eligible: true, work: missing.length + autoSubs.length };
}
if (station === "digest") {
diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md
@@ -1,6 +1,8 @@
# Changelog
## [Unreleased]
+- **Channel rows no longer scroll over a group's controls on `/channels`.** Scrolled down and to the right, the pinned Slug column of every row painted over the pinned group header and its five station buttons (Sync, Download, Transcribe, Digest and the speaker lane), and took the clicks. The pinned Slug cell and the group header sat at the same stacking level, and the later rows won. The rack now has one named layer order, kept in one file: the Advanced panel, then the column header, then the group header, then the pinned checkbox and Slug cells. Nothing ties any more. The screenshot audit found four more problems, fixed as well. A group header's name and buttons now stay on screen however far the columns scroll across (they used to scroll off to the left). An Advanced panel opened near the bottom or the right edge scrolls itself into view instead of being cut off. The rule above a pinned group header moves with it instead of leaving a gap the rows showed through. On a phone, the column header no longer paints over the selection bar pinned to the bottom of the screen.
+- **A group's Transcribe works for YouTube channels, and it counts what it queues.** The station used to be disabled for every `youtube`-handling channel with the message "a youtube-handling channel never runs whisper". That was wrong. A YouTube video that came down with no captions is transcription work like any other, and the automatic runner already treats it that way. Transcribe now counts two kinds of video, after the usual members-only, deleted and private exclusions: downloaded videos with no transcript at all, and downloaded videos whose only transcript is YouTube's auto-captions. Pressing it queues exactly those videos, by id, as the channel page does: up to two jobs per channel on the transcription queue. A video downloaded before it went private, members-only or deleted is no longer transcribed by the group button, because it was never in the figure. Pressing it again while either job runs says *already running*. The wording names no method ("…has downloaded audio to transcribe", "…each takes minutes"). **This figure can now be higher than the Transcription band in the same rack on channels with many auto-caption-only videos.** The band counts videos with no transcript at all, while the station counts everything its button would queue. That is intended.
- **Every channel table and every job-in-flight line is now drawn one way.** The /channels rack, the dashboard's Channels table and the work tables on the operation pages and /cleanup are one table with a column set per page, over one channel row built on the server (which no longer ships a channel's config to the browser); the dashboard's "Needs work" seed is computed by the same code the widget endpoint serves. On the jobs side, /jobs rows, the "Active jobs" cards on channel/video/operation pages, the monitor widget's Active jobs strip and the operations board's "In flight" list are one job row in three sizes, with one rule for which buttons (Retry / Reorder / Drain / Cancel / Force-release) a job gets. **What you might notice:** a work table's report column reads "stale"/"missing" like the rack's instead of a date; the dashboard's Sync button is the rack's; a lane line on /jobs offers Force-release while its runner is running; widget job lines show who asked for the job; an in-flight download on the operations board links to its job page. Nothing a count says moved.
- **`site.json`, each channel's `config.json` and the per-video sidecars now have one schema each, and the two config files have generated key tables.** **`SITE.md`** and **`CHANNEL.md`** (new, repo root) list every key with its default and meaning, generated by `common/bin/file-schemas-docs.ts` and checked by a test. Nothing an operator has configured reads or saves differently: every live `site.json` and `config.json`, and a 1,763-file sample of sidecars, read and write back byte-for-byte as before. **Fixed:** a social-channel fetch no longer undoes Configure-form edits made while it was running (it used to write back the whole config it read when it started). Every change to a channel's config now re-reads the file at the moment it saves and changes only its own fields, so a sync stamping its time and a form save made at the same moment both land. Two writes to the same file from the editor no longer share one temporary file.
- **`settings.json` has one schema and one writer, and its key table is generated.** Every key, its default, its clamp and its documentation is now one zod schema (`common/lib/settingsSchema.ts`); `getSettings`/`writeSettings` both parse through it, and every settings form saves through one helper (`editor/app/settings/saveSettings.ts`) that merges only what the form changed. **`SETTINGS.md`** (new, repo root) lists every key with its default and what it does, and `settings.json.example` is now the full default object — both generated by `common/bin/settings-example.ts` and checked by a test, so neither can drift. Nothing an operator has configured reads differently. **Fixed:** adding or editing a storage location on `/storage` no longer erases the record of which location the saved-video store is on (`storage.savedVideosLocationId`).
diff --git a/editor/app/channels/components/ChannelGroupHeaderRow.tsx b/editor/app/channels/components/ChannelGroupHeaderRow.tsx
@@ -2,6 +2,7 @@
import type { ChannelGroupSectionView } from "yt-dlp-transcript-common/views/channelGroupSections";
import { ChannelGroupLine } from "./ChannelGroupLine";
+import { RACK_LAYERS } from "./rackLayout";
// A group's section header: one full-colspan row above its channels' rows, so
// the columns stay locked across every group (comparing transcript counts
@@ -28,7 +29,12 @@ export function ChannelGroupHeaderRow({
const { group, channels } = section;
const name = group.name || group.id;
return (
- <tr className="border-t-2 border-border">
+ // THE SECTION RULE IS THE CELL'S SHADOW, NOT THE ROW'S BORDER. Tailwind's
+ // preflight collapses table borders, and a collapsed border belongs to the
+ // table grid, not to the sticky cell — so pinned under the thead, the th
+ // moved and its 2px top rule stayed behind as a gap the rows showed
+ // through. An inset shadow is painted by the th and travels with it.
+ <tr>
<th
colSpan={colSpan}
scope="rowgroup"
@@ -38,38 +44,52 @@ export function ChannelGroupHeaderRow({
className={
// The section name stays on screen while its rows scroll past, pinned
// just under the column header — whose height is measured into
- // `--thead-h` by ChannelsTable rather than guessed. Opaque, and above
- // the sticky identity cells it passes over, but below the thead.
+ // `--thead-h` by ChannelsRack rather than guessed. Opaque, and above
+ // the sticky identity cells it passes over, but below the thead
+ // (RACK_LAYERS — a tie with the identity cells is what let the Slug
+ // band paint over the station buttons).
"px-2 py-1.5 text-left font-normal align-top bg-muted " +
- "md:sticky md:top-[var(--thead-h,2.25rem)] md:z-20"
+ "shadow-[inset_0_2px_0_var(--color-border)] " +
+ `md:sticky md:top-[var(--thead-h,2.25rem)] ${RACK_LAYERS.groupHeader}`
}
>
- <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
- <span
- data-testid="group-name"
- className="text-xs font-semibold uppercase tracking-wider"
- >
- {name}
- </span>
- {/* Rendered because it is true and currently invisible: a visitor to
- the public site does not get this group preselected. */}
- {!group.selectedByDefault && (
- <span className="text-[10px] uppercase tracking-wide text-muted-foreground border border-border rounded px-1">
- off by default
+ {/* THE CONTENT PINS LEFT. The th spans every column, so its name and
+ five stations used to scroll off to the left with the table — the
+ group's controls out of reach exactly when the operator had
+ scrolled across to read a column. Capped at the region's visible
+ width (`--rack-w`, measured by ChannelsRack; 100% before the first
+ measure) less the th's padding, so the count on the right stays
+ in view too. */}
+ <div className="sticky left-2 max-w-[calc(var(--rack-w,100%)-1rem)]">
+ <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
+ <span
+ data-testid="group-name"
+ className="text-xs font-semibold uppercase tracking-wider"
+ >
+ {name}
</span>
- )}
- {/* The authored description. Written by SiteForm, parsed by
+ {/* Rendered because it is true and currently invisible: a visitor to
+ the public site does not get this group preselected. */}
+ {!group.selectedByDefault && (
+ <span className="text-[10px] uppercase tracking-wide text-muted-foreground border border-border rounded px-1">
+ off by default
+ </span>
+ )}
+ {/* The authored description. Written by SiteForm, parsed by
parseChannelGroup, and until now rendered nowhere in the editor. */}
- {group.description && (
- <span className="text-xs text-muted-foreground">
- {group.description}
+ {group.description && (
+ <span className="text-xs text-muted-foreground">
+ {group.description}
+ </span>
+ )}
+ <span className="ml-auto text-xs text-muted-foreground whitespace-nowrap">
+ {channels.length === 1
+ ? "1 channel"
+ : `${channels.length} channels`}
</span>
- )}
- <span className="ml-auto text-xs text-muted-foreground whitespace-nowrap">
- {channels.length === 1 ? "1 channel" : `${channels.length} channels`}
- </span>
+ </div>
+ <ChannelGroupLine section={section} siteId={siteId} />
</div>
- <ChannelGroupLine section={section} siteId={siteId} />
</th>
</tr>
);
diff --git a/editor/app/channels/components/ChannelGroupLine.tsx b/editor/app/channels/components/ChannelGroupLine.tsx
@@ -60,10 +60,12 @@ const STATIONS: Station[] = [
id: "transcribe",
label: "Transcribe",
done: "Transcribed",
- notEligible:
- "No channel in this group is set to transcribe — a youtube-handling channel never runs whisper.",
+ // Reachable only when every member is a social account: handling does not
+ // decide eligibility (channelGroupSections.ts), the files do. No method is
+ // named — which engine runs is the transcription settings' business.
+ notEligible: "No channel in this group has downloaded audio to transcribe.",
confirm: (group, count) =>
- `Transcribe ${count} downloaded video(s) across every channel in "${group}"? They run strictly one at a time on the transcription queue, and whisper is slow.`,
+ `Transcribe ${count} downloaded video(s) across every channel in "${group}"? They run one at a time on the transcription queue, and each takes minutes.`,
run: transcribeChannelGroupAction,
},
{
diff --git a/editor/app/channels/components/ChannelSelectionDeck.tsx b/editor/app/channels/components/ChannelSelectionDeck.tsx
@@ -44,6 +44,7 @@ import {
} from "../bulkStorageActions";
import type { MoveDestination } from "../lib/moveDestination";
import { useBarAction } from "./ChannelFocusBar";
+import { RACK_LAYERS } from "./rackLayout";
const TIER_LABEL: Record<StoredChannelTier, string> = {
normal: "Normal",
@@ -126,7 +127,7 @@ export function ChannelSelectionDeck({
className={
// Below md the document scrolls and the deck pins to the screen; on md+
// it is the last item of the page's flex column and is already docked.
- "sticky bottom-0 z-20 md:static md:shrink-0 " +
+ `sticky bottom-0 ${RACK_LAYERS.deck} md:static md:shrink-0 ` +
"border-t md:border md:rounded-md border-border bg-card/95 backdrop-blur " +
"px-3 py-2 text-sm shadow-lg " +
"motion-safe:animate-in motion-safe:fade-in motion-safe:slide-in-from-bottom-2"
diff --git a/editor/app/channels/components/ChannelTierSelect.tsx b/editor/app/channels/components/ChannelTierSelect.tsx
@@ -38,6 +38,7 @@ import {
setChannelTierAction,
type ActionResult,
} from "../actions";
+import { RACK_LAYERS } from "./rackLayout";
export type ChannelTierSelectProps = {
slug: string;
@@ -198,15 +199,37 @@ export default function ChannelTierSelect({
{/* OPENING THIS MUST NOT MOVE THE RACK. In a 40px row an inline panel
would push every row below it down by 150px, so the panel is absolute
- and overlays them instead. */}
- <details className="text-[11px]">
+ and overlays them instead.
+ ...WHICH MEANS THE SCROLL REGION CLIPS IT. Opened on a row near the
+ rack's bottom (or, below md, its right edge) the panel hung off the
+ region with its selects out of reach, and nothing said it was there.
+ Opening scrolls the region — and the document, below md — just far
+ enough to show the whole panel; `nearest` leaves an already-visible
+ one exactly where it is. Below md the selection deck pins to the
+ bottom of the SCREEN, outside the (isolated) rack, and paints over
+ it — so the panel keeps a 12rem scroll margin there, about the
+ deck's height, and "nearest" stops it above the deck instead of
+ under it. */}
+ <details
+ className="text-[11px]"
+ onToggle={(e) => {
+ const details = e.currentTarget;
+ if (!details.open) return;
+ requestAnimationFrame(() =>
+ details.lastElementChild?.scrollIntoView({
+ block: "nearest",
+ inline: "nearest",
+ }),
+ );
+ }}
+ >
<summary
aria-label={`advanced priority for ${slug}`}
className="cursor-pointer text-muted-foreground hover:text-foreground"
>
Advanced
</summary>
- <div className="absolute z-30 mt-1 flex w-64 flex-col gap-1 rounded-md border border-border bg-popover p-2 shadow-md">
+ <div className={`absolute ${RACK_LAYERS.popover} max-md:scroll-mb-48 mt-1 flex w-64 flex-col gap-1 rounded-md border border-border bg-popover p-2 shadow-md`}>
{PRIORITY_OPERATIONS.map((op) => (
<label key={op} className="flex items-center justify-between gap-2">
<span className="text-muted-foreground">
diff --git a/editor/app/channels/components/ChannelsRack.tsx b/editor/app/channels/components/ChannelsRack.tsx
@@ -134,11 +134,18 @@ export function ChannelsRack({
const region = regionRef.current;
const thead = theadRef.current;
if (!region || !thead || typeof ResizeObserver === "undefined") return;
- const measure = () =>
+ // `--rack-w` is the region's visible width: a group header's content pins
+ // to the region's left edge and is capped at this, so its name and
+ // stations stay on screen however far the columns scroll across
+ // (ChannelGroupHeaderRow).
+ const measure = () => {
region.style.setProperty("--thead-h", `${thead.offsetHeight}px`);
+ region.style.setProperty("--rack-w", `${region.clientWidth}px`);
+ };
measure();
const observer = new ResizeObserver(measure);
observer.observe(thead);
+ observer.observe(region);
return () => observer.disconnect();
}, []);
@@ -202,10 +209,16 @@ export function ChannelsRack({
Slug cells pin to its left, and the sixteen columns move underneath
them. The table itself must NOT clip (`overflow-hidden` would make it
the sticky ancestor and nothing would pin) — the rounded corners are
- the region's. */}
+ the region's.
+ `isolate` makes the region its own stacking context, so the whole
+ ladder (rackLayout.ts) is ordered INSIDE it: without it the pinned
+ cells and the thead competed with the page itself, and below md
+ the thead scrolling under the screen-pinned selection deck
+ (RACK_LAYERS.deck) painted over it. */}
<div
ref={regionRef}
- className="relative -mx-4 min-h-0 flex-1 overflow-auto border-y border-border md:mx-0 md:rounded-md md:border"
+ data-testid="channels-rack"
+ className="relative isolate -mx-4 min-h-0 flex-1 overflow-auto border-y border-border md:mx-0 md:rounded-md md:border"
>
<ChannelsTable
rows={channels}
diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx
@@ -19,6 +19,7 @@ import {
type ChannelSortKey,
type PipelineColumn,
} from "./channelColumns";
+import { RACK_BRIDGE, RACK_IDENTITY, RACK_LAYERS } from "./rackLayout";
// ONE CHANNEL TABLE — the /channels rack, the dashboard's channels table and
// every operation page's work section draw their rows here, off the one
@@ -273,7 +274,7 @@ export function ChannelsTable({
head.push(
<th
key="select"
- className={`${sticky ? "sticky left-0 z-10 bg-muted " : ""}w-9 px-2 py-1.5 align-bottom`}
+ className={`${sticky ? `sticky left-0 ${RACK_LAYERS.identity} bg-muted ` : ""}${RACK_IDENTITY.checkboxWidth} px-2 py-1.5 align-bottom`}
>
<input
type="checkbox"
@@ -294,7 +295,7 @@ export function ChannelsTable({
// close the block run the full height of the rack.
pipelineColumns.forEach((col, i) => {
const className =
- "w-24 min-w-20" +
+ RACK_BRIDGE +
(i === 0 ? " border-l border-border" : "") +
(i === pipelineColumns.length - 1 ? " border-r border-border" : "");
head.push(
@@ -373,6 +374,11 @@ export function ChannelsTable({
const table = (
<table
className={
+ // THE FLAT PATH CLIPS, THE STICKY PATH MUST NOT. `md:overflow-hidden`
+ // keeps the flat table's bg-muted head inside its rounded border, and
+ // nothing pins there. The sticky branch must never carry an overflow
+ // class: it would make the table the sticky ancestor and nothing would
+ // pin to the rack's scroll region (ChannelsRack.tsx, "THE RACK").
sticky
? "w-full text-sm"
: "text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full"
@@ -380,7 +386,7 @@ export function ChannelsTable({
>
<thead
ref={theadRef}
- className={sticky ? "sticky top-0 z-30 bg-muted" : "bg-muted"}
+ className={sticky ? `sticky top-0 ${RACK_LAYERS.thead} bg-muted` : "bg-muted"}
>
<tr>{head}</tr>
</thead>
@@ -503,7 +509,7 @@ function ChannelTableRow({
// background — it has to carry the same one explicitly or the rows would show
// through the pinned identity column while the rest scrolls.
const stickyBg = selected ? "bg-accent" : "bg-background";
- const bridge = "bg-surface w-24 min-w-20";
+ const bridge = `bg-surface ${RACK_BRIDGE}`;
const pad = sticky ? "px-2 py-1.5" : "px-3 py-2";
// Dimmed for the two things that take the row out of a pipeline: it is
// excluded from the export build, or its base tier is Paused. (The sync
@@ -513,7 +519,7 @@ function ChannelTableRow({
// THE DIM IS PER CELL, NEVER ON THE `<tr>`. `opacity` below 1 creates a
// STACKING CONTEXT, and a stacking context confines every positioned
// descendant to it: put `opacity-60` on the row and the Tier cell's
- // `absolute z-30` Advanced popover (ChannelTierSelect) can no longer paint
+ // `absolute` Advanced popover (ChannelTierSelect, `RACK_LAYERS.popover`) can no longer paint
// above the rows that follow, however high its z-index — every later row
// draws over it and swallows the clicks. So the Tier cell — the one that
// hosts the popover — is the one cell that is NOT dimmed (its registry entry
@@ -544,7 +550,7 @@ function ChannelTableRow({
<Td
key="select"
pad="px-2 py-1.5"
- className={`${sticky ? `sticky left-0 z-10 ${stickyBg} ` : ""}w-9${dim}`}
+ className={`${sticky ? `sticky left-0 ${RACK_LAYERS.identity} ${stickyBg} ` : ""}${RACK_IDENTITY.checkboxWidth}${dim}`}
>
<input
type="checkbox"
diff --git a/editor/app/channels/components/channelColumns.tsx b/editor/app/channels/components/channelColumns.tsx
@@ -11,6 +11,7 @@ import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton";
import { ChannelBuildToggle } from "./ChannelBuildToggle";
import { ChannelSyncButton } from "./ChannelSyncButton";
import ChannelTierSelect from "./ChannelTierSelect";
+import { RACK_IDENTITY, RACK_LAYERS } from "./rackLayout";
import type {
ChannelColumnId,
ChannelSortKey,
@@ -119,12 +120,11 @@ export const CHANNEL_COLUMNS: Record<
label: "Slug",
sortKey: "slug",
th: {
- stickyClassName:
- "sticky left-8 z-20 bg-muted shadow-[1px_0_0_var(--color-border)]",
+ stickyClassName: `sticky ${RACK_IDENTITY.slugLeft} ${RACK_LAYERS.identity} bg-muted shadow-[1px_0_0_var(--color-border)]`,
},
cell: (c, ctx) => ({
className: ctx.sticky
- ? `sticky left-8 z-20 whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${ctx.stickyBg}`
+ ? `sticky ${RACK_IDENTITY.slugLeft} ${RACK_LAYERS.identity} whitespace-nowrap font-mono shadow-[1px_0_0_var(--color-border)] ${ctx.stickyBg}`
: "whitespace-nowrap font-mono",
content: (
<span className="inline-flex items-center gap-1.5">
diff --git a/editor/app/channels/components/rackLayout.test.ts b/editor/app/channels/components/rackLayout.test.ts
@@ -0,0 +1,23 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { RACK_IDENTITY, RACK_LAYERS } from "./rackLayout";
+
+// Tailwind's default spacing scale: one unit = 0.25rem = 4 px.
+const px = (token: string, prefix: string) => {
+ const m = new RegExp(`^${prefix}-(\\d+)$`).exec(token);
+ assert.ok(m, `${token} is not a ${prefix}-<n> token`);
+ return Number(m[1]) * 4;
+};
+const z = (token: string) => Number(/z-(\d+)$/.exec(token)?.[1]);
+
+test("the pinned Slug cell starts inside the checkbox cell — the overlap closes the seam", () => {
+ assert.ok(
+ px(RACK_IDENTITY.slugLeft, "left") < px(RACK_IDENTITY.checkboxWidth, "w"),
+ );
+});
+
+test("the layer ladder is strict: popover > thead > group header > identity", () => {
+ assert.ok(z(RACK_LAYERS.popover) > z(RACK_LAYERS.thead));
+ assert.ok(z(RACK_LAYERS.thead) > z(RACK_LAYERS.groupHeader));
+ assert.ok(z(RACK_LAYERS.groupHeader) > z(RACK_LAYERS.identity));
+});
diff --git a/editor/app/channels/components/rackLayout.ts b/editor/app/channels/components/rackLayout.ts
@@ -0,0 +1,59 @@
+// THE RACK'S LAYOUT TOKENS — plain data, deliberately NOT "use client" (see
+// ./channelColumnPresets.ts for why: a server component importing a value from
+// a client module gets a client reference, not the value).
+//
+// Tailwind v4 (`app/globals.css`, no config) finds classes by scanning source
+// for complete literals, so every entry is a whole class string. Never build
+// one (`z-${n}`): it would not be generated.
+
+// THE LAYER LADDER, top to bottom:
+//
+// popover > thead > groupHeader > identity
+//
+// - popover: the Tier cell's Advanced panel (ChannelTierSelect). It opens over
+// the rows below AND over the thead when its row sits just under it; it used
+// to TIE the thead at z-30 and win only by coming later in the DOM.
+// - thead: the column header pins to the top of the scroll region, over
+// everything that scrolls under it, group headers included.
+// - groupHeader: a section's header row pins under the thead (md+ only — below
+// md the region does not scroll vertically) and carries the five station
+// buttons, so it must paint over the pinned identity cells of the rows
+// scrolling up beneath it.
+// - identity: the checkbox and Slug cells pinned to the left. BOTH at the same
+// level. Two sibling cells at one z-index paint in DOM order, and the Slug
+// cell comes later, so it covers the 4 px the two overlap (RACK_IDENTITY) —
+// which is what closes the seam between them. `20ee34db` closed that seam by
+// lifting the Slug cell to z-20 instead, which TIED it with the group header:
+// scrolled down and right, the slug band of every later row painted over the
+// group header and swallowed clicks on its stations (operator report
+// 2026-09-24). A higher Slug z re-creates that tie; do not.
+//
+// - deck (off the ladder's order, beside it): the selection deck pins to the
+// bottom of the SCREEN below md (ChannelSelectionDeck), over whatever part of
+// the page scrolls under it; on md+ it is static and z does nothing.
+//
+// `groupHeader` carries its `md:` prefix because the sticky it orders is md+
+// only (ChannelGroupHeaderRow).
+export const RACK_LAYERS = {
+ popover: "z-40",
+ thead: "z-30",
+ groupHeader: "md:z-20",
+ identity: "z-10",
+ deck: "z-20",
+} as const;
+
+// THE PINNED IDENTITY COLUMN'S GEOMETRY.
+//
+// Invariant: `slugLeft` (32 px) < `checkboxWidth` (36 px). The Slug cell pins
+// 4 px INSIDE the checkbox cell, and that overlap is the seam closer (see
+// `identity` above). A cell's width is a floor under auto table layout, so the
+// checkbox cell never renders narrower and the gap cannot reopen. Equal values
+// reopen the sub-pixel seam `20ee34db` found — the scrolled columns showing
+// through a hairline between the two pins. rackLayout.test.ts holds this.
+export const RACK_IDENTITY = {
+ checkboxWidth: "w-9",
+ slugLeft: "left-8",
+} as const;
+
+// One pipeline band column's width, header and cell alike — the meter bridge.
+export const RACK_BRIDGE = "w-24 min-w-20";
diff --git a/editor/app/channels/groupActions.ts b/editor/app/channels/groupActions.ts
@@ -1,7 +1,10 @@
"use server";
import { revalidatePath } from "next/cache";
-import { listChannelBriefs } from "yt-dlp-transcript-common/controller/channels";
+import {
+ listChannelBriefs,
+ type ChannelBrief,
+} from "yt-dlp-transcript-common/controller/channels";
import type { StreamActionResult } from "yt-dlp-transcript-common/jobs/streamCommand";
import { activeSlugsForKinds } from "yt-dlp-transcript-common/jobs/syncJobs";
import { isValidGroupId } from "yt-dlp-transcript-common/lib/channelGroups";
@@ -13,11 +16,16 @@ import {
laneOffFor,
slugsInGroup,
stationWorkFor,
+ transcribeStationIds,
type StationId,
} from "yt-dlp-transcript-common/views/channelGroupSections";
import { queueForSlugs } from "./lib/queueForSlugs";
import { downloadMissingAction, syncAction } from "./[slug]/pipelineActions";
-import { transcribeMissingAction } from "./[slug]/whisperActions";
+import {
+ transcribeAutoSubsBucketAction,
+ transcribeBucketAction,
+ transcribeMissingAction,
+} from "./[slug]/whisperActions";
import { backfillChannelAction } from "./[slug]/backfillActions";
import { runOperationChannelJob } from "yt-dlp-transcript-common/controller/operationJobs";
import { DIGEST_OPERATION_ID } from "yt-dlp-transcript-common/lib/operations";
@@ -42,26 +50,92 @@ export type GroupOpResult = {
skipped: { slug: string; reason: string }[];
};
-// The job kind each station enqueues, for the "already running" dedupe. The job
-// registry has no dedupe of its own.
-const KIND_FOR: Record<StationId, string> = {
- sync: "sync",
- download: "download-missing",
- transcribe: "whisper-all",
- digest: "digest-channel-local",
- speakers: "backfill-channel",
+// The job kinds each station enqueues, for the "already running" dedupe. The job
+// registry has no dedupe of its own. Transcribe names THREE: its button queues
+// one or both bucket jobs (or the scan, for a channel with no report), and the
+// dedupe spans them all — pressing it again while any one runs, including the
+// channel page's own bucket job, reads "already running", not a second batch.
+const KIND_FOR: Record<StationId, readonly string[]> = {
+ sync: ["sync"],
+ download: ["download-missing"],
+ transcribe: [
+ "whisper-all",
+ "whisper-bucket-downloaded-no-transcript",
+ "whisper-bucket-auto-subs",
+ ],
+ digest: ["digest-channel-local"],
+ speakers: ["backfill-channel"],
};
+// THE TRANSCRIBE STATION QUEUES WHAT ITS FIGURE COUNTS — `transcribeStationIds`,
+// the fold `stationWorkFor` sums — BY ID. Two batches, because one cannot cover
+// both: the no-transcript half is `transcribeBucketAction` over its ids (the
+// channel page's own bucket control, replayable as `downloadedNoTranscript`),
+// and the auto-captions half is `transcribeAutoSubsBucketAction`, whose
+// replace mode needs an ASR track per id. By id matters: a video downloaded
+// before it went private, members-only or deleted is still on disk and still
+// in `downloadedNoTranscript`, and a directory SCAN would transcribe it — one
+// more than the figure said. The channel page runs the same jobs; a combined
+// job kind would need its own replay spec and /jobs label and mirror nothing.
+// Handling is not consulted — a youtube channel's captionless download is
+// whisper work too.
+//
+// The SCAN (`transcribeMissingAction`, a whisper-all with no ids) is used ONLY
+// when the channel has never reported: with no snapshot there are no ids to
+// name, and the scan finds what there is, as the station always did.
+//
+// All three kinds resolve to TRANSCRIPTION_QUEUE, so the "strictly one at a
+// time" note above stays true. `queued` counts CHANNELS; `jobIds` carries one
+// id per channel — the no-transcript job's when both halves were queued, and
+// the other stream is cancelled here exactly as queueForSlugs cancels the one
+// returned. A half that fails while the other queues cannot ride on an ok
+// result (StreamActionResult's ok arm has no message), so it is logged.
+async function transcribeChannel(
+ slug: string,
+ brief: ChannelBrief | undefined,
+): Promise<StreamActionResult> {
+ if (!brief?.snapshot) return transcribeMissingAction(slug);
+ const ids = transcribeStationIds(brief.snapshot);
+ const halves = [
+ ids.missing.length > 0
+ ? await transcribeBucketAction(
+ slug,
+ ids.missing,
+ undefined,
+ undefined,
+ undefined,
+ "downloadedNoTranscript",
+ )
+ : null,
+ ids.autoSubs.length > 0
+ ? await transcribeAutoSubsBucketAction(slug, ids.autoSubs)
+ : null,
+ ].filter((r): r is StreamActionResult => r !== null);
+ // Both empty is skipped upstream as "nothing to do"; say so if reached.
+ if (halves.length === 0) return { ok: false, error: "nothing to do", info: true };
+ const ok = halves.find((r) => r.ok);
+ if (!ok) return halves[0];
+ for (const r of halves) {
+ if (r === ok) continue;
+ if (r.ok) void r.stream.cancel();
+ else
+ console.warn(
+ `[group transcribe] ${slug}: one half queued, the other refused: ${r.error}`,
+ );
+ }
+ return ok;
+}
+
const RUN_FOR: Record<
StationId,
- (slug: string) => Promise<StreamActionResult>
+ (slug: string, brief: ChannelBrief | undefined) => Promise<StreamActionResult>
> = {
sync: (slug) => syncAction(slug, undefined),
// runPipelineAction already refuses with {ok:false, error} for a paused
// install, a per-platform 429 cooldown and low disk, so all three land in
// `skipped` for free.
download: (slug) => downloadMissingAction(slug),
- transcribe: (slug) => transcribeMissingAction(slug),
+ transcribe: (slug, brief) => transcribeChannel(slug, brief),
// THE LOCAL LANE, PINNED. The metered lane is behind a settings gate and a
// spend cap, so it is never what a group button starts — which is why this
// names the lane explicitly instead of letting the dispatcher pick the
@@ -132,7 +206,7 @@ async function runGroupStation(
members.has(b.slug),
);
const briefBySlug = new Map(briefs.map((b) => [b.slug, b]));
- const active = activeSlugsForKinds([KIND_FOR[station]]);
+ const active = activeSlugsForKinds(KIND_FOR[station]);
const outcome = await queueForSlugs(
briefs.map((b) => b.slug),
@@ -153,7 +227,7 @@ async function runGroupStation(
if (station !== "sync" && work.work === 0) return "nothing to do";
return null;
},
- run: RUN_FOR[station],
+ run: (slug) => RUN_FOR[station](slug, briefBySlug.get(slug)),
},
);
return { group, ...outcome };
diff --git a/editor/e2e/channel-groups.spec.ts b/editor/e2e/channel-groups.spec.ts
@@ -1,9 +1,12 @@
import { test, expect, type Page } from "@playwright/test";
+import { mkdir, writeFile } from "node:fs/promises";
import {
channelStage,
generateReport,
jobRowByKind,
+ pathExists,
resetData,
+ resolvePath,
writeChannelConfig,
writeSite,
} from "./helpers";
@@ -147,18 +150,19 @@ test("a group's Sync queues that group's channels and nothing else", async ({
await expect(syncRows).toContainText("slow-b");
});
-test("a station with no eligible channel is disabled and says why", async ({
+test("transcribe is open to a youtube channel; a station with no eligible channel is disabled and says why", async ({
page,
}) => {
await seed();
await page.goto("/channels?site=alpha");
- // Both fixture channels are handling: "youtube", and a youtube channel never
- // runs whisper — so counting it would inflate the figure on a button that
- // would skip it anyway.
+ // Both fixture channels are handling: "youtube" — and that no longer shuts
+ // the station: a youtube video that came down with no captions is whisper
+ // work like any other. Neither channel has reported, so the figure is the
+ // honest unknown, as the download station's is below.
const transcribe = page.getByLabel("transcribe group News");
- await expect(transcribe).toBeDisabled();
- await expect(transcribe).toHaveAttribute("title", /never runs whisper/);
+ await expect(transcribe).toBeEnabled();
+ await expect(transcribe).toHaveText("Transcribe —");
// The default test settings enable no operation on the backfill lane, so the
// speakers station reads "off" — NOT 0 (which reads as finished) and not —
@@ -206,3 +210,79 @@ test("a group figure reads — until a channel reports, then a real number", asy
)
.toMatch(/Download 5$/);
});
+
+// An auto-caption-only video, shaped like real yt-dlp --write-auto-subs output
+// (the provenance sniff reads cue settings and inline word timings), with its
+// audio on disk — i.e. the `downloadedAutoSubsOnly` bucket.
+const ASR_VTT = `WEBVTT
+Kind: captions
+Language: en
+
+00:00:00.030 --> 00:00:03.919 align:start position:0%
+so<00:00:00.719> today<00:00:01.199> we're<00:00:01.439> going<00:00:01.680> to
+
+00:00:03.919 --> 00:00:03.929 align:start position:0%
+so today we're going to
+
+00:00:03.929 --> 00:00:07.070 align:start position:0%
+so today we're going to
+talk<00:00:04.320> about<00:00:04.639> the<00:00:04.879> whole<00:00:05.199> thing
+`;
+
+test("a youtube group's Transcribe counts and queues its auto-caption-only videos", async ({
+ page,
+}) => {
+ test.setTimeout(120_000);
+ await seed();
+ const id = "asrgrp0001";
+ const dataRel = `test-transcripts/channels/slow-b/data/${id}`;
+ const dir = resolvePath(dataRel);
+ await mkdir(dir, { recursive: true });
+ await writeFile(`${dir}/transcript.en.vtt`, ASR_VTT);
+ await writeFile(
+ `${dir}/metadata.info.json`,
+ JSON.stringify({
+ id,
+ title: `Synthetic ${id}`,
+ upload_date: "20240101",
+ duration: 60,
+ extractor_key: "Youtube",
+ webpage_url: `https://www.youtube.com/watch?v=${id}`,
+ subtitles: {},
+ automatic_captions: { en: [{ ext: "vtt", url: "fake://subs" }] },
+ }),
+ );
+ await writeFile(`${dir}/audio.mp3`, `fake audio ${id}\n`);
+ await writeFile(
+ resolvePath("test-transcripts/channels/slow-b/playlist"),
+ `https://www.youtube.com/watch?v=${id}\n`,
+ );
+ await generateReport(page, "slow-b");
+
+ await page.goto("/channels?site=alpha");
+ const transcribe = page.getByLabel("transcribe group News");
+ await expect(transcribe).toHaveText("Transcribe 1");
+ await expect(transcribe).toBeEnabled();
+ page.once("dialog", (d) => void d.accept());
+ await transcribe.click();
+ await expect(page.getByLabel("transcribe group News result")).toContainText(
+ /Queued 1 . skipped 0/,
+ { timeout: 15_000 },
+ );
+
+ await page.goto("/jobs");
+ const rows = jobRowByKind(page, "whisper-bucket-auto-subs");
+ await expect(rows).toHaveCount(1);
+ await expect(rows).toContainText("slow-b");
+ // Nothing was captionless, so neither the by-id no-transcript batch nor the
+ // scan rode along.
+ await expect(
+ jobRowByKind(page, "whisper-bucket-downloaded-no-transcript"),
+ ).toHaveCount(0);
+ await expect(jobRowByKind(page, "whisper-all")).toHaveCount(0);
+
+ // Let the fake whisper finish before the next spec's resetData.
+ await expect
+ .poll(() => pathExists(`${dataRel}/transcript.json`), { timeout: 60_000 })
+ .toBe(true);
+});
diff --git a/editor/e2e/channels-rack-audit.spec.ts b/editor/e2e/channels-rack-audit.spec.ts
@@ -0,0 +1,143 @@
+import { test, expect, type Page } from "@playwright/test";
+import {
+ resetData,
+ resolvePath,
+ writeChannelConfig,
+ writeSite,
+} from "./helpers";
+
+// THE /channels RACK AUDIT — screenshots, not assertions. A person (and the
+// reviewer) LOOKS at every shot for layering, overlap, clipping and
+// misalignment; channels-rack-layers.spec.ts is where a finding becomes a
+// hit-test that fails. Skipped unless RACK_SHOTS is set, so the ordinary suite
+// never spends time here:
+//
+// RACK_SHOTS=1 pnpm e2e channels-rack-audit.spec.ts (from the repo root)
+//
+// Shots land in editor/test-results/rack-shots/ (gitignored; Playwright clears
+// test-results/ at the start of every run, so copy a set out before the next).
+
+test.skip(!process.env.RACK_SHOTS, "audit only");
+
+const EXTRA = Array.from(
+ { length: 16 },
+ (_, i) => `rack-${String(i + 1).padStart(2, "0")}`,
+);
+
+// Two groups so a section boundary is on screen, and enough rows to scroll on
+// both axes.
+async function seed() {
+ await resetData("two-slow-channels");
+ for (const slug of EXTRA) await writeChannelConfig(slug);
+ await writeSite("alpha", {
+ siteTitle: "Alpha",
+ groups: [
+ { id: "default", name: "All channels", selectedByDefault: true, order: 2 },
+ {
+ id: "news",
+ name: "News",
+ description: "Shows where the host is the guest",
+ selectedByDefault: false,
+ order: 1,
+ },
+ ],
+ channels: [
+ { slug: "slow-b", groupId: "news" },
+ { slug: "rack-01", groupId: "news" },
+ { slug: "slow-a" },
+ ...EXTRA.slice(1).map((slug) => ({ slug })),
+ ],
+ });
+}
+
+const VIEWPORTS = [
+ { name: "desktop", width: 1440, height: 900 },
+ { name: "mobile", width: 390, height: 844 },
+] as const;
+
+async function shot(page: Page, name: string) {
+ await page.screenshot({ path: resolvePath(`test-results/rack-shots/${name}.png`) });
+}
+
+// Bottom-right: the region's own scroll (both axes on md+, the horizontal one
+// below md) and the document's (below md the document is what scrolls down).
+async function scrollBottomRight(page: Page) {
+ await page.getByTestId("channels-rack").evaluate((el) => {
+ el.scrollTop = el.scrollHeight;
+ el.scrollLeft = el.scrollWidth;
+ });
+ await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ // Let sticky / ResizeObserver settle before the shutter.
+ await page.waitForTimeout(250);
+}
+
+async function open(page: Page) {
+ await seed();
+ await page.goto("/channels?site=alpha");
+ await expect(page.getByRole("link", { name: "rack-16" })).toBeVisible();
+ // HYDRATED, not just painted: the stations are disabled until mount, and the
+ // rack's measured custom properties (--thead-h, --rack-w) and every
+ // onToggle exist only from then on. A click before it lands on server HTML.
+ await expect(page.getByLabel("sync group News")).toBeEnabled();
+}
+
+for (const vp of VIEWPORTS) {
+ test.describe(`rack audit @ ${vp.name}`, () => {
+ test.use({ viewport: { width: vp.width, height: vp.height } });
+
+ test(`${vp.name} grouped, top`, async ({ page }) => {
+ await open(page);
+ await shot(page, `${vp.name}-grouped-top`);
+ });
+
+ test(`${vp.name} grouped, scrolled bottom-right`, async ({ page }) => {
+ await open(page);
+ await scrollBottomRight(page);
+ await shot(page, `${vp.name}-grouped-scrolled`);
+ });
+
+ test(`${vp.name} flat, scrolled bottom-right`, async ({ page }) => {
+ await open(page);
+ await page.getByLabel("Group by section").uncheck();
+ await expect(page.getByRole("rowheader")).toHaveCount(0);
+ await scrollBottomRight(page);
+ await shot(page, `${vp.name}-flat-scrolled`);
+ });
+
+ test(`${vp.name} deck open, scrolled bottom`, async ({ page }) => {
+ await open(page);
+ await page.getByLabel("select all channels").check();
+ await expect(page.getByLabel("channel priority bulk")).toBeVisible();
+ await scrollBottomRight(page);
+ await shot(page, `${vp.name}-deck-open`);
+ });
+
+ // Below md the deck pins to the SCREEN while the document scrolls: park
+ // the column header under the deck's top edge to see which paints on top.
+ // A shorter window, because at 844 px the header sits above where the
+ // deck starts before the page has scrolled at all.
+ test(`${vp.name} deck open over the column header`, async ({ page }) => {
+ await page.setViewportSize({ width: vp.width, height: 640 });
+ await open(page);
+ await page.getByLabel("select all channels").check();
+ const deck = page.getByLabel("channel priority bulk");
+ await expect(deck).toBeVisible();
+ await page.evaluate(() => {
+ const deckTop = document
+ .querySelector('[aria-label="channel priority bulk"]')!
+ .getBoundingClientRect().top;
+ const theadTop = document.querySelector("thead")!.getBoundingClientRect().top;
+ window.scrollBy(0, theadTop - (deckTop + 12));
+ });
+ await page.waitForTimeout(250);
+ await shot(page, `${vp.name}-deck-over-thead`);
+ });
+
+ test(`${vp.name} Advanced popover open`, async ({ page }) => {
+ await open(page);
+ await page.getByLabel("advanced priority for slow-a").click();
+ await expect(page.getByLabel("download override for slow-a")).toBeVisible();
+ await shot(page, `${vp.name}-popover-open`);
+ });
+ });
+}
diff --git a/editor/e2e/channels-rack-layers.spec.ts b/editor/e2e/channels-rack-layers.spec.ts
@@ -0,0 +1,191 @@
+import { test, expect, type Locator, type Page } from "@playwright/test";
+import { resetData, writeChannelConfig, writeSite } from "./helpers";
+
+// THE RACK'S LAYER LADDER, asserted by hit-testing rather than by reading
+// classes: `document.elementFromPoint` answers "what would a click here land
+// on", which is the operator's complaint ("rows scroll OVER the group
+// controls") stated as a test.
+//
+// The order is popover > thead > group header > identity cells
+// (app/channels/components/rackLayout.ts). The regression this pins down was a
+// TIE: the pinned Slug cell and the group header both at z-20, broken by DOM
+// order — so scrolled down AND right, the slug band of every later row painted
+// over the group header and swallowed clicks on its station buttons.
+
+// Enough channels that the default group's rows outgrow the 1280x720 region,
+// so it scrolls vertically; the sixteen columns already make it scroll across.
+const EXTRA = Array.from(
+ { length: 16 },
+ (_, i) => `rack-${String(i + 1).padStart(2, "0")}`,
+);
+
+// Seeded, loaded and HYDRATED: the stations are disabled until mount, and the
+// measured --thead-h / --rack-w and the popover's onToggle exist only after it.
+async function open(page: Page) {
+ await seed();
+ await page.goto("/channels?site=alpha");
+ await expect(page.getByLabel("sync group News")).toBeEnabled();
+}
+
+async function seed() {
+ await resetData("two-slow-channels");
+ for (const slug of EXTRA) await writeChannelConfig(slug);
+ await writeSite("alpha", {
+ siteTitle: "Alpha",
+ groups: [
+ { id: "default", name: "All channels", selectedByDefault: true, order: 2 },
+ { id: "news", name: "News", selectedByDefault: false, order: 1 },
+ ],
+ channels: [
+ { slug: "slow-b", groupId: "news" },
+ { slug: "slow-a" },
+ ...EXTRA.map((slug) => ({ slug })),
+ ],
+ });
+}
+
+// Does a hit-test at (x, y) land inside `target`?
+async function hitsInside(
+ page: Page,
+ target: Locator,
+ x: number,
+ y: number,
+): Promise<boolean> {
+ const handle = await target.elementHandle();
+ return page.evaluate(
+ ([el, px, py]) => {
+ const hit = document.elementFromPoint(px as number, py as number);
+ return !!hit && (el as Element).contains(hit);
+ },
+ [handle, x, y] as const,
+ );
+}
+
+test.use({ viewport: { width: 1280, height: 720 } });
+
+test("scrolled to the bottom-right, the pinned group header paints over the pinned identity cells", async ({
+ page,
+}) => {
+ await open(page);
+
+ const region = page.getByTestId("channels-rack");
+ await expect(region).toBeVisible();
+ await expect(page.getByRole("link", { name: "rack-16" })).toBeVisible();
+ // The premise: the region really scrolls on both axes, or nothing below
+ // tests anything.
+ const extent = await region.evaluate((el) => ({
+ y: el.scrollHeight - el.clientHeight,
+ x: el.scrollWidth - el.clientWidth,
+ }));
+ expect(extent.y).toBeGreaterThan(0);
+ expect(extent.x).toBeGreaterThan(0);
+ await region.evaluate((el) => {
+ el.scrollTop = el.scrollHeight;
+ el.scrollLeft = el.scrollWidth;
+ });
+
+ const regionBox = (await region.boundingBox())!;
+ const header = page.getByRole("rowheader", { name: "All channels" });
+ const headerBox = (await header.boundingBox())!;
+ // The header pinned under the thead, inside the region's viewport.
+ expect(headerBox.y).toBeGreaterThan(regionBox.y);
+ expect(headerBox.y + headerBox.height).toBeLessThan(
+ regionBox.y + regionBox.height,
+ );
+
+ // (1) Inside the pinned Slug band (it starts at left-8 = 32px of the
+ // region) and on the header's line: the header owns that point. The header
+ // itself scrolled left with the table, so its own box.x is off to the left —
+ // the band is measured from the region, which is what stays put.
+ const x = regionBox.x + 40;
+ const y = headerBox.y + headerBox.height / 2;
+ expect(await hitsInside(page, header, x, y)).toBe(true);
+
+ // (2) The same fact as numbers: the header's z sits strictly above the slug
+ // cell's. A tie is what broke it.
+ const slugCell = page.locator("td", {
+ has: page.getByRole("link", { name: "rack-16", exact: true }),
+ });
+ const zOf = (l: Locator) =>
+ l.evaluate((el) => Number(getComputedStyle(el).zIndex) || 0);
+ expect(await zOf(header)).toBeGreaterThan(await zOf(slugCell));
+
+ // (2b) The group's controls stayed on screen: the header's content pins to
+ // the region's left edge, so scrolled all the way across, its Sync station
+ // is inside the region and a click on it lands on it.
+ const sync = page.getByLabel("sync group All channels");
+ const syncBox = (await sync.boundingBox())!;
+ expect(syncBox.x).toBeGreaterThanOrEqual(regionBox.x);
+ expect(syncBox.x + syncBox.width).toBeLessThanOrEqual(
+ regionBox.x + regionBox.width,
+ );
+ expect(
+ await hitsInside(
+ page,
+ sync,
+ syncBox.x + syncBox.width / 2,
+ syncBox.y + syncBox.height / 2,
+ ),
+ ).toBe(true);
+
+ // (3) The column header still owns its own centre over everything scrolling
+ // under it.
+ const slugTh = page.locator("th", {
+ has: page.getByRole("button", { name: "sort by Slug" }),
+ });
+ const thBox = (await slugTh.boundingBox())!;
+ expect(
+ await hitsInside(
+ page,
+ slugTh,
+ thBox.x + thBox.width / 2,
+ thBox.y + thBox.height / 2,
+ ),
+ ).toBe(true);
+});
+
+test("the Advanced priority popover paints over the rows, pins and headers around it", async ({
+ page,
+}) => {
+ await open(page);
+
+ // A row with rows under it: the panel overlays them, and their pinned
+ // identity cells, which is the overlap the ladder orders.
+ await page.getByLabel("advanced priority for rack-03").click();
+ const popover = page
+ .getByLabel("advanced priority for rack-03")
+ .locator("xpath=following-sibling::div[1]");
+ await expect(popover).toBeVisible();
+ const box = (await popover.boundingBox())!;
+ expect(
+ await hitsInside(page, popover, box.x + box.width / 2, box.y + box.height / 2),
+ ).toBe(true);
+ // And its bottom edge, which overlays the rows that follow it.
+ expect(
+ await hitsInside(page, popover, box.x + box.width / 2, box.y + box.height - 4),
+ ).toBe(true);
+});
+
+test("an Advanced popover opened on the last row scrolls itself into view", async ({
+ page,
+}) => {
+ await open(page);
+ const region = page.getByTestId("channels-rack");
+ // slow-a sorts last: its panel opens past the region's bottom edge.
+ await page.getByLabel("advanced priority for slow-a").click();
+ const popover = page
+ .getByLabel("advanced priority for slow-a")
+ .locator("xpath=following-sibling::div[1]");
+ await expect(popover).toBeVisible();
+ await expect
+ .poll(async () => {
+ const r = (await region.boundingBox())!;
+ const p = (await popover.boundingBox())!;
+ return p.y + p.height <= r.y + r.height + 1;
+ })
+ .toBe(true);
+ const box = (await popover.boundingBox())!;
+ expect(
+ await hitsInside(page, popover, box.x + box.width / 2, box.y + box.height - 4),
+ ).toBe(true);
+});
diff --git a/plans/one-core-phase-3.md b/plans/one-core-phase-3.md
@@ -709,6 +709,132 @@ undated candidates`, twice — once per Next module graph). IO pressure at boot
`full avg10 7.26` (58–66 % at the last rollout, when a remux was saturating the platter), which
is the difference from the 25 minutes release 2 paid.
+### Slice P, as shipped — /channels rack polish (2026-09-24)
+
+Branch `one-core/phase-3-p` off `4130aca1`, eleven commits (the ten below and this record, amended after review),
+not merged — the parent merges; slice W merges after it (W folded nothing under
+`editor/app/channels/components`). The operator's ask: fix every table and z-index problem on
+/channels ("channel rows scroll OVER the group-based controls"). Also, the group Transcribe
+station must stop refusing youtube-handling channels with a whisper-specific sentence.
+
+| sha | what |
+|---|---|
+| `5ac3e8ca` | `rackLayout.ts` — one named layer ladder (popover z-40 > thead z-30 > group header md:z-20 > identity z-10, plus the deck), `RACK_IDENTITY`, `RACK_BRIDGE`; every class site reads it; unit test for the width invariant and the order; `data-testid="channels-rack"`; flat-path overflow comment; `channels-rack-layers.spec.ts` |
+| `7a0b3d75` | the transcribe station counts what its button queues: handling branch deleted, `transcribeStationIds` (one fold, both buckets, exclusions), the group action runs `transcribeAutoSubsBucketAction` + `transcribeMissingAction`, `KIND_FOR` lists both kinds, method-free strings; unit + e2e |
+| `564f767d` | `channels-rack-audit.spec.ts` — 12 screenshots behind `RACK_SHOTS` |
+| `99b06b40` | audit fix A: a group header's name and stations pin left (`sticky left-2`, capped at the measured `--rack-w`) |
+| `53bbd482` | audit fix B: an opened Advanced panel scrolls itself into view (`nearest`) |
+| `476c8470` | audit fix D: the section rule is the th's inset shadow, not the `<tr>`'s collapsed border |
+| `6ff63cbf` | audit fix C: the scroll region is `isolate` (its own stacking context); both rack specs wait for hydration |
+| `e0f731fa` | review F1/F2: the no-transcript half is queued BY ID (`transcribeBucketAction`, `downloadedNoTranscript`); the scan only with no snapshot; `KIND_FOR.transcribe` gains `whisper-bucket-downloaded-no-transcript`; a refused half is logged |
+| `4f951ecc` | review F3/F5: below md an opened Advanced panel keeps a `scroll-mb-48` so it stops above the screen-pinned deck; the region comment names layers by key |
+
+**Root cause 1 — rows over the group controls.** It was a z-index TIE, broken by DOM order.
+The group header (`ChannelGroupHeaderRow`, `md:sticky … md:z-20`) holds the five station
+buttons. The pinned Slug cell had been raised to `z-20` by **`20ee34db`** (2026-09-13) to
+close a sub-pixel seam against the checkbox cell. Scrolled down and right, every later row's
+slug band painted over the header, because it comes later in the DOM. The z bump was never
+needed. `left-8` (32 px) pins the Slug cell 4 px inside the `w-9` (36 px) checkbox cell. Two
+sibling cells at one z-index paint in DOM order, so the Slug cell already covers the overlap at
+z-10. The rack plan's order (`plans/editor-channels-rack.md:198-204`) was right. `278d4463`
+shipped it as 30/20/10, and `20ee34db` put the slug cell on the header's level. A second tie
+went unstated: the popover and the thead were both z-30, and the popover won on DOM order only.
+Both ties are gone. After the fix, grep finds no `z-<n>` literal in
+`editor/app/channels/components/` outside `rackLayout.ts`.
+
+**Root cause 2 — "a youtube-handling channel never runs whisper".** `stationWorkFor`
+refused `handling !== "transcribe"`. But buckets are decided by files, never by handling:
+`downloadedNoTranscript` is whisper work for every channel, and the runner drains it for
+every channel. The channel page already replaces auto-captions for any handling. So the
+station never counted what the runner would do. It now counts `|downloadedNoTranscript| +
+|downloadedAutoSubsOnly|` after the download exclusions. The two buckets are disjoint, and one
+batch cannot cover both, so the button queues two jobs:
+- `whisper-bucket-auto-subs` over the auto-caption ids;
+- `whisper-all` when there are captionless videos, or when the channel has no report.
+
+Both jobs run on `TRANSCRIPTION_QUEUE`. The dedupe spans both kinds. `queued` counts channels,
+and `jobIds` carries the whisper-all id when both jobs were queued. **By id, after review:** with a snapshot, the no-transcript half is
+`transcribeBucketAction` over exactly `transcribeStationIds(…).missing`
+(`whisper-bucket-downloaded-no-transcript`, replayable as `downloadedNoTranscript`), not the
+`whisper-all` scan. `downloadedNoTranscript` does not filter excluded ids, so a scan would also
+transcribe a video downloaded before it went private: 2 on the label, 3 transcribed. The scan
+remains only for a channel with no report. The dedupe now spans all three kinds, including the
+channel page's own bucket job. If one half is refused while the other queues, the refusal is
+logged (`console.warn`): the ok arm of `StreamActionResult` has no message field to carry it. A combined job kind was
+rejected: it would need a replay spec and a /jobs label, and it would mirror nothing, since the
+channel page runs two jobs. A social account is still ineligible (`"social account"`), and
+that is now the only way `notEligible` can be reached.
+
+**The divergence, on purpose.** The rack's transcription band still counts
+`downloadedNoTranscript` alone (`channelSnapshot.ts:583-585`), and the stage title lists
+auto-captions as informational (`stageStatus.ts:344-352`). On a youtube channel with many
+auto-caption-only videos, the station's figure is now higher than the band's. That is the ask:
+the station counts exactly what its button queues. It is not a bug.
+
+**The audit.** Viewports 1440×900 and 390×844. Shots: grouped top; grouped and flat scrolled
+bottom-right; deck open scrolled to the bottom; deck parked over the column header (390×640 /
+1440×640); Advanced popover open. Baseline shots are in `$T/p-shots-before/` (pre-fix, plus
+the testid only). Fix C's before-shot is in `$T/p-shots-c-before/`. The final set is in
+`$T/p-shots-after/` (12 PNGs). `$T` = `/home/user/.claude/jobs/c0baff27/tmp`.
+
+| shot | finding | fix |
+|---|---|---|
+| desktop-grouped-scrolled, layers spec | the pinned slug band paints over the pinned group header and its stations (root cause 1) | `5ac3e8ca` |
+| desktop/mobile-grouped-scrolled | the group header's name and five stations scroll off to the left with the table; only "16 channels" stays in view | `99b06b40` |
+| desktop/mobile-popover-open, layers spec at 1280×720 | the Advanced panel on a row near the bottom (below md: near the right edge too) is clipped by the scroll region, with its selects out of reach | `53bbd482` |
+| desktop-grouped-scrolled (after `5ac3e8ca`) | a gap under the pinned group header: the `<tr>`'s collapsed `border-t-2` belongs to the table grid, so it stays behind when the th pins | `476c8470` |
+| mobile-deck-over-thead | the z-30 thead paints over the screen-pinned z-20 deck ("18 selected", tier select), because the region formed no stacking context | `6ff63cbf` |
+| mobile-* | below md the thead never pins. The region scrolls on both axes, so `sticky top-0` pins to the region and not to the document | recorded, not fixed — the documented trade-off (`ChannelsRack.tsx:152-155`, `channels/page.tsx:332-336`) |
+| mobile-deck-open | the deck at the end of the scroll sits in flow after the last row and covers nothing; while scrolling, it covers what passes under it, as a pinned bar does | recorded, no defect — no padding needed |
+| every shot | the Next dev-tools badge at the bottom left | recorded, dev-only |
+| desktop/mobile-popover-open (after) | while a panel is open, the region's scroll extent grows and a blank band shows under the last row | recorded, not fixed (see "Left") |
+| mobile-deck-over-thead (after) | the region's `-mx-4` edge shows the thead checkbox and the row edge in the 16 px gutter beside the deck | recorded, not fixed — cosmetic, predates the slice |
+| mobile-grouped-* | "Derived data off" wraps under the station line | recorded, not fixed — a wrap at 390 px, not a defect |
+| mobile popover under the deck (review) | with the rack isolated, the deck paints over an overlapping panel, and the scroll-into-view stopped the panel under it | `4f951ecc` |
+| desktop/mobile-*-scrolled | the Build/Tier columns show as a sliver under the pinned slug band | recorded, no defect — columns scroll under a pinned identity column |
+
+**The layers spec fails on the pre-fix code.** The run was the spec's first revision, with two
+tests: the header case and a popover case on `slow-a`. It ran on the `4130aca1` components plus
+the testid only. The header hit-test failed at step (1): the point inside the slug band landed
+on the slug cell. The popover case failed because the last row's panel was clipped. That became
+finding B. The final spec has three tests:
+- the header case, with the Sync station check added by fix A;
+- the popover case, moved to `rack-03`;
+- a `slow-a` scroll-into-view case, added by fix B.
+
+After the fixes: **3/3**. The spec does NOT exercise the popover-vs-thead tie: `rack-03`'s panel
+opens below the thead. That order is held by `rackLayout.test.ts`'s token check only.
+
+**Gates.**
+- tsc: clean after every commit.
+- common: **1727** (1723 − 1 rewritten + 5 new in `channelGroupSections.test.ts`); **1728** after review (the reviewer's walk).
+- editor unit (`tsx --test "app/**/*.test.ts"`): **69** (67 + 2 `rackLayout.test.ts`).
+- `test:scripts`: 156 + 1 skip.
+
+**e2e.** Every run was detached, from the worktree root:
+- baseline (audit + layers, pre-fix): 10 passed, 2 failed (the expected pair above), 1.0 min.
+- mid 1 (audit + layers + channel-groups): 20 passed, 1 failed (finding B), 1.3 min.
+- mid 2 (audit + layers): 13 passed, 2 failed. Both were clicks and measures before hydration. Fixed in the specs (`6ff63cbf`).
+- fix-C before-shot (isolate removed): 2/2, 0.3 min.
+- after (audit + layers): **15 passed, 0 failed**, 0.9 min.
+- after review (`channel-groups`, `channels-rack-layers`, `channel-priority`): **19 passed, 0 failed**, 1.1 min.
+- full list (`$T/p-specs.txt`, 24 files, every named file present, none dropped): **146 passed, 0 failed, 0 flaky, exit 0, 11.8 min**.
+
+**Numbers.** `phase3-view-numbers.ts`, primary's `transcripts/` and `settings.json`,
+read-only. The first before/after pair, hours apart, differed: nuxanor-kick went from 4 to 3
+untranscribed, hasanabi dropped out, digest eligibility went from 76,519 to 76,521. That is
+the live editor transcribing, not code. None of P's files is in the tool's import graph. Run
+back to back, main (primary checkout `77f63356` = `4130aca1` + one plan file) and the branch
+gave **diff empty, 5,119 bytes each**.
+
+**Builds** (at `6ff63cbf`): editor `next build` exit 0, 82 s, route table lists `ƒ /api/view/[name]`. Export `next build` exit 0, 54 s.
+
+**Left.**
+- The mobile thead does not pin (a documented trade-off).
+- `20ee34db`'s `min-w-52` on the tier cell is unchanged.
+- An opened Advanced panel lengthens the region's scroll extent while it is open, because it
+ is absolute inside the scroll box. Closing it restores the extent.
+
## Next release — slice 3b and Phase 4 (inventory kept from 2026-09-23)
Slices 3a and 4b shipped in the release above (2026-09-24); the slice 3b bullets and the