From 5888e075860ace2213a46cdea3acbf35d1d1cf59 Mon Sep 17 00:00:00 2001 From: Garrett Mills Date: Wed, 14 Oct 2026 20:35:50 -0500 Subject: [PATCH] Click preview compact mode: toolbar below, fixed size, Battles fit too - Toolbar (pane toggle + close) moves below the pane for thumb reach. - The compact modal gets a fixed box sized to its largest pane, with each pane anchored just above the toolbar, so switching to the (shorter) info tile no longer moves the buttons. A too-tall info tile scrolls within itself. - Horizontal (Battle) art now uses the same post-toolbar height budget as portrait art, computed once in JS from the measured available height. - Fix useRotatedPreviewBox's width/height bounds being swapped, which let rotated art overflow its width cap (e.g. ~469px wide on a 375px phone). - Only apply the narrow side-by-side DFC budget at the >=860px breakpoint where the faces actually sit side by side; below it a DFC Battle's front was rendered at ~43vw. --- src/components.tsx | 89 +++++++++++++++++++++++++++++++++------------- src/styles.css | 37 ++++++++++++------- 2 files changed, 88 insertions(+), 38 deletions(-) diff --git a/src/components.tsx b/src/components.tsx index 4e4a72f..909fb10 100644 --- a/src/components.tsx +++ b/src/components.tsx @@ -812,17 +812,23 @@ const ROTATED_ASPECT = 680 / 488; * horizontal-card preview. Recomputed on resize so it stays live like the * CSS-only sizing used for normal (portrait) cards. * - * `compact` shrinks the budget to roughly match a DFC back face's own - * `min(430px, 43vw)` box (see the `.preview-modal-dfc .preview-modal-img` + * `dfc` (when wide enough for side-by-side faces) shrinks the budget to + * roughly match a DFC back face's own `min(430px, 43vw)` box (see the `.preview-modal-dfc .preview-modal-img` * rule) and leaves headroom below for the info tile — pass it whenever the * horizontal front face has a sibling back face, so a transformed Battle * doesn't render at full single-card size next to a much smaller back face - * and push the info tile off-screen. */ -function useRotatedPreviewBox(compact: boolean) { + * and push the info tile off-screen. + * + * `fitH` (compact mode, see `ClickPreviewModal`) overrides both with the + * exact art height budget left over after the pane toolbar. */ +function useRotatedPreviewBox(dfc: boolean, fitH: number | null) { const compute = () => { - const boundW = compact ? Math.min(430, window.innerWidth * 0.43) : Math.min(720, window.innerWidth * 0.9); - const boundH = compact ? window.innerHeight * 0.55 : window.innerHeight * 0.9; - const w = Math.min(boundH, boundW * ROTATED_ASPECT); + // Faces only sit side by side at the `.preview-modal-dfc` ≥860px + // breakpoint; below it they flip one at a time, so use the full budget. + const sideBySide = dfc && fitH === null && window.innerWidth >= 860; + const boundW = sideBySide ? Math.min(430, window.innerWidth * 0.43) : Math.min(720, window.innerWidth * 0.9); + const boundH = fitH ?? (sideBySide ? window.innerHeight * 0.55 : window.innerHeight * 0.9); + const w = Math.min(boundW, boundH * ROTATED_ASPECT); return { w, h: w / ROTATED_ASPECT }; }; const [box, setBox] = useState(compute); @@ -831,7 +837,7 @@ function useRotatedPreviewBox(compact: boolean) { const onResize = () => setBox(compute()); window.addEventListener("resize", onResize); return () => window.removeEventListener("resize", onResize); - }, [compact]); + }, [dfc, fitH]); return box; } @@ -857,9 +863,9 @@ function LegalityPills({ legalities }: { legalities: number }) { * type/mana/color, price, ownership context (when the caller had a * `LibraryCard`/`DeckEntry` to hand — see `PreviewCard`), and per-format * legality. */ -function CardInfoTile({ card }: { card: PreviewCard }) { +const CardInfoTile = forwardRef(function CardInfoTile({ card }, ref) { return ( -
+
{card.name}
{card.type_line}
@@ -895,10 +901,15 @@ function CardInfoTile({ card }: { card: PreviewCard }) {
); -} +}); type PreviewPane = "card" | "info"; +// Compact-mode toolbar height and the modal's flex gap — keep in sync with +// `.preview-modal-bar` / `.preview-modal` in styles.css. +const PREVIEW_BAR_H = 36; +const PREVIEW_GAP = 16; + /** Segmented control for the compact (one-pane-at-a-time) click preview: * front face / back face (double-faced cards only) / info tile. */ function PreviewPaneToggle({ @@ -937,23 +948,26 @@ function ClickPreviewModal({ }) { const { card, showBack } = state; const hasBack = !!card.back_image_uri; - const rotatedBox = useRotatedPreviewBox(hasBack); const backdropRef = useRef(null); const modalRef = useRef(null); + const infoRef = useRef(null); // Compact mode: when the full layout (art + info tile) would be taller than // the viewport — i.e. on phones, where the tile wraps below the art — show - // one pane at a time behind a Front/Back/Info toggle instead of making the + // one pane at a time above a Front/Back/Info toggle instead of making the // user scroll. Decided by measuring the full layout itself: every resize // drops back to it, and the layout effect below re-measures and switches // before paint, so there's no flash and no feedback loop (compact mode is // never what gets measured). The art's size doesn't depend on the image // having loaded (see `.preview-modal-img`), so measuring on mount is exact. - const [compact, setCompact] = useState(false); + // `fit` holds the measurements compact mode sizes itself from; null = full. + const [fit, setFit] = useState<{ avail: number; infoH: number } | null>(null); const [pane, setPane] = useState("card"); const [measureKey, setMeasureKey] = useState(0); + const artBudget = fit && fit.avail - PREVIEW_BAR_H - PREVIEW_GAP; + const rotatedBox = useRotatedPreviewBox(hasBack, artBudget); useEffect(() => { const onResize = () => { - setCompact(false); + setFit(null); setMeasureKey((k) => k + 1); }; window.addEventListener("resize", onResize); @@ -962,11 +976,35 @@ function ClickPreviewModal({ useLayoutEffect(() => { const backdrop = backdropRef.current; const modal = modalRef.current; - if (compact || !backdrop || !modal) return; + if (fit || !backdrop || !modal) return; const cs = getComputedStyle(backdrop); const avail = backdrop.clientHeight - parseFloat(cs.paddingTop) - parseFloat(cs.paddingBottom); - if (modal.getBoundingClientRect().height > avail) setCompact(true); - }, [compact, measureKey, card, showBack]); + if (modal.getBoundingClientRect().height > avail) { + setFit({ avail, infoH: infoRef.current?.getBoundingClientRect().height ?? 0 }); + } + }, [fit, measureKey, card, showBack]); + + // In compact mode the modal gets a fixed box — as wide and tall as the + // largest pane (capped to the screen), toolbar pinned to its bottom — so + // switching panes never moves the toolbar. Mirrors the CSS sizing of the + // art (`.preview-modal-img`) and info tile (`.preview-info`). + let compactStyle: React.CSSProperties | undefined; + if (fit && artBudget !== null) { + const vw = window.innerWidth; + const portraitW = Math.min(488, vw * 0.9, (artBudget * 488) / 680); + const faces = [ + card.horizontal ? rotatedBox : { w: portraitW, h: (portraitW * 680) / 488 }, + ...(hasBack ? [{ w: portraitW, h: (portraitW * 680) / 488 }] : []), + ]; + const paneW = Math.max(Math.min(320, vw * 0.9), ...faces.map((f) => f.w)); + const paneH = Math.min(artBudget, Math.max(fit.infoH, ...faces.map((f) => f.h))); + compactStyle = { + width: paneW, + height: paneH + PREVIEW_GAP + PREVIEW_BAR_H, + ["--preview-img-h" as string]: `${artBudget}px`, + }; + } + const compact = !!fit; const selectPane = (next: PreviewPane, back: boolean) => { setPane(next); @@ -980,6 +1018,7 @@ function ClickPreviewModal({
e.stopPropagation()} > - {compact && ( -
- - -
- )}
{/* Only the front face can be horizontal (Battles) — a transformed Battle's back face is always a normal, correctly-oriented @@ -1035,7 +1068,13 @@ function ClickPreviewModal({ )}
- + + {compact && ( +
+ + +
+ )}
); diff --git a/src/styles.css b/src/styles.css index 7da7c7d..f7f3fc6 100644 --- a/src/styles.css +++ b/src/styles.css @@ -2012,7 +2012,7 @@ tbody tr:hover { flex-wrap: wrap; align-items: flex-start; justify-content: center; - gap: 16px; + gap: 16px; /* mirrored by PREVIEW_GAP in components.tsx */ max-width: 96vw; } .preview-modal-faces { @@ -2085,30 +2085,41 @@ tbody tr:hover { } } /* Compact mode (see ClickPreviewModal): the full layout would be taller than - the viewport, so a toolbar (pane toggle + close) shows one pane at a time — - a single face, or the info tile — with the art shrunk to fit the height - left over after the backdrop padding, toolbar and gap. */ + the viewport, so it shows one pane at a time — a single face, or the info + tile — above a toolbar (pane toggle + close) that sits at the bottom for + thumb reach. The modal's width/height and `--preview-img-h` are set inline + from JS: a fixed box sized to the largest pane, so the toolbar stays put + when switching panes. Each pane is pushed down onto the toolbar + (`margin-top: auto`) and centered horizontally; an info tile taller than + the art budget scrolls within itself. */ .preview-modal-compact { - /* A column (not the base wrapping row) so the toolbar stretches to exactly - the visible pane's width instead of the row's full 96vw. */ flex-direction: column; flex-wrap: nowrap; align-items: stretch; - --preview-img-h: calc( - 100vh - 48px - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 36px - 16px - ); } +.preview-modal-compact .preview-modal-faces, +.preview-modal-compact .preview-info { + margin-top: auto; + align-self: center; +} +.preview-modal-compact .preview-modal-faces { + display: block; + flex-shrink: 0; +} +.preview-modal-compact .preview-info { + flex: 0 1 auto; + min-height: 0; + overflow-y: auto; +} +/* Height mirrored by PREVIEW_BAR_H in components.tsx. */ .preview-modal-bar { - flex-basis: 100%; + flex-shrink: 0; height: 36px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } -.preview-modal-compact .preview-modal-faces { - display: block; -} .preview-modal-compact .preview-modal-img:not(.active) { display: none; }