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.
This commit is contained in:
2026-10-14 20:35:50 -05:00
parent 7d1184a762
commit 5888e07586
2 changed files with 88 additions and 38 deletions

View File

@@ -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<HTMLDivElement, { card: PreviewCard }>(function CardInfoTile({ card }, ref) {
return (
<div className="preview-info">
<div className="preview-info" ref={ref}>
<div className="preview-info-name">{card.name}</div>
<div className="preview-info-type">{card.type_line}</div>
<div className="preview-info-row">
@@ -895,10 +901,15 @@ function CardInfoTile({ card }: { card: PreviewCard }) {
</div>
</div>
);
}
});
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<HTMLDivElement>(null);
const modalRef = useRef<HTMLDivElement>(null);
const infoRef = useRef<HTMLDivElement>(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<PreviewPane>("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({
<div className="preview-modal-backdrop" ref={backdropRef} onClick={onClose}>
<div
ref={modalRef}
style={compactStyle}
className={[
"preview-modal",
hasBack ? "preview-modal-dfc" : "",
@@ -990,12 +1029,6 @@ function ClickPreviewModal({
.join(" ")}
onClick={(e) => e.stopPropagation()}
>
{compact && (
<div className="preview-modal-bar">
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</div>
)}
<div className="preview-modal-faces">
{/* 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({
</button>
)}
</div>
<CardInfoTile card={card} />
<CardInfoTile ref={infoRef} card={card} />
{compact && (
<div className="preview-modal-bar">
<PreviewPaneToggle hasBack={hasBack} showBack={showBack} pane={pane} onSelect={selectPane} />
<IconButton icon={Icons.close} label="Close (Esc)" onClick={onClose} overlay />
</div>
)}
</div>
</div>
);

View File

@@ -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;
}