Archilyzer · Source

archilyzer

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

commit 64efeae33e29574992cc22dd860d8506bfcd9860
parent bffaa0cf7928908ca5eab0a63805fde3cb174a47
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Sun, 13 Sep 2026 11:16:53 -0400

channels: the table becomes a rack with a pinned meter bridge

At 1440px the six band columns and Actions sat off-screen: the wrapper was
overflow-visible on md+, so sixteen columns spilled past the viewport rather
than scrolling. The table now lives in one scroll region. Its thead pins to
the top of that region and the checkbox and Slug cells pin to its left, so
the identity column and the column headers never leave the screen while the
bands scroll horizontally. Group headers pin under the thead at its MEASURED
height (a ResizeObserver writes --thead-h; top-9 is the fallback).

md:overflow-hidden comes off the table - it would have become the sticky
ancestor and nothing would have pinned. The rounded corners are the region.

The six band columns become one block: body cells on bg-surface, rules at
the first and last, and one "Pipeline" eyebrow over the first header. Every
header cell is bottom-aligned so that extra line moves no other label.

Density: cells go px-3 py-2 -> px-2 py-1.5, dates render medium/short
instead of the locale default (two thirds the width, one line), counts and
dates get tabular-nums, Handling becomes a mono micro-tag.

The band legend and the Names.A/Names.T explainer move OUT of an 11px stack
below 67 rows into an instrument bar above the rack, beside "Group by
section" - a legend under the chart it explains is of no use to anyone.

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

Diffstat:
Meditor/app/channels/components/ChannelGroupHeaderRow.tsx | 11+++++++++--
Meditor/app/channels/components/ChannelsTable.tsx | 412+++++++++++++++++++++++++++++++++++++++++++++++++------------------------------
2 files changed, 265 insertions(+), 158 deletions(-)

