From e9ab144d396e7599c065e29fe8cb106dd67b25db Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 21 Jun 2026 17:33:28 -0400 Subject: [PATCH 01/15] 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) --- .../v2/components/Gallery/GalleryShell.vue | 85 ++++++--- .../src/v2/components/GameCard/GameCard.vue | 34 +++- .../src/v2/components/shared/GameCover.vue | 28 ++- .../useGalleryVirtualItems/index.ts | 179 ++++++++++++------ .../packFlowRows.test.ts | 51 +++++ .../composables/useResponsiveColumns/index.ts | 7 +- 6 files changed, 294 insertions(+), 90 deletions(-) create mode 100644 frontend/src/v2/composables/useGalleryVirtualItems/packFlowRows.test.ts diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index b80e2d41a..20ec189c4 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -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(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(); +const ratioVersion = ref(0); +let ratioBumpTimer: ReturnType | 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 | 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({
@@ -831,7 +858,6 @@ defineExpose({
(); // 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 })" > -
-
-
- - {{ t("platform.cover-style") }} -
-
- -
-
-
From e49070602815b8cc7f6ab05ed67da32ad6efb98b Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 21 Jun 2026 17:54:35 -0400 Subject: [PATCH 03/15] fix(v2): pin gallery cards to flex-shrink: 0 so covers never crop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flow-packer sizes a row from cardHeight * ratio (floating point) and the browser rounds rendered widths, so a "just fits" row can land a hair over the container. With shrink enabled, fixed-height cards absorb that by narrowing — cropping the cover via object-fit. Pin shrink to 0 on the row's children: trades the rare sub-pixel crop for a hair of ragged overflow, and keeps loading skeletons (default shrink: 1) at packed width. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/v2/components/Gallery/GalleryShell.vue | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index 20ec189c4..acaadf66e 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -1028,6 +1028,16 @@ defineExpose({ gap: 12px; padding-bottom: 18px; } +/* Never shrink a card: the packer sizes a row from `cardHeight * ratio` + (floating point), and the browser rounds the rendered widths, so a row + the packer computed as "just fits" can land a hair over the container. + With shrink enabled, fixed-height cards would absorb that by narrowing — + cropping the cover (object-fit). Pinning shrink to 0 trades that rare + sub-pixel crop for a hair of ragged overflow instead, and keeps loading + skeletons (which otherwise default to shrink) at their packed width. */ +.r-v2-shell__row > * { + flex-shrink: 0; +} /* Card reveal animation (.r-v2-card-fade) lives in global.css — shared with the Home dashboard rows. */ From fd7d40aa1addd20f5a09478ecd2b69619361c5a0 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 21 Jun 2026 17:55:33 -0400 Subject: [PATCH 04/15] docs(v2): note the gallery ratio cache is intentionally unbounded ratioByRomId is keyed by rom id so a re-visited platform/collection re-packs instantly without re-waiting on image loads. It's never pruned, but each entry is two numbers (a few hundred KB even at tens of thousands of distinct ROMs), so an LRU ceiling isn't worth it. Document the choice so it reads as deliberate. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/v2/components/Gallery/GalleryShell.vue | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index acaadf66e..845c4dc1b 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -269,6 +269,12 @@ const coverAspectRatio = computed(() => // 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. +// +// Intentionally unbounded for the page's lifetime: keeping it by rom id is +// what lets a re-visited platform/collection re-pack instantly without +// waiting on image loads again. Each entry is two numbers, so even a user +// browsing tens of thousands of distinct ROMs costs a few hundred KB — not +// worth an LRU ceiling. Reset happens naturally on a full page reload. const ratioByRomId = new Map(); const ratioVersion = ref(0); let ratioBumpTimer: ReturnType | null = null; From 8f257962016146b49ace2702f8a48d47b5059631 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 21 Jun 2026 17:58:17 -0400 Subject: [PATCH 05/15] refactor(v2): drop unused rowIndex from gallery row item Since rows became variable-length contiguous runs, rowIndex was hardcoded to 0 at both build sites and read nowhere. Remove it so a future reader doesn't mistake 0 for a meaningful index. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/v2/composables/useGalleryVirtualItems/index.ts | 2 -- frontend/src/v2/composables/useGalleryVirtualItems/types.ts | 3 ++- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/v2/composables/useGalleryVirtualItems/index.ts b/frontend/src/v2/composables/useGalleryVirtualItems/index.ts index 60c547609..22e962c23 100644 --- a/frontend/src/v2/composables/useGalleryVirtualItems/index.ts +++ b/frontend/src/v2/composables/useGalleryVirtualItems/index.ts @@ -341,7 +341,6 @@ export function useGalleryVirtualItems(opts: Options) { items.push({ kind: "row", key: `row-${row.start}`, - rowIndex: 0, startPosition: row.start, endPosition: row.end, letters: [range.letter], @@ -361,7 +360,6 @@ export function useGalleryVirtualItems(opts: Options) { items.push({ kind: "row", key: `row-${row.start}`, - rowIndex: 0, startPosition: row.start, endPosition: row.end, letters: lettersInRange(ranges, row.start, row.end), diff --git a/frontend/src/v2/composables/useGalleryVirtualItems/types.ts b/frontend/src/v2/composables/useGalleryVirtualItems/types.ts index a724f4560..b9cda1ea7 100644 --- a/frontend/src/v2/composables/useGalleryVirtualItems/types.ts +++ b/frontend/src/v2/composables/useGalleryVirtualItems/types.ts @@ -22,7 +22,8 @@ export type GalleryItem = | { kind: "row"; key: string; - rowIndex: number; + /** A contiguous run of ROM positions, flow-packed to fill the row + * width. Variable length — there's no fixed column count. */ startPosition: number; endPosition: number; // exclusive /** Letters covered by this row's position range (from server's From d5f30d1fabecf9d2d842a62723918e8c5e201f9c Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 21 Jun 2026 18:06:23 -0400 Subject: [PATCH 06/15] docs(v2): collapse verbose comment blocks from the aspect-ratio work Tighten the multi-line comments added across GameCover/GameCard/ GalleryShell/useGalleryVirtualItems/useResponsiveColumns and the packFlowRows test to one or two lines each, keeping the load-bearing rationale and dropping the prose. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v2/components/Gallery/GalleryShell.vue | 51 ++++++------------- .../src/v2/components/GameCard/GameCard.vue | 14 ++--- .../src/v2/components/shared/GameCover.vue | 14 ++--- .../useGalleryVirtualItems/index.ts | 49 +++++++----------- .../packFlowRows.test.ts | 17 +++---- .../useGalleryVirtualItems/types.ts | 3 +- .../composables/useResponsiveColumns/index.ts | 5 +- 7 files changed, 53 insertions(+), 100 deletions(-) diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index 845c4dc1b..235cf3d4c 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -242,9 +242,8 @@ const { groupBy, layout, toolbarPosition } = useGalleryMode(); // CSS grid `minmax(--r-card-art-w, 1fr)` stay in lock-step. const { xs, smAndDown } = useBreakpoint(); const sectionEl = ref(null); -// 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. +// Card-art width reference (matches GameCard's `--r-card-art-w`); sets the +// fixed card HEIGHT (a 2/3 cover at this width). Real width follows the ratio. const CARD_GAP_PX = 12; const cardWidth = () => (xs.value ? 130 : 158); const cardHeight = () => Math.round(cardWidth() / (2 / 3)); @@ -254,9 +253,8 @@ const { columns, usableWidth } = useResponsiveColumns(sectionEl, { inset: () => (xs.value ? 64 : smAndDown.value ? 76 : 108), }); -// 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. +// Fallback cover ratio (boxart style) — the per-card `--r-cover-ratio` seed +// before GameCover measures the real image, plus the bootstrap skeletons. const { boxartStyle } = useUISettings(); const coverAspectRatio = computed(() => coverRatio( @@ -264,17 +262,10 @@ 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. -// -// Intentionally unbounded for the page's lifetime: keeping it by rom id is -// what lets a re-visited platform/collection re-pack instantly without -// waiting on image loads again. Each entry is two numbers, so even a user -// browsing tens of thousands of distinct ROMs costs a few hundred KB — not -// worth an LRU ceiling. Reset happens naturally on a full page reload. +// Measured natural cover ratios, keyed by rom id (the key survives gallery +// switches, so a re-visited platform re-packs without re-waiting on images). +// Updates batch behind `ratioVersion` → one re-pack per burst, not per cover. +// Intentionally unbounded: two numbers per rom, reset on page reload. const ratioByRomId = new Map(); const ratioVersion = ref(0); let ratioBumpTimer: ReturnType | null = null; @@ -1020,13 +1011,9 @@ 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. */ +/* Flow-packed wrapping row: same-height, natural-width cards. The packer + sized it to fit, so `nowrap` is safe; gaps match the packer (12) and the + chrome math (18). `flex-start` pins every card to the same top. */ .r-v2-shell__row { display: flex; flex-wrap: nowrap; @@ -1034,13 +1021,9 @@ defineExpose({ gap: 12px; padding-bottom: 18px; } -/* Never shrink a card: the packer sizes a row from `cardHeight * ratio` - (floating point), and the browser rounds the rendered widths, so a row - the packer computed as "just fits" can land a hair over the container. - With shrink enabled, fixed-height cards would absorb that by narrowing — - cropping the cover (object-fit). Pinning shrink to 0 trades that rare - sub-pixel crop for a hair of ragged overflow instead, and keeps loading - skeletons (which otherwise default to shrink) at their packed width. */ +/* Never shrink: float rounding can push a "just fits" row a hair over, and + shrinking a fixed-height card would crop its cover. Take ragged overflow + instead (also keeps skeletons, default shrink:1, at their packed width). */ .r-v2-shell__row > * { flex-shrink: 0; } @@ -1137,10 +1120,8 @@ defineExpose({ ); } -/* 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. */ +/* Smaller cards on phones. Matches GameCard's own xs `--r-card-art-w` so + skeletons and the packer's card-height reference track the real cards. */ html[data-bp~="xs"] .r-v2-shell { --r-card-art-w: 130px; } diff --git a/frontend/src/v2/components/GameCard/GameCard.vue b/frontend/src/v2/components/GameCard/GameCard.vue index ba0b448c5..023216b37 100644 --- a/frontend/src/v2/components/GameCard/GameCard.vue +++ b/frontend/src/v2/components/GameCard/GameCard.vue @@ -529,13 +529,9 @@ function onStaticKeydown(e: KeyboardEvent) { color: var(--r-color-fg); } -/* 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). */ +/* Default (gallery) card: fixed art height (a 2/3 cover's height at + `--r-card-art-w`), natural width from the cover's own ratio. Same height, + varying widths. Size tiers + hero keep their fixed footprints. */ .r-gc:not([class*="r-gc--size-"]):not(.r-gc--hero) { --r-card-art-h: calc(var(--r-card-art-w) / 0.6667); width: auto; @@ -546,9 +542,7 @@ function onStaticKeydown(e: KeyboardEvent) { .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. */ +/* Label fills the cover's width and ellipsises, without widening the card. */ .r-gc:not([class*="r-gc--size-"]):not(.r-gc--hero) .r-gc__label { width: 0; min-width: 100%; diff --git a/frontend/src/v2/components/shared/GameCover.vue b/frontend/src/v2/components/shared/GameCover.vue index 74f9e347d..700341a0d 100644 --- a/frontend/src/v2/components/shared/GameCover.vue +++ b/frontend/src/v2/components/shared/GameCover.vue @@ -82,9 +82,8 @@ const props = withDefaults(defineProps(), { }); 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. */ + /** The rendered image's natural ratio (w / h) once loaded — lets the + * gallery's wrapping rows pack by true shape. */ ratio: [number]; }>(); @@ -122,10 +121,8 @@ 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. +// Natural ratio (w / h) of the rendered image, measured on load — drives +// the box shape. Null until decoded / for the placeholder (→ style ratio). const naturalRatio = ref(null); function measureNaturalRatio() { const el = imgEl.value; @@ -143,8 +140,7 @@ 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). +// True ratio once known, else the style ratio as a first guess. const boxRatio = computed(() => naturalRatio.value ?? art.ratio.value); const selfHover = ref(false); diff --git a/frontend/src/v2/composables/useGalleryVirtualItems/index.ts b/frontend/src/v2/composables/useGalleryVirtualItems/index.ts index 22e962c23..4d1ffe919 100644 --- a/frontend/src/v2/composables/useGalleryVirtualItems/index.ts +++ b/frontend/src/v2/composables/useGalleryVirtualItems/index.ts @@ -68,11 +68,9 @@ 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. */ +/** Flow-pack a contiguous run of positions into width-filling rows: each + * card is `cardHeight * ratio` wide and wraps when it would overflow (an + * over-wide card gets its own row). Pure; `ratioAt` applies its own default. */ export function packFlowRows( start: number, end: number, @@ -125,21 +123,17 @@ interface Options { notFoundMessage?: Ref | ComputedRef; /** Skeleton row count while loading the first window. */ skeletonRowCount?: 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). */ + /** Fixed card-art height in px, shared by every card so every row has the + * same height. Defaults to the md footprint (158px / (2/3) → 237px). */ cardHeight?: MaybeRefOrGetter; - /** Usable px width of a row (container minus gutters / AlphaStrip). The - * flow-packer fills a row until the next card would overflow it. */ + /** Usable px width of a row (container minus gutters / AlphaStrip). */ rowWidth?: MaybeRefOrGetter; /** Horizontal gap between cards in px (default 12). */ gap?: MaybeRefOrGetter; - /** 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. */ + /** Natural cover ratio (w / h) for a position (card width = + * `cardHeight * ratioAt(p)`). Defaults to 2/3 until the image is measured. */ 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`. */ + /** Bump to force a re-pack when measured ratios change (Vue tracks it). */ ratioVersion?: Ref | ComputedRef; } @@ -206,9 +200,8 @@ export function useGalleryVirtualItems(opts: Options) { ? 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. + // Uniform row height (cards share one fixed art height) keeps the scroller + // exact-offset regardless of how many variable-width cards a row holds. const rowHeightPx = computed(() => cardHeightPx() + ROW_CHROME_PX); const ratioAt = (position: number): number => { @@ -216,9 +209,8 @@ export function useGalleryVirtualItems(opts: Options) { 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 share - // the uniform height; every other kind is fixed. + // `unknown` matches RVirtualScroller's generic prop. 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; @@ -311,19 +303,16 @@ export function useGalleryVirtualItems(opts: Options) { 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. + // At least one card wide before the container is measured. const rowWidth = Math.max( cardHeight, opts.rowWidth != null ? toValue(opts.rowWidth) : 0, ); - // Read the version so a measured-ratio change re-runs the pack. + // Track 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 flow-packed rows (rows restart at the letter's first position, - // so a letter never shares a visual row with another). + // Header + own flow-packed rows per letter (rows restart per letter). for (const range of ranges) { items.push({ kind: "letter-header", @@ -406,10 +395,8 @@ export function useGalleryVirtualItems(opts: Options) { } if (opts.groupBy.value !== "letter") { - // 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). + // Map each letter to the variable-size row containing its first + // position. Both rows and ranges are ascending → one forward walk. const ranges = letterRanges.value; // sorted by start let li = 0; for (let i = 0; i < items.length && li < ranges.length; i++) { diff --git a/frontend/src/v2/composables/useGalleryVirtualItems/packFlowRows.test.ts b/frontend/src/v2/composables/useGalleryVirtualItems/packFlowRows.test.ts index 7fbc46d72..55592e870 100644 --- a/frontend/src/v2/composables/useGalleryVirtualItems/packFlowRows.test.ts +++ b/frontend/src/v2/composables/useGalleryVirtualItems/packFlowRows.test.ts @@ -1,16 +1,14 @@ 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 +// Card width = H * ratio. H=100, GAP=12 → a square card is 100px wide. +const SQUARE = () => 1; 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. + // Row 340: 100+12+100+12+100=324 fits, a 4th overflows → 3 per row. const rows = packFlowRows(0, 7, 340, H, GAP, SQUARE); expect(rows).toEqual([ { start: 0, end: 3 }, @@ -20,14 +18,13 @@ describe("packFlowRows", () => { }); 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. + // Portrait 0.5 → 50px wide; all 5 fit in 340. 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. + // ratio 4 → 400px wide > row 340 → each alone. const rows = packFlowRows(0, 2, 340, H, GAP, () => 4); expect(rows).toEqual([ { start: 0, end: 1 }, @@ -36,8 +33,8 @@ describe("packFlowRows", () => { }); 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. + // widths 50,200,50,50: first 3 = 324 fit, 4th wraps. + const ratioAt = (p: number) => (p === 1 ? 2 : 0.5); const rows = packFlowRows(0, 4, 340, H, GAP, ratioAt); expect(rows).toEqual([ { start: 0, end: 3 }, diff --git a/frontend/src/v2/composables/useGalleryVirtualItems/types.ts b/frontend/src/v2/composables/useGalleryVirtualItems/types.ts index b9cda1ea7..8dddcca92 100644 --- a/frontend/src/v2/composables/useGalleryVirtualItems/types.ts +++ b/frontend/src/v2/composables/useGalleryVirtualItems/types.ts @@ -22,8 +22,7 @@ export type GalleryItem = | { kind: "row"; key: string; - /** A contiguous run of ROM positions, flow-packed to fill the row - * width. Variable length — there's no fixed column count. */ + /** A contiguous, variable-length run of positions (flow-packed). */ startPosition: number; endPosition: number; // exclusive /** Letters covered by this row's position range (from server's diff --git a/frontend/src/v2/composables/useResponsiveColumns/index.ts b/frontend/src/v2/composables/useResponsiveColumns/index.ts index e0123aa98..1e14a8beb 100644 --- a/frontend/src/v2/composables/useResponsiveColumns/index.ts +++ b/frontend/src/v2/composables/useResponsiveColumns/index.ts @@ -45,9 +45,8 @@ export function useResponsiveColumns( const min = options.min ?? 1; const columns = ref(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. + // Observed content width minus `inset` — px available to a row of cards, + // for consumers that flow-pack by width rather than a fixed column count. const usableWidth = ref(0); let observer: ResizeObserver | null = null; // Last observed width — kept so a change in a reactive option (card From d1a9dbb4fb5eb6348ee5581f99b41468d22d4065 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 21 Jun 2026 19:10:36 -0400 Subject: [PATCH 07/15] refactor(v2): extract gallery cover-ratio measurement into a composable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the measured-ratio map, debounced ratioVersion, onCardRatio handler, and ratioAt resolver out of GalleryShell into useGalleryCoverRatios. The composable owns its debounce-timer cleanup (onBeforeUnmount), so the shell no longer tracks ratioBumpTimer. Behaviour is unchanged; adds a unit test for the dedup / debounce / position→rom→ratio mapping. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../v2/components/Gallery/GalleryShell.vue | 29 ++----- .../useGalleryCoverRatios/index.test.ts | 77 +++++++++++++++++++ .../useGalleryCoverRatios/index.ts | 55 +++++++++++++ 3 files changed, 137 insertions(+), 24 deletions(-) create mode 100644 frontend/src/v2/composables/useGalleryCoverRatios/index.test.ts create mode 100644 frontend/src/v2/composables/useGalleryCoverRatios/index.ts diff --git a/frontend/src/v2/components/Gallery/GalleryShell.vue b/frontend/src/v2/components/Gallery/GalleryShell.vue index 235cf3d4c..49f61e33f 100644 --- a/frontend/src/v2/components/Gallery/GalleryShell.vue +++ b/frontend/src/v2/components/Gallery/GalleryShell.vue @@ -61,6 +61,7 @@ import { type ListSortKey } from "@/v2/components/Gallery/listColumns"; import { GameCard, GameCardSkeleton } from "@/v2/components/GameCard"; import { useBreakpoint } from "@/v2/composables/useBreakpoint"; import { coverRatio, isBoxartStyle } from "@/v2/composables/useCoverArt"; +import { useGalleryCoverRatios } from "@/v2/composables/useGalleryCoverRatios"; import { useGalleryFilterUrl } from "@/v2/composables/useGalleryFilterUrl"; import { useGalleryMode } from "@/v2/composables/useGalleryMode"; import { useGalleryViewModeUrl } from "@/v2/composables/useGalleryViewModeUrl"; @@ -262,29 +263,10 @@ const coverAspectRatio = computed(() => ), ); -// Measured natural cover ratios, keyed by rom id (the key survives gallery -// switches, so a re-visited platform re-packs without re-waiting on images). -// Updates batch behind `ratioVersion` → one re-pack per burst, not per cover. -// Intentionally unbounded: two numbers per rom, reset on page reload. -const ratioByRomId = new Map(); -const ratioVersion = ref(0); -let ratioBumpTimer: ReturnType | 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; -} +// Measured natural cover ratios feeding the flow-packer — GameCard reports +// each cover's ratio on load (`onCardRatio`), the packer reads `ratioAt`, +// and `ratioVersion` bumps (debounced) to trigger a single re-pack. +const { ratioVersion, ratioAt, onCardRatio } = useGalleryCoverRatios(); // 2D arrow / gamepad nav for both layouts of the gallery. Two passes: // * Grid mode — rows are `.r-v2-shell__row` (the per-virtualizer-item @@ -671,7 +653,6 @@ 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. diff --git a/frontend/src/v2/composables/useGalleryCoverRatios/index.test.ts b/frontend/src/v2/composables/useGalleryCoverRatios/index.test.ts new file mode 100644 index 000000000..a74de9c5d --- /dev/null +++ b/frontend/src/v2/composables/useGalleryCoverRatios/index.test.ts @@ -0,0 +1,77 @@ +import { mount } from "@vue/test-utils"; +import { createPinia, setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { defineComponent } from "vue"; +import storeGalleryRoms from "@/v2/stores/galleryRoms"; +import { useGalleryCoverRatios } from "./index"; + +// Run the composable inside a real component so its `onBeforeUnmount` +// cleanup has a host instance. Returns the composable's result. +function withComposable(fn: () => T): T { + let result!: T; + mount( + defineComponent({ + setup() { + result = fn(); + return () => null; + }, + }), + ); + return result; +} + +describe("useGalleryCoverRatios", () => { + beforeEach(() => { + setActivePinia(createPinia()); + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("ratioAt maps position → rom id → measured ratio, else 0", () => { + storeGalleryRoms().romIdIndex = [101, 102, 103]; + const { ratioAt, onCardRatio } = withComposable(() => + useGalleryCoverRatios(), + ); + + expect(ratioAt(1)).toBe(0); // not yet measured + expect(ratioAt(99)).toBe(0); // position outside the index + + onCardRatio({ romId: 102, ratio: 1.2 }); + expect(ratioAt(1)).toBeCloseTo(1.2); // position 1 → rom 102 + expect(ratioAt(0)).toBe(0); // rom 101 still unmeasured + }); + + it("bumps ratioVersion once per debounce burst", () => { + storeGalleryRoms().romIdIndex = [101, 102]; + const { ratioVersion, onCardRatio } = withComposable(() => + useGalleryCoverRatios(), + ); + + expect(ratioVersion.value).toBe(0); + onCardRatio({ romId: 101, ratio: 0.7 }); + onCardRatio({ romId: 102, ratio: 0.5 }); + expect(ratioVersion.value).toBe(0); // still debouncing + + vi.advanceTimersByTime(150); + expect(ratioVersion.value).toBe(1); // one bump for the whole burst + }); + + it("ignores sub-epsilon changes (no re-store, no extra re-pack)", () => { + storeGalleryRoms().romIdIndex = [101]; + const { ratioVersion, ratioAt, onCardRatio } = withComposable(() => + useGalleryCoverRatios(), + ); + + onCardRatio({ romId: 101, ratio: 0.7 }); + vi.advanceTimersByTime(150); + expect(ratioVersion.value).toBe(1); + expect(ratioAt(0)).toBeCloseTo(0.7); + + onCardRatio({ romId: 101, ratio: 0.705 }); // delta 0.005 < 0.01 + vi.advanceTimersByTime(150); + expect(ratioVersion.value).toBe(1); // no new bump + expect(ratioAt(0)).toBeCloseTo(0.7); // value unchanged + }); +}); diff --git a/frontend/src/v2/composables/useGalleryCoverRatios/index.ts b/frontend/src/v2/composables/useGalleryCoverRatios/index.ts new file mode 100644 index 000000000..92af551c1 --- /dev/null +++ b/frontend/src/v2/composables/useGalleryCoverRatios/index.ts @@ -0,0 +1,55 @@ +// useGalleryCoverRatios — the gallery's measured natural cover ratios. +// +// Covers render at their image's natural aspect, so the flow-packer needs +// each card's ratio (width = cardHeight * ratio) to decide where rows break. +// The API doesn't ship cover dimensions, so the only source is the image +// itself: GameCard reports `@ratio` once its cover loads, and this collects +// the values for the packer to read via `ratioAt(position)`. +// +// Keyed by rom id (not position), so the key survives a gallery context +// switch — a re-visited platform re-packs without re-waiting on images. +// Intentionally unbounded: two numbers per rom, reset on page reload. +// +// Updates batch behind `ratioVersion`: a burst of image loads bumps it once +// (after a short debounce) so the packed layout recomputes a single time +// instead of once per cover. +import { onBeforeUnmount, ref } from "vue"; +import storeGalleryRoms from "@/v2/stores/galleryRoms"; + +// Below this delta a new measurement isn't worth a re-pack (sub-pixel noise). +const RATIO_EPSILON = 0.01; +// Wait this long after the last new ratio before bumping `ratioVersion`. +const REPACK_DEBOUNCE_MS = 150; + +export function useGalleryCoverRatios() { + const galleryRoms = storeGalleryRoms(); + const ratioByRomId = new Map(); + const ratioVersion = ref(0); + let bumpTimer: ReturnType | null = null; + + /** Record a cover's measured ratio (GameCard's `@ratio` handler). */ + function onCardRatio(payload: { romId: number; ratio: number }) { + const prev = ratioByRomId.get(payload.romId); + if (prev != null && Math.abs(prev - payload.ratio) < RATIO_EPSILON) return; + ratioByRomId.set(payload.romId, payload.ratio); + if (bumpTimer) return; + bumpTimer = setTimeout(() => { + bumpTimer = null; + ratioVersion.value++; + }, REPACK_DEBOUNCE_MS); + } + + /** Measured ratio for a position, or 0 when unknown (the packer then + * falls back to its default ratio). */ + function ratioAt(position: number): number { + const romId = galleryRoms.romIdIndex[position]; + if (romId == null) return 0; + return ratioByRomId.get(romId) ?? 0; + } + + onBeforeUnmount(() => { + if (bumpTimer) clearTimeout(bumpTimer); + }); + + return { ratioVersion, ratioAt, onCardRatio }; +} From 74ef5946098cf2f505df5926725e5807b7728533 Mon Sep 17 00:00:00 2001 From: Georges-Antoine Assi Date: Sun, 21 Jun 2026 19:24:05 -0400 Subject: [PATCH 08/15] feat(v2): render the Random Pick cover at its native aspect ratio Swap the hand-rolled RImg + useCoverArt cover resolution in RandomPickWidget for the shared GameCover, which measures the image's natural ratio. The thumbnail now renders at a fixed 70px height with natural width (no crop), matching the gallery, and the widget's cover plumbing (coverSrc / coverContain / placeholder fallback) collapses into GameCover. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Home/Widgets/RandomPickWidget.vue | 44 +++++++------------ 1 file changed, 15 insertions(+), 29 deletions(-) diff --git a/frontend/src/v2/components/Home/Widgets/RandomPickWidget.vue b/frontend/src/v2/components/Home/Widgets/RandomPickWidget.vue index f26ca52c9..c56705dbc 100644 --- a/frontend/src/v2/components/Home/Widgets/RandomPickWidget.vue +++ b/frontend/src/v2/components/Home/Widgets/RandomPickWidget.vue @@ -5,15 +5,14 @@ // calls per pick: one to learn the library total, one to fetch the // selected offset; same approach the v1 RandomBtn uses. The pick is // intentionally not cached so each mount re-shuffles. -import { RBtn, RImg } from "@v2/lib"; -import { computed, onMounted, ref } from "vue"; +import { RBtn } from "@v2/lib"; +import { onMounted, ref } from "vue"; import { useI18n } from "vue-i18n"; import { useRouter } from "vue-router"; import { ROUTES } from "@/plugins/router"; import romApi from "@/services/api/rom"; import type { SimpleRom } from "@/stores/roms"; -import { useCoverArt } from "@/v2/composables/useCoverArt"; -import { coverPlaceholderArt } from "@/v2/utils/covers"; +import GameCover from "@/v2/components/shared/GameCover.vue"; import WidgetCard from "./WidgetCard.vue"; defineOptions({ inheritAttrs: false }); @@ -24,21 +23,6 @@ const router = useRouter(); const pick = ref(null); const loading = ref(false); -// Honour the chosen boxart style here too — the picked cover matches the -// gallery. `useCoverArt` resolves the styled art + webp; we fall back to -// the generated placeholder when the rom has no cover at all. -const art = useCoverArt(() => pick.value); -const coverSrc = computed(() => { - const r = pick.value; - if (!r) return ""; - return ( - art.coverUrl.value ?? - art.fallbackUrl.value ?? - coverPlaceholderArt(r.name || r.fs_name, r.is_identified) - ); -}); -const coverContain = computed(() => art.objectFit.value === "contain"); - async function reroll() { loading.value = true; try { @@ -73,13 +57,10 @@ onMounted(reroll);