grid tiles get their gap back, and the picture fills the tile

TWO REPORTS, ONE MEASUREMENT WRONG AND ONE SHAPE WRONG.

the rows looked vertically stacked because the card OVERFLOWED its row. GRID_ROW_HEIGHT
was 130 while the card was `p-4` around a 64px thumbnail plus two lines of text —
about 148 — so each card ate the 12px gap and the next row touched it. the horizontal
gap was fine, which is exactly what made it read as a styling oddity rather than a
number being wrong.

the height is now derived from the card instead of guessed: a 96px image band, `py-2`
around a 20px name and a 16px meta line, inside a 2px border. 152. guessing is how it
came to be 130.

THE PICTURE NOW FILLS THE TILE. it was a fixed `h-16 w-20` box, so a landscape photo
sat in the middle with air either side and a portrait one shrank to nothing. `w-full`
with the card clipping means every tile reads as the same shape whatever the
photograph is — which is the whole reason a grid of pictures is easier to scan than a
grid of names.

so the card lost its padding and gained `overflow-hidden`: the image reaches the
edges and the card does the clipping. the padding moved to the text block, the only
part that ever wanted it. the image itself is not rounded — the card clips, and
rounding both would show a sliver of card colour inside each corner.

a folder icon sits in the same 96px band, so a folder and a photograph are the same
shape and the rows line up.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-29 08:29:03 +01:00
co-authored by Claude Opus 5
parent 000df7fe45
commit 03638a7f62
2 changed files with 47 additions and 18 deletions
@@ -15,7 +15,19 @@ type FileGridProps = {
};
const LIST_ROW_HEIGHT = 42;
const GRID_ROW_HEIGHT = 130;
/*
What one grid row actually occupies, and why the number changed.
It was 130 while the card was `p-4` around a 64px thumbnail plus two lines of text — about 148. The
card overflowed its own row by roughly the size of the gap, so consecutive rows touched and the grid
looked vertically stacked. The horizontal gap was fine, which is what made it read as a styling
oddity rather than a measurement being wrong.
Now: a 96px image band, then `py-2` (16) around a 20px name and a 16px meta line, inside a 2px
border. 96 + 16 + 20 + 16 + 4 = 152. Measured against the card rather than guessed, because guessing
is how it came to be 130.
*/
const GRID_ROW_HEIGHT = 152;
const GRID_GAP = 12;
const HEADER_HEIGHT = 36;
// Type-ahead: keys pressed within this window accumulate into one burst. 1–2 chars jump to the first
@@ -641,7 +641,20 @@ export const FileItem = ({
sharp enough for a retina screen at both. One key, one render, and the views cannot diverge.
*/
const listVisual = entry.thumb ? withThumb(entry.thumb, icon, 'size-7 rounded', 256) : icon;
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-16 w-20 rounded-md', 256) : iconLarge;
/*
The grid picture fills the card's WIDTH and is cropped by height.
It used to be a fixed `h-16 w-20` box centred with `object-contain`, so a landscape photo sat in the
middle of the card with air either side and a portrait one shrank to nothing. `w-full` with the
card's own `overflow-hidden` doing the clipping means every tile reads as the same shape whatever
the photograph is — which is the whole reason a grid of pictures is easier to scan than a grid of
names.
No rounding here: the card clips, so rounding the image too would show a sliver of card colour
inside the corner.
*/
const gridVisual = entry.thumb ? withThumb(entry.thumb, iconLarge, 'h-24 w-full', 256) : iconLarge;
useEffect(() => {
return () => {
@@ -784,7 +797,7 @@ export const FileItem = ({
<ContextMenuTrigger asChild onContextMenu={handleContextMenu}>
<div
data-file-item
className={`group relative rounded-lg border-2 p-4 flex flex-col items-center gap-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors cursor-pointer ${
className={`group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border-2 hover:border-duck-teal/50 data-[state=open]:border-duck-teal/50 transition-colors ${
selected ? 'border-duck-teal/50 bg-duck-teal/10' : 'border-duck-dark/20'
} ${cutOpacity} select-none`}
style={selected ? undefined : cardStyle()}
@@ -800,23 +813,27 @@ export const FileItem = ({
<EllipsisMenu {...menuProps} onEnsureSelected={ensureSelected} />
</div>
{gridVisual}
{/* One fixed-height band whether it holds a photograph or a folder icon, so every tile is
the same shape and the rows line up. The card clips; this does not round. */}
<div className="flex h-24 w-full shrink-0 items-center justify-center overflow-hidden">{gridVisual}</div>
{renaming ? (
<InlineRenameInput
initialName={entry.name}
onCommit={handleCommitRename}
onCancel={() => setRenaming(false)}
/>
) : (
<span className="text-sm font-medium text-duck-dark text-center truncate w-full">{entry.name}</span>
)}
<div className="flex min-w-0 flex-col items-center gap-0.5 px-2 py-2">
{renaming ? (
<InlineRenameInput
initialName={entry.name}
onCommit={handleCommitRename}
onCancel={() => setRenaming(false)}
/>
) : (
<span className="w-full truncate text-center text-sm font-medium text-duck-dark">{entry.name}</span>
)}
<span className="text-xs text-duck-dark/50">
{isDir ? t('files.grid.folderType') : formatSize(entry.size)}
{' · '}
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
<span className="text-xs text-duck-dark/50">
{isDir ? t('files.grid.folderType') : formatSize(entry.size)}
{' · '}
{date(entry.modifiedAt, { month: 'short', day: 'numeric', year: 'numeric' })}
</span>
</div>
</div>
</ContextMenuTrigger>
<ContextMenuContent className="z-[600]">