From a4b1802657af713b5abba45f505fc447e9447f0b Mon Sep 17 00:00:00 2001 From: root Date: Thu, 9 Apr 2026 12:19:29 +0200 Subject: [PATCH] fix: square timeline cells that fully fill each row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid was leaving horizontal space unused for two reasons: 1. Width measurement was based on parentRef.clientWidth - PADDING*2, which is fragile to padding/box-sizing/scrollbar mismatches and was off by enough pixels in practice to drop a column. Replace with a 1px-tall normal-flow sentinel rendered inside the inner virtualizer wrapper at the exact horizontal extent rows render at. ResizeObserver on the sentinel gives the authoritative row width — no padding subtraction, no scrollbar guesswork. 2. The flex layout with explicit per-cell px widths accumulated floor() rounding error and let cells drift away from square. Switch to a CSS grid with fixed-px tracks (`repeat(cols, ${cellSize}px)` + matching `gridAutoRows`) so every track is exactly cellSize wide AND tall. By construction `cols × cellSize + (cols-1) × gap == measured width`, so the row fills edge-to-edge with cells that are guaranteed square. PhotoThumbnail gains an opt-in `fill` prop the timeline uses to switch its inline width/height to 100%, so the cell stretches to whatever the parent grid track gives it. Heap sidebar / non-grid callers still get explicit `size`-by-`size` square thumbnails as before. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/timeline/PhotoThumbnail.tsx | 23 ++++++-- frontend/src/components/timeline/Timeline.tsx | 59 ++++++++++++++++--- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index 22f669d..c573408 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -17,6 +17,13 @@ const AUTO_RETRY_DELAYS = [1500, 3500, 7000, 12000, 20000] interface PhotoThumbnailProps { photo: Photo size: number + /** When true, the cell stretches to fill its parent (100% width + + * 100% height) and ignores `size` for the box dimensions. Used by + * the Timeline grid where the parent is a CSS grid track of 1fr — + * this is what guarantees the row fills the container without any + * rounding gap on the right. The heap sidebar leaves this off so + * thumbnails stay at the explicit `size`. */ + fill?: boolean isSelected: boolean /** True when the photo belongs to the currently active heap. */ isInActiveHeap?: boolean @@ -30,6 +37,7 @@ interface PhotoThumbnailProps { export function PhotoThumbnail({ photo, size, + fill = false, isSelected, isInActiveHeap = false, activeHeapName = null, @@ -51,6 +59,12 @@ export function PhotoThumbnail({ // overflowed their row because TanStack Virtual estimates row height as a // single fixed value — portraits in a landscape row would overlap the row // below. With object-cover the image still fills the cell, just cropped. + // + // The cell stretches to whatever width the parent grid track gives it + // (via width:100% + aspect-ratio:1) so the timeline's CSS grid can hand + // out 1fr columns and we never leave horizontal space unused. `size` + // remains the *minimum* track width and the fallback when there's no + // parent grid (e.g. heap thumbnails). const displayHeight = size const clearRetryTimer = () => { @@ -131,10 +145,11 @@ export function PhotoThumbnail({ 'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg', !imageLoaded && 'bg-surface animate-pulse' )} - style={{ - width: size, - height: displayHeight, - }} + style={ + fill + ? { width: '100%', height: '100%' } + : { width: size, height: displayHeight } + } onClick={onClick} onDoubleClick={onDoubleClick} draggable diff --git a/frontend/src/components/timeline/Timeline.tsx b/frontend/src/components/timeline/Timeline.tsx index 9c3d836..ddd3724 100644 --- a/frontend/src/components/timeline/Timeline.tsx +++ b/frontend/src/components/timeline/Timeline.tsx @@ -162,6 +162,12 @@ function buildItems( export function Timeline() { const parentRef = useRef(null) + // Sentinel placed inside the inner virtualizer wrapper at the exact + // position rows will render. We measure THIS instead of parentRef, + // because parentRef has padding and we'd otherwise have to subtract + // it (and account for any scrollbar) — easy to get wrong by a pixel + // and end up with a column count off by one. + const widthSentinelRef = useRef(null) const [containerWidth, setContainerWidth] = useState(0) const { @@ -195,12 +201,18 @@ export function Timeline() { if (containerWidth === 0) { return { columns: 4, cellSize: THUMBNAIL_SIZE } } - const available = containerWidth - PADDING * 2 + // containerWidth here is the sentinel's actual rendered width — no + // padding subtraction needed, the sentinel already lives inside the + // padded scroll container. + const available = containerWidth const cols = Math.max( 1, Math.floor((available + GAP) / (THUMBNAIL_SIZE + GAP)) ) - const cell = Math.floor((available - (cols - 1) * GAP) / cols) + // Exact float — no floor. cellSize × cols + (cols-1) × gap == available + // by construction, so the row fills edge-to-edge without any + // sub-pixel rounding gap. + const cell = (available - (cols - 1) * GAP) / cols return { columns: cols, cellSize: cell } }, [containerWidth]) @@ -279,11 +291,12 @@ export function Timeline() { return current }, [headerOffsets, scrollTop]) - // Measure container width on mount, window resize, and any layout - // change driven by the sidebar collapse / right panel toggle. Plain - // window.resize wouldn't catch those — ResizeObserver does. + // Measure the sentinel's actual rendered width on mount, window + // resize, and any layout change driven by the sidebar collapse / + // right panel toggle. ResizeObserver picks up everything window + // resize misses (sidebar collapse doesn't fire window resize). useEffect(() => { - const el = parentRef.current + const el = widthSentinelRef.current if (!el) return const measure = () => setContainerWidth(el.clientWidth) measure() @@ -485,6 +498,23 @@ export function Timeline() { position: 'relative', }} > + {/* Width sentinel — a 1px-tall normal-flow div that takes the + * full width of the inner virtualizer wrapper, which is the + * exact width rows render at. clientWidth on this is what we + * base the column count on, sidestepping any padding / + * scrollbar mismatch the parentRef-based measurement is + * vulnerable to. ResizeObserver doesn't reliably fire on + * zero-area absolute elements, so 1px tall + relative flow. */} +