diff --git a/editor/app/channels/components/ChannelGroupHeaderRow.tsx b/editor/app/channels/components/ChannelGroupHeaderRow.tsx @@ -28,14 +28,21 @@ export function ChannelGroupHeaderRow({ const { group, channels } = section; const name = group.name || group.id; return ( - <tr className="border-t-2 border-border bg-muted/40"> + <tr className="border-t-2 border-border"> <th colSpan={colSpan} scope="rowgroup" id={`group-${group.id}`} aria-label={name} data-group={group.id} - className="px-3 py-2 text-left font-normal align-top" + 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. + "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" + } > <div className="flex flex-wrap items-baseline gap-x-3 gap-y-1"> <span diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import type { ChannelStat } from "yt-dlp-transcript-common/controller/channels"; import { bandSentence, @@ -295,126 +295,201 @@ export function ChannelsTable({ }); } + // A GROUP HEADER PINS UNDER THE COLUMN HEADER, not over it. The offset is the + // thead's real height rather than a guess, because the head grows a line when + // the pipeline block's eyebrow renders and shrinks again when settings switch + // every derived operation off. `top-9` is the fallback the class carries for + // the first paint and for a browser with no ResizeObserver. + const regionRef = useRef<HTMLDivElement>(null); + const theadRef = useRef<HTMLTableSectionElement>(null); + useEffect(() => { + const region = regionRef.current; + const thead = theadRef.current; + if (!region || !thead || typeof ResizeObserver === "undefined") return; + const measure = () => + region.style.setProperty("--thead-h", `${thead.offsetHeight}px`); + measure(); + const observer = new ResizeObserver(measure); + observer.observe(thead); + return () => observer.disconnect(); + }, []); + return ( - <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible"> - <div className="px-4 md:px-0 pb-2"> - <ChannelFocusBar sites={sites} focusLabel={focusLabel} /> + // On md+ this is a pane of the page's viewport-height flex column: the + // instrument bar and the deck are fixed chrome and only the region between + // them scrolls. Below md it has no height of its own and the document + // scrolls, exactly as it always has. + <div className="flex min-h-0 flex-1 flex-col"> + <ChannelFocusBar sites={sites} focusLabel={focusLabel} /> + {/* THE INSTRUMENT BAR: what the rack is showing (grouping) on the left, + how to read it (the band legend, and the two-route explainer behind a + disclosure) on the right. Both used to live in an 11px stack UNDER 67 + rows, where the legend for a chart is of no use to anyone. */} + <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 px-1 py-2 text-xs text-muted-foreground"> + {sections && sections.length > 0 ? ( + <label className="flex items-center gap-2"> + <input + type="checkbox" + checked={grouped} + onChange={(e) => setGrouped(e.target.checked)} + className="accent-primary" + /> + Group by section + </label> + ) : ( + <span /> + )} + <div className="flex flex-wrap items-center gap-x-4 gap-y-1"> + <BandLegend /> + {columns.some((c) => c.id.startsWith("attribution-")) && ( + <details className="max-w-prose"> + <summary className="cursor-pointer hover:text-foreground"> + Names&middot;A / Names&middot;T + </summary> + <p className="pt-1"> + Speaker names come from the audio (Names&middot;A) or the + transcript (Names&middot;T) &mdash; two routes to the same + field, counted separately because they cost different things. + </p> + </details> + )} + </div> </div> - {sections && sections.length > 0 && ( - <label className="flex items-center gap-2 px-4 md:px-0 pb-2 text-xs text-muted-foreground"> - <input - type="checkbox" - checked={grouped} - onChange={(e) => setGrouped(e.target.checked)} - className="accent-primary" - /> - Group by section - </label> - )} - <table className="text-sm border-y md:border border-border md:rounded-md md:overflow-hidden w-full"> - <thead className="bg-muted"> - <tr> - <th className="px-2 py-2"> - <input - type="checkbox" - aria-label="select all channels" - checked={allSelected} - onChange={(e) => - setSelected( - e.target.checked - ? new Set(channels.map((c) => c.slug)) - : new Set(), - ) - } - className="accent-primary" + {/* THE RACK. One scroll box: the thead pins to its top, the checkbox and + 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. */} + <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" + > + <table className="w-full text-sm"> + <thead ref={theadRef} className="sticky top-0 z-30 bg-muted"> + <tr> + <th className="sticky left-0 z-40 w-8 bg-muted px-2 py-1.5 align-bottom shadow-[1px_0_0_var(--color-border)]"> + <input + type="checkbox" + aria-label="select all channels" + checked={allSelected} + onChange={(e) => + setSelected( + e.target.checked + ? new Set(channels.map((c) => c.slug)) + : new Set(), + ) + } + className="accent-primary" + /> + </th> + <SortableTh + label="Slug" + sortKey="slug" + sort={sort} + onClick={onHeaderClick} + className="sticky left-8 z-40 bg-muted shadow-[1px_0_0_var(--color-border)]" + /> + <SortableTh + label="Name" + sortKey="name" + sort={sort} + onClick={onHeaderClick} + /> + <SortableTh + label="Handling" + sortKey="handling" + sort={sort} + onClick={onHeaderClick} + /> + <SortableTh + label="Build" + sortKey="build" + sort={sort} + onClick={onHeaderClick} + /> + <SortableTh + label="Tier" + sortKey="tier" + sort={sort} + onClick={onHeaderClick} + title="Channel priority: Normal, Low or Paused, with per-operation pins behind Advanced. The four auto-queue trees are compiled from this column." + /> + <SortableTh + label="Playlist" + sortKey="playlist" + sort={sort} + onClick={onHeaderClick} + align="right" /> - </th> - <SortableTh - label="Slug" - sortKey="slug" - sort={sort} - onClick={onHeaderClick} - /> - <SortableTh - label="Name" - sortKey="name" - sort={sort} - onClick={onHeaderClick} - /> - <SortableTh - label="Handling" - sortKey="handling" - sort={sort} - onClick={onHeaderClick} - /> - <SortableTh - label="Build" - sortKey="build" - sort={sort} - onClick={onHeaderClick} - /> - <SortableTh - label="Tier" - sortKey="tier" - sort={sort} - onClick={onHeaderClick} - title="Channel priority: Normal, Low or Paused, with per-operation pins behind Advanced. The four auto-queue trees are compiled from this column." - /> - <SortableTh - label="Playlist" - sortKey="playlist" - sort={sort} - onClick={onHeaderClick} - align="right" - /> - <SortableTh - label="Last sync" - sortKey="lastSync" - sort={sort} - onClick={onHeaderClick} - className="whitespace-nowrap" - /> - <SortableTh - label="Report" - sortKey="report" - sort={sort} - onClick={onHeaderClick} - className="whitespace-nowrap" - title="When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong." - /> - {columns.map((col) => ( <SortableTh - key={col.id} - label={col.shortLabel} - sortKey={`op:${col.id}`} + label="Last sync" + sortKey="lastSync" sort={sort} onClick={onHeaderClick} - className="w-28 min-w-24" - title={`${col.label} — ${col.costBasis}. Sorts by what can run now.`} + className="whitespace-nowrap" /> - ))} - <th className="text-left font-medium px-3 py-2 whitespace-nowrap"> - Actions - </th> - </tr> - </thead> - {showSections ? ( - // One sort state, applied independently per section — which IS - // "sorts within each group". - sections.map((section) => ( - <tbody - key={section.group.id} - aria-labelledby={`group-${section.group.id}`} - > - <ChannelGroupHeaderRow - section={section} - siteId={siteId as string} - colSpan={colSpan} + <SortableTh + label="Report" + sortKey="report" + sort={sort} + onClick={onHeaderClick} + className="whitespace-nowrap" + title="When this channel's report was last generated. Every count and band on the row is read from it — stale or missing means those numbers may be wrong." /> - {applySort( - section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []), - sort, - ).map((c) => ( + {/* THE METER BRIDGE. Six loose grey dashes become one block: the + band columns share an eyebrow naming them, and the rules that + open and close the block run the full height of the rack. */} + {columns.map((col, i) => ( + <SortableTh + key={col.id} + label={col.shortLabel} + sortKey={`op:${col.id}`} + sort={sort} + onClick={onHeaderClick} + eyebrow={i === 0 ? "Pipeline" : undefined} + className={ + "w-24 min-w-20" + + (i === 0 ? " border-l border-border" : "") + + (i === columns.length - 1 ? " border-r border-border" : "") + } + title={`${col.label} — ${col.costBasis}. Sorts by what can run now.`} + /> + ))} + <th className="whitespace-nowrap px-2 py-1.5 text-left align-bottom font-medium"> + Actions + </th> + </tr> + </thead> + {showSections ? ( + // One sort state, applied independently per section — which IS + // "sorts within each group". + sections.map((section) => ( + <tbody + key={section.group.id} + aria-labelledby={`group-${section.group.id}`} + > + <ChannelGroupHeaderRow + section={section} + siteId={siteId as string} + colSpan={colSpan} + /> + {applySort( + section.channels.flatMap((c) => rowBySlug.get(c.slug) ?? []), + sort, + ).map((c) => ( + <ChannelTableRow + key={c.slug} + channel={c} + columns={columns} + selected={selected.has(c.slug)} + onToggle={toggleOne} + /> + ))} + </tbody> + )) + ) : ( + <tbody> + {rows.map((c) => ( <ChannelTableRow key={c.slug} channel={c} @@ -424,36 +499,17 @@ export function ChannelsTable({ /> ))} </tbody> - )) - ) : ( - <tbody> - {rows.map((c) => ( - <ChannelTableRow - key={c.slug} - channel={c} - columns={columns} - selected={selected.has(c.slug)} - onToggle={toggleOne} - /> - ))} - </tbody> - )} - </table> + )} + </table> + </div> + {/* OUTSIDE the overflow box, always: inside it, `sticky bottom-0` pins to + the table's scroll box rather than to the screen, which is what put + the old bar off the bottom of a phone. */} <ChannelSelectionDeck slugs={selectedSlugs} onClear={() => setSelected(new Set())} defaultRoot={defaultMediaRoot} /> - <div className="flex flex-col gap-1 px-3 py-2 md:px-0"> - <BandLegend /> - {columns.some((c) => c.id.startsWith("attribution-")) && ( - <p className="text-xs text-muted-foreground"> - Speaker names come from the audio (Names&middot;A) or the transcript - (Names&middot;T) &mdash; two routes to the same field, counted - separately because they cost different things. - </p> - )} - </div> </div> ); } @@ -477,6 +533,7 @@ function PipelineCell({ column, band, count, + className = "", }: { slug: string; column: PipelineColumn; @@ -488,12 +545,17 @@ function PipelineCell({ // better than the bare <td> it replaces — a screen reader now gets the number // AND a label saying what it counts. count: number | null; + // The bridge's own chrome: the surface it sits on and the rules that open and + // close the block. Owned by the row, because only the row knows which cell is + // first and which is last. + className?: string; }) { - if (!band) return <Td className="text-muted-foreground">&mdash;</Td>; + if (!band) + return <Td className={`text-muted-foreground ${className}`}>&mdash;</Td>; const sentence = bandSentence(band); return ( <Td - className="align-middle" + className={className} ariaLabel={`${column.countLabel ?? column.label} for ${slug}`} title={`${column.label} — ${sentence}`} > @@ -518,10 +580,16 @@ function ChannelTableRow({ selected: boolean; onToggle: (slug: string) => void; }) { + // A sticky cell paints over the row behind it, so it cannot inherit the row's + // 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"; return ( <tr className={ "border-t border-border " + + (selected ? "bg-accent " : "") + // 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 // exclusion flag that used to dim it is now a `sync` pin, which is a @@ -531,7 +599,9 @@ function ChannelTableRow({ : "") } > - <td className="px-2 py-2"> + <td + className={`sticky left-0 z-10 w-8 px-2 py-1.5 align-middle shadow-[1px_0_0_var(--color-border)] ${stickyBg}`} + > <input type="checkbox" aria-label={`select ${c.slug}`} @@ -540,7 +610,9 @@ function ChannelTableRow({ className="accent-primary" /> </td> - <Td className="font-mono"> + <Td + className={`sticky left-8 z-10 font-mono shadow-[1px_0_0_var(--color-border)] ${stickyBg}`} + > <span className="inline-flex items-center gap-1.5"> <Link href={`/channels/${c.slug}`} @@ -551,8 +623,10 @@ function ChannelTableRow({ <MediaLocationBadge media={c.media} compact /> </span> </Td> - <Td>{c.config.name ?? ""}</Td> - <Td>{c.config.handling}</Td> + <Td className="max-w-48 truncate">{c.config.name ?? ""}</Td> + <Td className="font-mono text-[11px] text-muted-foreground"> + {c.config.handling} + </Td> <Td> <ChannelBuildToggle slug={c.slug} @@ -568,32 +642,36 @@ function ChannelTableRow({ heldReason={c.priority.heldReason} /> </Td> - <Td className="text-right" ariaLabel={`playlist count for ${c.slug}`}> + <Td + className="text-right tabular-nums" + ariaLabel={`playlist count for ${c.slug}`} + > {c.playlistCount ?? "—"} </Td> - <Td className="text-xs text-muted-foreground whitespace-nowrap"> - {c.config.lastSyncedAt - ? new Date(c.config.lastSyncedAt).toLocaleString() - : ""} + <Td className="whitespace-nowrap text-xs tabular-nums text-muted-foreground"> + {formatStamp(c.config.lastSyncedAt)} </Td> <Td ariaLabel={`report age for ${c.slug}`} - className={`text-xs whitespace-nowrap ${ + className={`whitespace-nowrap text-xs tabular-nums ${ c.report.state === "current" ? "text-muted-foreground" : "text-warning" }`} > {c.report.state === "current" - ? c.report.generatedAt - ? new Date(c.report.generatedAt).toLocaleString() - : "" + ? formatStamp(c.report.generatedAt) : c.report.state} </Td> - {columns.map((col) => ( + {columns.map((col, i) => ( <PipelineCell key={col.id} slug={c.slug} column={col} band={bandOf(c, col.id)} + className={ + bridge + + (i === 0 ? " border-l border-border" : "") + + (i === columns.length - 1 ? " border-r border-border" : "") + } count={ col.id === "download" ? c.downloadCount @@ -614,6 +692,17 @@ function ChannelTableRow({ ); } +// A 40px row has one line for a date, so the date has to fit one line. The +// locale default spells the year and the seconds out ("11/09/2026, 10:24:31"); +// medium/short is the same instant in the same locale, two thirds the width. +function formatStamp(iso: string | null | undefined): string { + if (!iso) return ""; + return new Date(iso).toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + function SortableTh({ label, sortKey, @@ -622,6 +711,7 @@ function SortableTh({ align = "left", className = "", title, + eyebrow, }: { label: string; sortKey: SortKey; @@ -630,6 +720,11 @@ function SortableTh({ align?: "left" | "right"; className?: string; title?: string; + // Names the BLOCK this header opens, not the column: the first band header + // carries "Pipeline" for the six that follow it. Every header cell is + // bottom-aligned so the extra line never pushes its neighbours' labels out of + // line. + eyebrow?: string; }) { const active = sort?.key === sortKey; const indicator = active ? (sort?.dir === "asc" ? "▲" : "▼") : ""; @@ -637,11 +732,16 @@ function SortableTh({ const textAlign = align === "right" ? "text-right" : "text-left"; return ( <th - className={`${textAlign} font-medium px-3 py-2 ${className}`} + className={`${textAlign} px-2 py-1.5 align-bottom font-medium ${className}`} aria-sort={ active ? (sort?.dir === "asc" ? "ascending" : "descending") : "none" } > + {eyebrow && ( + <span className="block font-mono text-[10px] uppercase tracking-[0.16em] text-muted-foreground"> + {eyebrow} + </span> + )} <button type="button" onClick={() => onClick(sortKey)} @@ -676,7 +776,7 @@ function Td({ }) { return ( <td - className={`px-3 py-2 ${className}`} + className={`px-2 py-1.5 align-middle ${className}`} aria-label={ariaLabel} title={title} >