Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 50e8496a93301150cec10e0c2429b7df463ffab9
parent 6f2d48322fbb80c12c42cdd03040f6784a49d62c
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 18:57:31 -0400

channels: one layer ladder for the rack

"Rows scroll over the group controls" was a z-index TIE broken by DOM
order: the pinned Slug cell (z-20, lifted by 57247cbf to close a seam
against the checkbox cell) and the pinned group header (md:z-20) sat on
one level, so scrolled down and right the slug band of every later row
painted over the group header and its five station buttons.

rackLayout.ts names the ladder once — popover z-40 > thead z-30 > group
header md:z-20 > identity z-10 (both pinned cells), plus the mobile
deck — and every class site reads it; no z-<n> literal is left in
channels/components outside it. The seam needs no z bump: the Slug cell
pins at left-8 (32 px) inside the w-9 (36 px) checkbox cell, and the
later sibling at one level paints over the 4 px overlap. The unit test
holds that invariant and the ladder's order. The popover no longer ties
the thead either.

channels-rack-layers.spec.ts hit-tests it: scrolled bottom-right, a
point inside the pinned slug band on the group header's line belongs to
the header; the header's z beats the slug cell's; the Slug column header
owns its centre; the Advanced popover owns its own. The region carries
data-testid="channels-rack" for it. The flat (non-sticky) table's
overflow-hidden stays, with a comment that the sticky branch must never
carry one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

Diffstat:
Meditor/app/channels/components/ChannelGroupHeaderRow.tsx | 9++++++---
Meditor/app/channels/components/ChannelSelectionDeck.tsx | 3++-
Meditor/app/channels/components/ChannelTierSelect.tsx | 3++-
Meditor/app/channels/components/ChannelsRack.tsx | 1+
Meditor/app/channels/components/ChannelsTable.tsx | 18++++++++++++------
Meditor/app/channels/components/channelColumns.tsx | 6+++---
Aeditor/app/channels/components/rackLayout.test.ts | 23+++++++++++++++++++++++
Aeditor/app/channels/components/rackLayout.ts | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/channels-rack-layers.spec.ts | 143+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 251 insertions(+), 14 deletions(-)

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 @@ -38,10 +39,12 @@ 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" + `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"> 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; @@ -206,7 +207,7 @@ export default function ChannelTierSelect({ > 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} 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 @@ -205,6 +205,7 @@ export function ChannelsRack({ the region's. */} <div ref={regionRef} + data-testid="channels-rack" className="relative -mx-4 min-h-0 flex-1 overflow-auto border-y border-border md:mx-0 md:rounded-md md:border" > <ChannelsTable 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/e2e/channels-rack-layers.spec.ts b/editor/e2e/channels-rack-layers.spec.ts @@ -0,0 +1,143 @@ +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")}`, +); + +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 seed(); + await page.goto("/channels?site=alpha"); + + 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)); + + // (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 seed(); + await page.goto("/channels?site=alpha"); + + // 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); +});