From be05c80d82b9166f215a0fcb1f092257366b57fd Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 17 Jul 2026 02:11:06 +0200 Subject: [PATCH] fix(feed): drop the compact row's columns by the ROW's width, not the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/VideoCard.tsx | 47 ++++++++++++++++--------- frontend/src/components/VirtualFeed.tsx | 15 +++++++- 2 files changed, 44 insertions(+), 18 deletions(-) diff --git a/frontend/src/components/VideoCard.tsx b/frontend/src/components/VideoCard.tsx index 5b37762..38d5ae8 100644 --- a/frontend/src/components/VideoCard.tsx +++ b/frontend/src/components/VideoCard.tsx @@ -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 ( -
+
{/* 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). */} @@ -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 (
{/* Actions lead: this is where the eye already is (the title is the thing you read), so @@ -465,20 +474,24 @@ function VideoCard({
{actions}
{/* 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. */}
{title}
-
{channelButton}
- + {fits(ROW_FITS.channel) &&
{channelButton}
} + {fits(ROW_FITS.meta) && } {/* 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. */} -
- {viewsCell(false)} -
-
{published}
+ {fits(ROW_FITS.views) && ( +
+ {viewsCell(false)} +
+ )} + {fits(ROW_FITS.published) && ( +
{published}
+ )} {/* Thumb draws this over the image; with no image it goes on the row itself. */} {pct > 0 && (
diff --git a/frontend/src/components/VirtualFeed.tsx b/frontend/src/components/VirtualFeed.tsx index 59fbc68..b88ebb7 100644 --- a/frontend/src/components/VirtualFeed.tsx +++ b/frontend/src/components/VirtualFeed.tsx @@ -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}