feat(v2): render cover art at its natural aspect ratio

Stop forcing a fixed box ratio on gallery covers. Cards now share one
height and vary in width to match each cover image's natural aspect, with
no cropping.

- GameCover measures the rendered image's natural ratio on load and drives
  its own aspect-ratio from it (style ratio kept only as a pre-load seed);
  emits the measured ratio.
- GameCard default (gallery) card: fixed art height, natural width; size
  tiers and hero keep their fixed footprints. Forwards the ratio event.
- Gallery grid becomes flow-packed wrapping rows: useGalleryVirtualItems
  greedily packs same-height / natural-width cards per row (ragged right),
  measuring ratios client-side (cached by rom id, debounced re-pack). Row
  height stays uniform so RVirtualScroller, AlphaStrip, scroll restoration
  and grid-nav are unchanged.
- useResponsiveColumns additionally exposes usableWidth for width packing.
- Unit tests for packFlowRows.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Georges-Antoine Assi
2026-06-21 17:33:28 -04:00
parent dbc6139752
commit e9ab144d39
6 changed files with 294 additions and 90 deletions

View File

@@ -242,19 +242,21 @@ const { groupBy, layout, toolbarPosition } = useGalleryMode();
// CSS grid `minmax(--r-card-art-w, 1fr)` stay in lock-step.
const { xs, smAndDown } = useBreakpoint();
const sectionEl = ref<HTMLElement | null>(null);
// Single source for the responsive card-art width — feeds both the column
// count and the virtualiser's row-height math so they never drift.
const cardWidth = () => (xs.value ? 108 : 158);
const { columns } = useResponsiveColumns(sectionEl, {
// Card-art width reference (matches GameCard's `--r-card-art-w`). It sets
// the card HEIGHT (a 2/3 cover's height at this width); each card's real
// width then follows its cover's natural ratio.
const CARD_GAP_PX = 12;
const cardWidth = () => (xs.value ? 130 : 158);
const cardHeight = () => Math.round(cardWidth() / (2 / 3));
const { columns, usableWidth } = useResponsiveColumns(sectionEl, {
cardWidth,
gap: 12,
gap: CARD_GAP_PX,
inset: () => (xs.value ? 64 : smAndDown.value ? 76 : 108),
});
// Active cover aspect ratio from the gallery-wide boxart style. Drives
// both the cards' `--r-cover-ratio` (via the shell root, inherited) and
// the virtualiser's row height so the cover shape, the grid, and the
// scroll offsets all agree.
// Fallback cover ratio from the gallery-wide boxart style — used only as
// the per-card `--r-cover-ratio` seed (before GameCover measures the real
// image) and for the bootstrap skeleton rows.
const { boxartStyle } = useUISettings();
const coverAspectRatio = computed(() =>
coverRatio(
@@ -262,6 +264,31 @@ const coverAspectRatio = computed(() =>
),
);
// Measured natural cover ratios, keyed by rom id (stable across gallery
// context switches, so the cache survives navigation). GameCard reports
// each cover's ratio once its image loads; the flow-packer reads them via
// `ratioAt(position)`. Updates are batched behind `ratioVersion` so a burst
// of image loads triggers a single re-pack instead of one per cover.
const ratioByRomId = new Map<number, number>();
const ratioVersion = ref(0);
let ratioBumpTimer: ReturnType<typeof setTimeout> | null = null;
function onCardRatio(payload: { romId: number; ratio: number }) {
const prev = ratioByRomId.get(payload.romId);
// Ignore no-op / sub-pixel changes so we don't re-pack for nothing.
if (prev != null && Math.abs(prev - payload.ratio) < 0.01) return;
ratioByRomId.set(payload.romId, payload.ratio);
if (ratioBumpTimer) return;
ratioBumpTimer = setTimeout(() => {
ratioBumpTimer = null;
ratioVersion.value++;
}, 150);
}
function ratioAt(position: number): number {
const romId = galleryRoms.romIdIndex[position];
if (romId == null) return 0; // → packer falls back to the default ratio
return ratioByRomId.get(romId) ?? 0;
}
// 2D arrow / gamepad nav for both layouts of the gallery. Two passes:
// * Grid mode — rows are `.r-v2-shell__row` (the per-virtualizer-item
// wrapper around the row's GameCards). ArrowLeft/Right within a row,
@@ -301,8 +328,11 @@ const { virtualItems, letterToIndex, availableLetters, getItemHeight } =
notFound: notFoundRef,
notFoundMessage: notFoundMessageRef,
skeletonRowCount: props.skeletonRowCount,
coverRatio: coverAspectRatio,
cardWidth,
cardHeight,
rowWidth: usableWidth,
gap: CARD_GAP_PX,
ratioAt,
ratioVersion,
});
const scrollerRef = ref<InstanceType<typeof RVirtualScroller> | null>(null);
@@ -644,6 +674,7 @@ onBeforeUnmount(() => {
gallerySelection.clear();
if (searchDebounce) clearTimeout(searchDebounce);
if (fetchDebounceTimer) clearTimeout(fetchDebounceTimer);
if (ratioBumpTimer) clearTimeout(ratioBumpTimer);
// Cancel any per-position fetches still in flight for our last
// visible set — `invalidateWindows` / `resetGallery` already aborts
// window-level fetches; this covers per-card cleanup on unmount.
@@ -682,10 +713,6 @@ const asEmpty = (i: GalleryItem) => i as EmptyItem;
const asListRow = (i: GalleryItem) => i as ListRowItem;
const itemKind = (i: GalleryItem) => i.kind;
const rowGridStyle = computed(() => ({
gridTemplateColumns: `repeat(${Math.max(1, columns.value)}, minmax(var(--r-card-art-w), 1fr))`,
}));
// View-facing surface. Methods only — internal state stays internal.
defineExpose({
/** Re-apply the previously-saved scroll position for the current route
@@ -787,7 +814,6 @@ defineExpose({
<div
v-else-if="itemKind(item as GalleryItem) === 'row'"
class="r-v2-shell__row"
:style="rowGridStyle"
>
<template
v-for="(p, slotIdx) in rowPositions(asRow(item as GalleryItem))"
@@ -802,6 +828,7 @@ defineExpose({
:show-platform-badge="showPlatformBadge"
selectable
:position="p"
@ratio="onCardRatio"
/>
<GameCardSkeleton v-else />
</template>
@@ -831,7 +858,6 @@ defineExpose({
<div
v-else-if="itemKind(item as GalleryItem) === 'skeleton-row'"
class="r-v2-shell__row"
:style="rowGridStyle"
>
<GameCardSkeleton
v-for="n in Math.max(1, columns)"
@@ -988,9 +1014,18 @@ defineExpose({
margin-bottom: 16px;
}
/* Flow-packed wrapping row: same-height, natural-width cards laid left to
right. The JS packer (useGalleryVirtualItems) chose how many cards fit,
so `nowrap` is safe — a brief over/under-fill during the load transient
(before measured ratios settle) just leaves a ragged right edge or a
hair of overflow, corrected on the next re-pack. `align-items:
flex-start` keeps every card pinned to the same top. The 12px column gap
matches the packer's `gap`; the 18px row gap matches the chrome math. */
.r-v2-shell__row {
display: grid;
gap: 18px 12px;
display: flex;
flex-wrap: nowrap;
align-items: flex-start;
gap: 12px;
padding-bottom: 18px;
}
@@ -1086,14 +1121,12 @@ defineExpose({
);
}
/* Smaller default cards on phones so the grid packs 23 per row instead
of one stretched card. The grid `minmax(--r-card-art-w, 1fr)` and the
GameCards (default size, reading the token) both shrink in lock-step;
the JS column-chunking above uses a matching 108px card width. Height
is left to the card's `--r-cover-ratio` derive rule so the boxart style
drives the cover shape on phones too. */
/* Smaller cards on phones. Matches GameCard's own xs `--r-card-art-w`
(130px) so the shell's skeletons and the flow-packer's card-height
reference (`cardWidth()` in script) stay in lock-step with what the
cards actually render. */
html[data-bp~="xs"] .r-v2-shell {
--r-card-art-w: 108px;
--r-card-art-w: 130px;
}
html[data-bp~="xs"] .r-v2-shell__scroller {

View File

@@ -234,6 +234,10 @@ function onPlatformClick(e: MouseEvent) {
const emit = defineEmits<{
(e: "click", event: MouseEvent): void;
/** The cover's natural aspect ratio (width / height) once its image
* loads — forwarded from GameCover so the gallery can flow-pack cards
* at their true shape. Carries the rom id so the consumer can key it. */
(e: "ratio", payload: { romId: number; ratio: number }): void;
}>();
// Gallery selection — only wired when the consumer opts in via
@@ -409,6 +413,7 @@ function onStaticKeydown(e: KeyboardEvent) {
:webp="webp"
:active="coverActive"
:morph-id="isSynthetic ? null : rom.id"
@ratio="emit('ratio', { romId: rom.id, ratio: $event })"
>
<!-- Selection checkbox top-left, drawn over the cover. Hidden
at rest; appears on hover for discoverability, and stays
@@ -524,13 +529,30 @@ function onStaticKeydown(e: KeyboardEvent) {
color: var(--r-color-fg);
}
/* Default (gallery) card derives its art height from the active cover
ratio so the boxart style drives the shape (cover_path 2/3, box3d 3/4,
physical / miximage 1/1). Explicit size tiers + hero keep their fixed
footprints — they set `--r-card-art-h` directly. `--r-cover-ratio` is
set inline by `useCoverArt`; the fallback keeps standalone cards sane. */
/* Default (gallery) card: FIXED art height, NATURAL width. The height is
the footprint a 2/3 box-art cover would have at `--r-card-art-w`, so
box-art cards keep their familiar size; the cover's real width then
follows its own aspect ratio (`--r-cover-ratio`, set per-image by
GameCover). Cards end up the same height with varying widths. Explicit
size tiers + hero keep their fixed footprints (set `--r-card-art-h`/
width directly). */
.r-gc:not([class*="r-gc--size-"]):not(.r-gc--hero) {
--r-card-art-h: calc(var(--r-card-art-w) / var(--r-cover-ratio, 0.6667));
--r-card-art-h: calc(var(--r-card-art-w) / 0.6667);
width: auto;
display: flex;
flex-direction: column;
}
/* Width follows the cover (overrides GameCover's base `width: 100%`). */
.r-gc:not([class*="r-gc--size-"]):not(.r-gc--hero) .r-gc__art {
width: auto;
}
/* Keep the label from widening the card past its cover: it contributes 0
to the card's intrinsic width but fills (and ellipsises within) whatever
width the cover establishes. */
.r-gc:not([class*="r-gc--size-"]):not(.r-gc--hero) .r-gc__label {
width: 0;
min-width: 100%;
max-width: 100%;
}
/* The art box IS the shared <GameCover> (this class lands on its root).

View File

@@ -81,6 +81,13 @@ const props = withDefaults(defineProps<Props>(), {
morphStatic: false,
});
const emit = defineEmits<{
/** Fires with the rendered image's natural aspect ratio (width / height)
* once it loads. Lets a surface that lays cards out by their true shape
* (the gallery's wrapping rows) pack without a forced ratio. */
ratio: [number];
}>();
const art = useCoverArt(() => props.rom, {
coverSrc: () => props.coverSrc,
forceStyle: props.forceStyle
@@ -115,12 +122,30 @@ const coverLoaded = ref(false);
const activeSrc = computed(() =>
showFallback.value ? art.fallbackUrl.value : art.coverUrl.value,
);
// Natural aspect ratio (width / height) of the rendered image, measured on
// load. Drives the cover box's actual shape — no forced style ratio. Null
// until the image decodes (or when showing the placeholder), where we fall
// back to the style ratio as a sensible first guess.
const naturalRatio = ref<number | null>(null);
function measureNaturalRatio() {
const el = imgEl.value;
if (el && el.naturalWidth > 0 && el.naturalHeight > 0) {
const r = el.naturalWidth / el.naturalHeight;
naturalRatio.value = r;
emit("ratio", r);
}
}
watch(activeSrc, () => {
coverLoaded.value = false;
naturalRatio.value = null;
});
const onCoverLoad = () => {
coverLoaded.value = true;
measureNaturalRatio();
};
// The box shape: the image's true ratio once known, else the style ratio
// as a first guess (keeps box-art cards from jumping — they're 2/3 anyway).
const boxRatio = computed(() => naturalRatio.value ?? art.ratio.value);
const selfHover = ref(false);
const coverActive = computed(
@@ -161,6 +186,7 @@ onMounted(() => {
// soft initial paint instead of getting stuck blurred).
if (imgEl.value?.complete && imgEl.value.naturalWidth > 0) {
coverLoaded.value = true;
measureNaturalRatio();
}
if (!props.hoverMotion) return;
rootEl.value?.addEventListener("mouseenter", onEnter);
@@ -187,7 +213,7 @@ defineExpose({
ref="rootEl"
class="game-cover"
:class="{ 'game-cover--alt': isAltStyle }"
:style="[{ '--r-cover-ratio': art.ratio.value }, morphStyle]"
:style="[{ '--r-cover-ratio': boxRatio }, morphStyle]"
>
<img
v-if="showingImage"

View File

@@ -68,6 +68,42 @@ export function galleryRowHeight(
return Math.round(cardWidth / ratio) + ROW_CHROME_PX;
}
/** Flow-pack a contiguous run of positions into rows that each fill the
* available width: cards keep their natural width (`cardHeight * ratio`)
* and wrap to the next row when the next card would overflow. A card wider
* than the whole row still gets its own row (never an empty row). Pure —
* exported for unit tests. `ratioAt` must already apply any default. */
export function packFlowRows(
start: number,
end: number,
rowWidth: number,
cardHeight: number,
gap: number,
ratioAt: (position: number) => number,
): Array<{ start: number; end: number }> {
const rows: Array<{ start: number; end: number }> = [];
if (end <= start) return rows;
let rowStart = start;
let acc = 0;
for (let p = start; p < end; p++) {
const w = cardHeight * ratioAt(p);
if (p === rowStart) {
acc = w;
continue;
}
const next = acc + gap + w;
if (next > rowWidth) {
rows.push({ start: rowStart, end: p });
rowStart = p;
acc = w;
} else {
acc = next;
}
}
rows.push({ start: rowStart, end });
return rows;
}
interface Options {
layout: Ref<LayoutMode> | ComputedRef<LayoutMode>;
groupBy: Ref<GroupByMode> | ComputedRef<GroupByMode>;
@@ -89,13 +125,22 @@ interface Options {
notFoundMessage?: Ref<string> | ComputedRef<string>;
/** Skeleton row count while loading the first window. */
skeletonRowCount?: number;
/** Active cover aspect ratio (width / height) — drives grid row height
* so AlphaStrip jumps and scroll offsets stay exact when the boxart
* style changes the cover shape. Defaults to box-art 2/3. */
coverRatio?: MaybeRefOrGetter<number>;
/** Card-art width used to derive the row height (match the value fed to
* `useResponsiveColumns`). Defaults to the md card (158px). */
cardWidth?: MaybeRefOrGetter<number>;
/** Fixed card-art HEIGHT in px — every card shares it, so every grid row
* has the same height (the scroller stays exact-offset). Defaults to the
* md footprint (a 2/3 cover at 158px → 237px). */
cardHeight?: MaybeRefOrGetter<number>;
/** Usable px width of a row (container minus gutters / AlphaStrip). The
* flow-packer fills a row until the next card would overflow it. */
rowWidth?: MaybeRefOrGetter<number>;
/** Horizontal gap between cards in px (default 12). */
gap?: MaybeRefOrGetter<number>;
/** Natural cover ratio (width / height) for a position — the card's
* width is `cardHeight * ratioAt(p)`. Defaults to box-art 2/3 for
* positions whose image hasn't been measured yet. */
ratioAt?: (position: number) => number;
/** Bump to force a re-pack when measured ratios change. Read inside the
* layout so Vue tracks it; the packer itself calls `ratioAt`. */
ratioVersion?: Ref<number> | ComputedRef<number>;
}
const ALPHABET = "#ABCDEFGHIJKLMNOPQRSTUVWXYZ@".split("");
@@ -156,21 +201,24 @@ export function useGalleryVirtualItems(opts: Options) {
buildLetterRanges(opts.charIndex.value, opts.total.value),
);
// Per-style row height — recomputes when the boxart ratio or the
// responsive card width changes, so every grid row keeps an exact
// offset (the scroller is exact-offset, not measured).
const rowHeightPx = computed(() =>
galleryRowHeight(
opts.coverRatio != null ? toValue(opts.coverRatio) : DEFAULT_COVER_RATIO,
opts.cardWidth != null
? toValue(opts.cardWidth)
: REFERENCE_COVER_WIDTH_PX,
),
);
const cardHeightPx = () =>
opts.cardHeight != null
? toValue(opts.cardHeight)
: Math.round(REFERENCE_COVER_WIDTH_PX / DEFAULT_COVER_RATIO);
// Uniform row height: every card shares one fixed art height, so every
// grid row is the same height regardless of how many (variable-width)
// cards it holds. Keeps the scroller exact-offset with no measuring.
const rowHeightPx = computed(() => cardHeightPx() + ROW_CHROME_PX);
const ratioAt = (position: number): number => {
const r = opts.ratioAt?.(position);
return r != null && r > 0 ? r : DEFAULT_COVER_RATIO;
};
// Signature matches RVirtualScroller's `getItemHeight` prop (which uses
// `unknown` because the primitive is generic). Row / skeleton-row use
// the per-style height; every other kind is fixed.
// `unknown` because the primitive is generic). Row / skeleton-row share
// the uniform height; every other kind is fixed.
function getItemHeight(item: unknown): number {
const { kind } = item as GalleryItem;
if (kind === "row" || kind === "skeleton-row") return rowHeightPx.value;
@@ -259,47 +307,64 @@ export function useGalleryVirtualItems(opts: Options) {
return items;
}
const cols = Math.max(1, opts.columns.value);
const total = opts.total.value;
const ranges = letterRanges.value;
const cardHeight = cardHeightPx();
const gap = opts.gap != null ? toValue(opts.gap) : 12;
// Fall back to a one-card-wide row before the container is measured, so
// packing degrades to one-per-row rather than dividing by zero.
const rowWidth = Math.max(
cardHeight,
opts.rowWidth != null ? toValue(opts.rowWidth) : 0,
);
// Read the version so a measured-ratio change re-runs the pack.
void (opts.ratioVersion ? opts.ratioVersion.value : 0);
if (opts.groupBy.value === "letter") {
// Group by letter — each letter section gets a header followed by
// its own row chunks (rows always restart at the letter's first
// position, so a letter never shares a visual row with another).
// Group by letter — each letter section gets a header followed by its
// own flow-packed rows (rows restart at the letter's first position,
// so a letter never shares a visual row with another).
for (const range of ranges) {
items.push({
kind: "letter-header",
key: `lh-${range.letter}`,
letter: range.letter,
});
const rowsInGroup = Math.ceil((range.end - range.start) / cols);
for (let r = 0; r < rowsInGroup; r++) {
const rowStart = range.start + r * cols;
const rowEnd = Math.min(rowStart + cols, range.end);
for (const row of packFlowRows(
range.start,
range.end,
rowWidth,
cardHeight,
gap,
ratioAt,
)) {
items.push({
kind: "row",
key: `row-${range.letter}-${r}`,
rowIndex: r,
startPosition: rowStart,
endPosition: rowEnd,
key: `row-${row.start}`,
rowIndex: 0,
startPosition: row.start,
endPosition: row.end,
letters: [range.letter],
});
}
}
} else {
// Flat — rows are aligned to absolute positions.
const totalRows = Math.ceil(total / cols);
for (let r = 0; r < totalRows; r++) {
const rowStart = r * cols;
const rowEnd = Math.min(rowStart + cols, total);
// Flat — flow-pack the whole list; rows stay contiguous position runs.
for (const row of packFlowRows(
0,
total,
rowWidth,
cardHeight,
gap,
ratioAt,
)) {
items.push({
kind: "row",
key: `row-flat-${r}`,
rowIndex: r,
startPosition: rowStart,
endPosition: rowEnd,
letters: lettersInRange(ranges, rowStart, rowEnd),
key: `row-${row.start}`,
rowIndex: 0,
startPosition: row.start,
endPosition: row.end,
letters: lettersInRange(ranges, row.start, row.end),
});
}
}
@@ -343,20 +408,22 @@ export function useGalleryVirtualItems(opts: Options) {
}
if (opts.groupBy.value !== "letter") {
// Flat mode — for each letter range, jump to the row that holds
// its first position.
let firstRowIdx = -1;
for (let i = 0; i < items.length; i++) {
if (items[i].kind === "row") {
firstRowIdx = i;
break;
}
}
if (firstRowIdx >= 0) {
const cols = Math.max(1, opts.columns.value);
for (const range of letterRanges.value) {
if (map.has(range.letter)) continue;
map.set(range.letter, firstRowIdx + Math.floor(range.start / cols));
// Flat mode — rows are variable-size contiguous runs, so map each
// letter to the row whose position range contains its first position.
// Rows and letterRanges are both ascending, so one forward walk with
// a letter pointer assigns every letter in O(rows + letters).
const ranges = letterRanges.value; // sorted by start
let li = 0;
for (let i = 0; i < items.length && li < ranges.length; i++) {
const it = items[i];
if (it.kind !== "row") continue;
while (
li < ranges.length &&
ranges[li].start >= it.startPosition &&
ranges[li].start < it.endPosition
) {
if (!map.has(ranges[li].letter)) map.set(ranges[li].letter, i);
li++;
}
}
}

View File

@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import { packFlowRows } from "./index";
// Card width = cardHeight * ratio. With cardHeight 100 and ratio 1 each
// card is 100px wide; a 12px gap sits between cards in a row.
const SQUARE = () => 1; // 100px wide per card
const H = 100;
const GAP = 12;
describe("packFlowRows", () => {
it("fills a row until the next card would overflow, then wraps", () => {
// Row width 340: card(100) +12+100 +12+100 = 324 fits; a 4th (+12+100)
// = 436 > 340 → wraps. So 3 per row.
const rows = packFlowRows(0, 7, 340, H, GAP, SQUARE);
expect(rows).toEqual([
{ start: 0, end: 3 },
{ start: 3, end: 6 },
{ start: 6, end: 7 },
]);
});
it("packs more, narrower cards per row when covers are portrait", () => {
// Portrait 0.5 → 50px wide. Row 340: 50,+12+50(112),+62(174),+62(236),
// +62(298),+62(360>340 stop) → 5 per row.
const rows = packFlowRows(0, 5, 340, H, GAP, () => 0.5);
expect(rows).toEqual([{ start: 0, end: 5 }]);
});
it("gives an over-wide card its own row instead of an empty one", () => {
// ratio 4 → 400px wide > row 340. Each lands alone.
const rows = packFlowRows(0, 2, 340, H, GAP, () => 4);
expect(rows).toEqual([
{ start: 0, end: 1 },
{ start: 1, end: 2 },
]);
});
it("mixes widths within a row by running natural width", () => {
const ratioAt = (p: number) => (p === 1 ? 2 : 0.5); // 50,200,50,50
// Row 340: p0=50; +12+200=262; +12+50=324; +12+50=386>340 → wrap.
const rows = packFlowRows(0, 4, 340, H, GAP, ratioAt);
expect(rows).toEqual([
{ start: 0, end: 3 },
{ start: 3, end: 4 },
]);
});
it("returns nothing for an empty range", () => {
expect(packFlowRows(5, 5, 340, H, GAP, SQUARE)).toEqual([]);
});
});

View File

@@ -45,6 +45,10 @@ export function useResponsiveColumns(
const min = options.min ?? 1;
const columns = ref<number>(min);
// Observed content width minus `inset` — the px available to a row of
// cards. Exposed for consumers that flow-pack by width (the gallery's
// wrapping rows) rather than chunk by a fixed column count.
const usableWidth = ref<number>(0);
let observer: ResizeObserver | null = null;
// Last observed width — kept so a change in a reactive option (card
// width / inset flipping at a breakpoint) can recompute without waiting
@@ -58,6 +62,7 @@ export function useResponsiveColumns(
const inset = resolve(options.inset, 0);
const usable = width - inset;
if (usable <= 0) return;
if (usable !== usableWidth.value) usableWidth.value = usable;
// CSS auto-fill semantics: floor((containerWidth + gap) / (cardWidth + gap))
const next = Math.max(min, Math.floor((usable + gap) / (cardWidth + gap)));
if (next !== columns.value) columns.value = next;
@@ -101,5 +106,5 @@ export function useResponsiveColumns(
onBeforeUnmount(detach);
return { columns };
return { columns, usableWidth };
}