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 // 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 // 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. // (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(); const { t } = useTranslation();
// Icons, not the labelled chips the thumbnail uses: at a glance this is the least important thing // 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 // 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 // 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. // the text-size setting the same way its contents do.
return ( return (
<div <div className="shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted">
className={clsx(
"shrink-0 grid grid-cols-[4rem_1rem_1rem] gap-1 items-center text-sm text-muted",
className
)}
>
{/* Right-aligned: a duration is a number, so the digits line up on their own edge (30px for {/* 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). */} "1:52" up to 59px for an 11-hour stream — 4rem holds the longest). */}
<span className="text-right tabular-nums"> <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({ function VideoCard({
video, video,
view, view,
width,
onState, onState,
onResetState, onResetState,
onToggleSave, onToggleSave,
@@ -338,6 +344,8 @@ function VideoCard({
}: { }: {
video: Video; video: Video;
view: FeedView; view: FeedView;
/** Measured width of this card/row, from VirtualFeed. 0 until the first measure. */
width: number;
onState: (id: string, status: string) => void; onState: (id: string, status: string) => void;
onResetState?: (id: string) => void; onResetState?: (id: string) => void;
onToggleSave: (id: string, saved: boolean) => 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. // The title takes what's left, which is constant per container width — so it aligns too.
if (noThumb) { if (noThumb) {
const pct = resumePct(video); const pct = resumePct(video);
const fits = (needs: number) => width === 0 || width >= needs;
return ( return (
<div className={clsx(rowShell, "flex items-center gap-3 px-3 py-2", watched && "opacity-55")}> <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 {/* 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> <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 {/* 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 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 from the right as the row narrows, 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 date with no title. `width` is the ROW's own, so this holds whatever the rail and the
rail and filter panel move the row's width too — but they're pitched to keep the title filter panel are doing. Until the first measure lands (width 0) show everything: the
alive at the narrow end rather than to hold every column to the last pixel. */} row is off-screen for that frame anyway, and guessing narrow would flash. */}
<div className="min-w-0 flex-1">{title}</div> <div className="min-w-0 flex-1">{title}</div>
<div className="shrink-0 w-32 hidden sm:block">{channelButton}</div> {fits(ROW_FITS.channel) && <div className="shrink-0 w-32">{channelButton}</div>}
<RowMetaCells video={video} className="hidden md:grid" /> {fits(ROW_FITS.meta) && <RowMetaCells video={video} />}
{/* Right-aligned like the duration: it's a number, so the digits share an edge. 64px {/* 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 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. */} 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"> {fits(ROW_FITS.views) && (
{viewsCell(false)} <div className="shrink-0 w-16 text-sm text-muted text-right tabular-nums truncate">
</div> {viewsCell(false)}
<div className="shrink-0 w-48 text-sm text-muted truncate hidden xl:block">{published}</div> </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. */} {/* Thumb draws this over the image; with no image it goes on the row itself. */}
{pct > 0 && ( {pct > 0 && (
<div className="absolute bottom-0 left-2 right-2 h-1 rounded-full bg-black/40"> <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 // Distance from the scroll element's content top to where this list starts (the
// feed toolbar sits above it inside the same scroll area). // feed toolbar sits above it inside the same scroll area).
const [scrollMargin, setScrollMargin] = useState(0); 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(() => { useLayoutEffect(() => {
if (listRef.current) setScrollEl(getScrollParent(listRef.current)); if (listRef.current) setScrollEl(getScrollParent(listRef.current));
@@ -103,7 +109,13 @@ export default function VirtualFeed({
scrollEl.scrollTop; scrollEl.scrollTop;
setScrollMargin(m); 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(); measure();
const ro = new ResizeObserver(measure); const ro = new ResizeObserver(measure);
@@ -168,6 +180,7 @@ export default function VirtualFeed({
key={v.id} key={v.id}
video={v} video={v}
view={view} view={view}
width={itemWidth}
onState={onState} onState={onState}
onToggleSave={onToggleSave} onToggleSave={onToggleSave}
onOpenChannel={onOpenChannel} onOpenChannel={onOpenChannel}