fix(feed): drop the compact row's columns by the ROW's width, not the viewport

The viewport was the wrong signal: the nav rail and filter panel move the
row's width by ~430px between them, so at a 1280 window with both pinned
every column still qualified while the row had 762px for 766px of them —
the title squeezed back to nothing, the exact bug the breakpoints were
meant to fix. My own comment predicted it and I didn't follow it through.

VirtualFeed already measures the container for colCount, so it now passes
the item's real width down. Measured in the failing config: title -4 ->
359 with the date column standing down.
This commit is contained in:
2026-07-17 02:11:06 +02:00
parent 21cc5de034
commit be05c80d82
2 changed files with 44 additions and 18 deletions
+30 -17
View File
@@ -149,7 +149,7 @@ function resumePct(video: Video): number {
// reading as a column. The flex row this replaces let a "36:36" and a "3:31:09" push the marker
// beside them to different places, and pulled it left again whenever the duration was absent
// (live/upcoming) — visible as a ragged edge down the page.
function RowMetaCells({ video, className }: { video: Video; className?: string }) {
function RowMetaCells({ video }: { video: Video }) {
const { t } = useTranslation();
// Icons, not the labelled chips the thumbnail uses: at a glance this is the least important thing
// in the row, so it gets about a letter's worth of width. Icons rather than a coloured dot
@@ -168,12 +168,7 @@ function RowMetaCells({ video, className }: { video: Video; className?: string }
// widen a slot and the title column silently pays for it. rem, not px, so the whole cell tracks
// the text-size setting the same way its contents do.
return (
<div
className={clsx(
"shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted",
className
)}
>
<div className="shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted">
{/* Right-aligned: a duration is a number, so the digits line up on their own edge (30px for
"1:52" up to 59px for an 11-hour stream — 4rem holds the longest). */}
<span className="text-right tabular-nums">
@@ -327,9 +322,20 @@ function Thumb({
);
}
// What the one-line row needs before each column earns its place, cumulative from the left:
// actions (192) + a title worth reading (~120) + gaps + the row's padding, then each column plus
// its gap. Driven by the ROW's measured width, never the viewport — see VirtualFeed's itemWidth.
const ROW_FITS = {
channel: 490,
meta: 606,
views: 682,
published: 886,
};
function VideoCard({
video,
view,
width,
onState,
onResetState,
onToggleSave,
@@ -338,6 +344,8 @@ function VideoCard({
}: {
video: Video;
view: FeedView;
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number;
onState: (id: string, status: string) => void;
onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => void;
@@ -458,6 +466,7 @@ function VideoCard({
// The title takes what's left, which is constant per container width — so it aligns too.
if (noThumb) {
const pct = resumePct(video);
const fits = (needs: number) => width === 0 || width >= needs;
return (
<div className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}>
{/* Actions lead: this is where the eye already is (the title is the thing you read), so
@@ -465,20 +474,24 @@ function VideoCard({
<div className="shrink-0 w-48">{actions}</div>
{/* Everything else is shrink-0, so the title is the only thing that can give — which means
once the fixed columns outgrow the row, it gives ALL of it and vanishes. Columns drop
from the right as space runs out, least important first: a title with no date beats a
date with no title. Breakpoints are the viewport, not this row, so they're rough — the
rail and filter panel move the row's width too — but they're pitched to keep the title
alive at the narrow end rather than to hold every column to the last pixel. */}
from the right as the row narrows, least important first: a title with no date beats a
date with no title. `width` is the ROW's own, so this holds whatever the rail and the
filter panel are doing. Until the first measure lands (width 0) show everything: the
row is off-screen for that frame anyway, and guessing narrow would flash. */}
<div className="min-w-0 flex-1">{title}</div>
<div className="shrink-0 w-32 hidden sm:block">{channelButton}</div>
<RowMetaCells video={video} className="hidden md:grid" />
{fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelButton}</div>}
{fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px
holds the widest bare form ("101.7K" at 48) in either language — dropping the word
took this from 144 and handed the difference to the title. */}
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate hidden lg:block">
{viewsCell(false)}
</div>
<div className="shrink-0 w-48 text-sm text-muted truncate hidden xl:block">{published}</div>
{fits(ROW_FITS.views) && (
<div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
{viewsCell(false)}
</div>
)}
{fits(ROW_FITS.published) && (
<div className="shrink-0 w-48 text-sm text-muted truncate">{published}</div>
)}
{/* Thumb draws this over the image; with no image it goes on the row itself. */}
{pct > 0 && (
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40">
+14 -1
View File
@@ -86,6 +86,12 @@ export default function VirtualFeed({
// Distance from the scroll element's content top to where this list starts (the
// feed toolbar sits above it inside the same scroll area).
const [scrollMargin, setScrollMargin] = useState(0);
// How wide ONE row/card actually renders. The one-line row drops columns as this shrinks, and it
// has to be the real width, not the viewport: the nav rail (56 slim / ~230 pinned) and the filter
// panel (~80 tab / ~256 pinned) move it by ~430px between them. A viewport breakpoint got this
// wrong — at 1280 with both pinned every column still showed while the row had 762px for 766px of
// them, which squeezed the title to nothing. This is measured here anyway, for colCount.
const [itemWidth, setItemWidth] = useState(0);
useLayoutEffect(() => {
if (listRef.current) setScrollEl(getScrollParent(listRef.current));
@@ -103,7 +109,13 @@ export default function VirtualFeed({
scrollEl.scrollTop;
setScrollMargin(m);
}
setColCount(columnsFor(view, node.clientWidth));
const w = node.clientWidth;
const cols = columnsFor(view, w);
setColCount(cols);
const { minCol: min, maxCol: max } = FEED_VIEW_SPEC[view];
setItemWidth(
min == null ? Math.min(w, max ?? w) : Math.floor((w - GRID_GAP * (cols - 1)) / cols)
);
};
measure();
const ro = new ResizeObserver(measure);
@@ -168,6 +180,7 @@ export default function VirtualFeed({
key={v.id}
video={v}
view={view}
width={itemWidth}
onState={onState}
onToggleSave={onToggleSave}
onOpenChannel={onOpenChannel}