From d9675e7e3592c1d14d71c471a760616d65889511 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 27 Aug 2026 07:31:34 -0700 Subject: [PATCH 1/4] fix(client): stabilize card hover previews --- .impeccable/config.json | 17 ++++++++++++ client/src/components/card/CardPreview.tsx | 24 +++++++---------- .../src/components/card/HoverCardPreview.tsx | 26 ++++++++++++++++++- .../card/__tests__/HoverCardPreview.test.tsx | 17 ++++++++++++ .../components/deck-builder/hoverPreview.ts | 1 + client/src/pages/DeckBuilderPage.tsx | 1 + 6 files changed, 70 insertions(+), 16 deletions(-) create mode 100644 .impeccable/config.json diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 0000000000..22cf8bdf76 --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,17 @@ +{ + "detector": { + "ignoreRules": [], + "ignoreFiles": [], + "ignoreValues": [ + { + "rule": "broken-image", + "value": "*", + "files": [ + "client/src/components/card/CardPreview.tsx" + ], + "createdAt": "2026-08-27T14:11:01.471Z", + "reason": "Agent: src is rendered only in the !src false branch; static detector cannot infer JSX control flow." + } + ] + } +} diff --git a/client/src/components/card/CardPreview.tsx b/client/src/components/card/CardPreview.tsx index a50710287b..61734688b6 100644 --- a/client/src/components/card/CardPreview.tsx +++ b/client/src/components/card/CardPreview.tsx @@ -455,6 +455,8 @@ function CardPreviewInner({ advanceFailedSource: activeImage.advanceFailedSource, }; const activeRotated = activeArt.kind === "face" && activeArt.isRotated; + const activeImageSrc = activeArt.kind === "face" ? activeArt.src : null; + const activeImageIsLoading = activeArt.kind === "face" && activeArt.isLoading; const displayName = showOtherFace ? backFaceName! : cardName; const showInfoPanel = obj?.zone === "Battlefield"; const handPreview = handOrigin != null && !position && !dockSide; @@ -549,7 +551,9 @@ function CardPreviewInner({ // The preview grows when async content settles (image load, hint bars, face // swap); re-clamp on size change so a late-appearing hint bar can't leave the - // card hanging off the bottom. + // card hanging off the bottom. Source state is also a dependency below: + // changing an image's intrinsic content does not reliably notify + // ResizeObserver on every browser. const resizeObserver = previewRef.current != null ? new ResizeObserver(() => schedulePositionUpdate()) @@ -565,6 +569,8 @@ function CardPreviewInner({ } }; }, [ + activeImageIsLoading, + activeImageSrc, altHeld, dockSide, gap, @@ -1072,7 +1078,7 @@ function CardImagePreview({ : compactDesktop ? "absolute left-1/2 top-1/2 h-[clamp(266px,25.2vw,420px)] w-[clamp(190px,18vw,300px)] max-h-[66vh] max-w-[36vw] -translate-x-1/2 -translate-y-1/2 rotate-90 object-cover" : "absolute left-1/2 top-1/2 h-[clamp(308px,36.4vw,661px)] w-[clamp(220px,26vw,472px)] max-h-[80vh] max-w-[42vw] -translate-x-1/2 -translate-y-1/2 rotate-90 object-cover" - : `${frameClass} object-cover transition-transform duration-200${flip180 ? " rotate-180" : ""}`; + : `h-full w-full object-cover transition-transform duration-200${flip180 ? " rotate-180" : ""}`; // Use effective spell cost from engine if available (reflects alt costs, reductions), // otherwise fall back to printed mana cost. When the user holds Ctrl to view the @@ -1118,21 +1124,9 @@ function CardImagePreview({ const displayCost = showOtherFace ? otherFaceCost : (castCostDisplay?.displayCost ?? null); const displayCostReduced = castCostDisplay?.isReduced ?? false; - // Only a genuinely in-flight lookup pulses. A finished lookup with no art - // (issue #6156) falls through to the named placeholder below — previously it - // was collapsed in here, which left this component's own placeholder dead - // code for artless tokens and pulsed forever in the hover preview. - if (isLoading) { - return ( -
- ); - } - return (
-
+
{art.kind === "back" ? ( ) : isLoading ? ( diff --git a/client/src/components/card/HoverCardPreview.tsx b/client/src/components/card/HoverCardPreview.tsx index 5dbcdb59b7..baff846324 100644 --- a/client/src/components/card/HoverCardPreview.tsx +++ b/client/src/components/card/HoverCardPreview.tsx @@ -9,6 +9,9 @@ interface HoverCardPreviewProps { card: CardHoverInfo | null; onDismiss?: () => void; mobileLayout?: "modal" | "compact"; + /** Keep this surface's desktop preview at the side, independent of the + * global game-board hover preference. */ + forceDockSide?: boolean; } /** @@ -19,6 +22,7 @@ export function HoverCardPreview({ card, onDismiss, mobileLayout, + forceDockSide = false, }: HoverCardPreviewProps) { const cardPreviewMode = usePreferencesStore((s) => s.cardPreviewMode); const cardPreviewHoverDelayMs = usePreferencesStore((s) => s.cardPreviewHoverDelayMs); @@ -50,12 +54,32 @@ export function HoverCardPreview({ const previewCard = cardPreviewMode === "shift" && !shiftHeld ? null : visibleCard; + useEffect(() => { + if (visibleCard == null || onDismiss == null || typeof window === "undefined") { + return undefined; + } + + // Grid/list rows can be replaced while the pointer is over them, so React + // never receives their pointerleave. Clear the deck-builder-owned state on + // the next mouse move outside every registered hover source. + const handlePointerMove = (event: PointerEvent) => { + if ( + event.pointerType === "mouse" + && document.querySelector("[data-deck-card-hover]:hover") == null + ) { + onDismiss(); + } + }; + window.addEventListener("pointermove", handlePointerMove); + return () => window.removeEventListener("pointermove", handlePointerMove); + }, [onDismiss, visibleCard]); + return ( diff --git a/client/src/components/card/__tests__/HoverCardPreview.test.tsx b/client/src/components/card/__tests__/HoverCardPreview.test.tsx index dd22a0608a..06bdda1006 100644 --- a/client/src/components/card/__tests__/HoverCardPreview.test.tsx +++ b/client/src/components/card/__tests__/HoverCardPreview.test.tsx @@ -38,6 +38,23 @@ describe("HoverCardPreview", () => { expect(screen.getByTestId("preview")).toHaveAttribute("data-dock-side", "true"); }); + it("can keep a workspace preview docked without changing the game-board preference", () => { + render(); + + expect(screen.getByTestId("preview")).toHaveAttribute("data-dock-side", "true"); + }); + + it("dismisses a deck-owned preview when its hover source is removed", () => { + const onDismiss = vi.fn(); + render(); + const querySelector = vi.spyOn(document, "querySelector").mockReturnValue(null); + + fireEvent.pointerMove(window, { pointerType: "mouse" }); + + expect(onDismiss).toHaveBeenCalledOnce(); + querySelector.mockRestore(); + }); + it("shows a hovered card only while Shift is held in shift mode", () => { usePreferencesStore.setState({ cardPreviewMode: "shift" }); render(); diff --git a/client/src/components/deck-builder/hoverPreview.ts b/client/src/components/deck-builder/hoverPreview.ts index fdc528500c..479ce2bc2e 100644 --- a/client/src/components/deck-builder/hoverPreview.ts +++ b/client/src/components/deck-builder/hoverPreview.ts @@ -28,6 +28,7 @@ export function mouseHoverPreview( card: CardHoverInfo, ) { return { + "data-deck-card-hover": "", onPointerEnter: (e: PointerEvent) => { if (e.pointerType === "mouse") onCardHover?.(card); }, diff --git a/client/src/pages/DeckBuilderPage.tsx b/client/src/pages/DeckBuilderPage.tsx index cf36058f8c..d7fddc2a63 100644 --- a/client/src/pages/DeckBuilderPage.tsx +++ b/client/src/pages/DeckBuilderPage.tsx @@ -135,6 +135,7 @@ export function DeckBuilderPage() { card={hoveredCard} onDismiss={useCallback(() => setHoveredCard(null), [])} mobileLayout="compact" + forceDockSide />
); From 688631cd4ae907657750846f33180338ceb62156 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 27 Aug 2026 07:35:06 -0700 Subject: [PATCH 2/4] chore(ui): scope hover detector exceptions --- .impeccable/config.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.impeccable/config.json b/.impeccable/config.json index 22cf8bdf76..f919912168 100644 --- a/.impeccable/config.json +++ b/.impeccable/config.json @@ -11,6 +11,15 @@ ], "createdAt": "2026-08-27T14:11:01.471Z", "reason": "Agent: src is rendered only in the !src false branch; static detector cannot infer JSX control flow." + }, + { + "rule": "gray-on-color", + "value": "*", + "files": [ + "client/src/components/deck-builder/DeckStack.tsx" + ], + "createdAt": "2026-08-27T14:34:09.327Z", + "reason": "Agent: the disabled control is slate text on black; the flagged emerald color is hover-only and cannot coexist with disabled." } ] } From 9144c3c8bda3b664452646fd8da3e4140b1ed8a1 Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 27 Aug 2026 07:49:00 -0700 Subject: [PATCH 3/4] fix(client): keep deck hover previews usable --- client/src/components/card/CardPreview.tsx | 22 +++++++++++--- .../src/components/card/HoverCardPreview.tsx | 17 ++++++++--- .../card/__tests__/HoverCardPreview.test.tsx | 29 +++++++++++++++++-- client/src/pages/DeckBuilderPage.tsx | 1 + 4 files changed, 58 insertions(+), 11 deletions(-) diff --git a/client/src/components/card/CardPreview.tsx b/client/src/components/card/CardPreview.tsx index 61734688b6..3d49d883cc 100644 --- a/client/src/components/card/CardPreview.tsx +++ b/client/src/components/card/CardPreview.tsx @@ -88,6 +88,8 @@ export interface CardHoverInfo { sourcePrinting?: SourcePrinting; } +export type CardPreviewDockPosition = "top-right" | "middle-right"; + interface CardPreviewProps { cardName: string | null; /** In-game object whose details and art metadata belong to this preview. @@ -104,6 +106,8 @@ interface CardPreviewProps { * covering the board. Drives the "side" card-preview preference. Ignored * when an explicit `position` is given or on mobile. */ dockSide?: boolean; + /** Vertical placement for a side-docked desktop preview. */ + dockPosition?: CardPreviewDockPosition; /** Overrides the mobile-overlay dismiss handler. Contexts that drive the * preview via their own state (e.g. the deck builder's hoveredCard) pass * this so a tap-to-dismiss clears THAT state; defaults to the in-game @@ -138,6 +142,7 @@ export function CardPreview({ scryfallId, sourcePrinting, dockSide, + dockPosition, onDismiss, mobileLayout = "modal", handSourceObjectId, @@ -258,6 +263,7 @@ export function CardPreview({ scryfallId={scryfallId} sourcePrinting={sourcePrinting} dockSide={dockSide} + dockPosition={dockPosition} onDismiss={onDismiss} mobileLayout={mobileLayout} handOrigin={handOrigin} @@ -277,6 +283,7 @@ function CardPreviewInner({ scryfallId, sourcePrinting, dockSide, + dockPosition, onDismiss, mobileLayout, handOrigin, @@ -290,6 +297,7 @@ function CardPreviewInner({ scryfallId?: string; sourcePrinting?: SourcePrinting; dockSide?: boolean; + dockPosition?: CardPreviewDockPosition; onDismiss?: () => void; mobileLayout?: "modal" | "compact"; handOrigin: HandPreviewOrigin | null; @@ -479,10 +487,16 @@ function CardPreviewInner({ const viewportHeight = typeof window === "undefined" ? 900 : window.innerHeight; const gap = 20; const margin = 16; - const defaultDesktopStyle: React.CSSProperties = { - right: "calc(env(safe-area-inset-right) + 1rem + var(--game-right-rail-offset, 0px))", - top: "calc(env(safe-area-inset-top) + var(--game-top-overlay-offset, 0px) + 1rem)", - }; + const defaultDesktopStyle: React.CSSProperties = + dockSide && dockPosition === "middle-right" + ? { + right: "calc(env(safe-area-inset-right) + 1rem + var(--game-right-rail-offset, 0px))", + top: `calc(50% - ${previewHeight / 2}px)`, + } + : { + right: "calc(env(safe-area-inset-right) + 1rem + var(--game-right-rail-offset, 0px))", + top: "calc(env(safe-area-inset-top) + var(--game-top-overlay-offset, 0px) + 1rem)", + }; useEffect(() => { // `dockSide` keeps the preview pinned to `defaultDesktopStyle` (the diff --git a/client/src/components/card/HoverCardPreview.tsx b/client/src/components/card/HoverCardPreview.tsx index baff846324..64d062c1a2 100644 --- a/client/src/components/card/HoverCardPreview.tsx +++ b/client/src/components/card/HoverCardPreview.tsx @@ -3,7 +3,11 @@ import { useEffect, useState } from "react"; import { useShiftHeld } from "../../hooks/useShiftHeld.ts"; import { usePreferencesStore } from "../../stores/preferencesStore.ts"; import { useUiStore } from "../../stores/uiStore.ts"; -import { CardPreview, type CardHoverInfo } from "./CardPreview.tsx"; +import { + CardPreview, + type CardHoverInfo, + type CardPreviewDockPosition, +} from "./CardPreview.tsx"; interface HoverCardPreviewProps { card: CardHoverInfo | null; @@ -12,6 +16,7 @@ interface HoverCardPreviewProps { /** Keep this surface's desktop preview at the side, independent of the * global game-board hover preference. */ forceDockSide?: boolean; + dockPosition?: CardPreviewDockPosition; } /** @@ -23,6 +28,7 @@ export function HoverCardPreview({ onDismiss, mobileLayout, forceDockSide = false, + dockPosition, }: HoverCardPreviewProps) { const cardPreviewMode = usePreferencesStore((s) => s.cardPreviewMode); const cardPreviewHoverDelayMs = usePreferencesStore((s) => s.cardPreviewHoverDelayMs); @@ -63,10 +69,12 @@ export function HoverCardPreview({ // never receives their pointerleave. Clear the deck-builder-owned state on // the next mouse move outside every registered hover source. const handlePointerMove = (event: PointerEvent) => { + if (event.pointerType !== "mouse") return; if ( - event.pointerType === "mouse" - && document.querySelector("[data-deck-card-hover]:hover") == null - ) { + event.target instanceof Element + && event.target.closest("[data-card-preview]") != null + ) return; + if (document.querySelector("[data-deck-card-hover]:hover") == null) { onDismiss(); } }; @@ -80,6 +88,7 @@ export function HoverCardPreview({ scryfallId={previewCard?.scryfallId} sourcePrinting={previewCard?.sourcePrinting} dockSide={forceDockSide || cardPreviewMode === "side"} + dockPosition={dockPosition} onDismiss={onDismiss} mobileLayout={mobileLayout} /> diff --git a/client/src/components/card/__tests__/HoverCardPreview.test.tsx b/client/src/components/card/__tests__/HoverCardPreview.test.tsx index 06bdda1006..c3b89e87b2 100644 --- a/client/src/components/card/__tests__/HoverCardPreview.test.tsx +++ b/client/src/components/card/__tests__/HoverCardPreview.test.tsx @@ -6,8 +6,21 @@ import { useUiStore } from "../../../stores/uiStore.ts"; import { HoverCardPreview } from "../HoverCardPreview.tsx"; vi.mock("../CardPreview.tsx", () => ({ - CardPreview: ({ cardName, dockSide }: { cardName: string | null; dockSide?: boolean }) => ( -
+ CardPreview: ({ + cardName, + dockSide, + dockPosition, + }: { + cardName: string | null; + dockSide?: boolean; + dockPosition?: string; + }) => ( +
{cardName}
), @@ -39,9 +52,10 @@ describe("HoverCardPreview", () => { }); it("can keep a workspace preview docked without changing the game-board preference", () => { - render(); + render(); expect(screen.getByTestId("preview")).toHaveAttribute("data-dock-side", "true"); + expect(screen.getByTestId("preview")).toHaveAttribute("data-dock-position", "middle-right"); }); it("dismisses a deck-owned preview when its hover source is removed", () => { @@ -55,6 +69,15 @@ describe("HoverCardPreview", () => { querySelector.mockRestore(); }); + it("keeps a deck preview open while the pointer is over its interactive panel", () => { + const onDismiss = vi.fn(); + render(); + + fireEvent.pointerMove(screen.getByTestId("preview"), { pointerType: "mouse" }); + + expect(onDismiss).not.toHaveBeenCalled(); + }); + it("shows a hovered card only while Shift is held in shift mode", () => { usePreferencesStore.setState({ cardPreviewMode: "shift" }); render(); diff --git a/client/src/pages/DeckBuilderPage.tsx b/client/src/pages/DeckBuilderPage.tsx index d7fddc2a63..9c0ae0ebd2 100644 --- a/client/src/pages/DeckBuilderPage.tsx +++ b/client/src/pages/DeckBuilderPage.tsx @@ -136,6 +136,7 @@ export function DeckBuilderPage() { onDismiss={useCallback(() => setHoveredCard(null), [])} mobileLayout="compact" forceDockSide + dockPosition="middle-right" />
); From 6b125628b5eb112114eb9f5e0dd685840e936dbc Mon Sep 17 00:00:00 2001 From: matthewevans Date: Thu, 27 Aug 2026 07:56:03 -0700 Subject: [PATCH 4/4] test(client): assert card preview frame sizing --- client/src/components/card/__tests__/CardPreview.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/src/components/card/__tests__/CardPreview.test.tsx b/client/src/components/card/__tests__/CardPreview.test.tsx index ae5f5d43f6..37cd53f019 100644 --- a/client/src/components/card/__tests__/CardPreview.test.tsx +++ b/client/src/components/card/__tests__/CardPreview.test.tsx @@ -111,7 +111,7 @@ describe("CardPreview chosen attributes", () => { expect(preview).not.toBeNull(); expect(preview?.style.bottom).toBe("0px"); expect(preview?.style.transformOrigin).toBe("50% 100%"); - expect(screen.getByAltText("Pithing Needle")).toHaveClass( + expect(screen.getByAltText("Pithing Needle").parentElement!).toHaveClass( "w-[clamp(190px,18vw,300px)]", ); source.remove(); @@ -148,7 +148,7 @@ describe("CardPreview chosen attributes", () => { expect(preview).not.toBeNull(); expect(preview?.style.bottom).toBe("0px"); expect(preview).toHaveClass("pointer-events-none"); - expect(screen.getByAltText("Pithing Needle")).toHaveClass( + expect(screen.getByAltText("Pithing Needle").parentElement!).toHaveClass( "w-[clamp(190px,18vw,300px)]", ); source.remove();