diff --git a/.impeccable/config.json b/.impeccable/config.json new file mode 100644 index 0000000000..f919912168 --- /dev/null +++ b/.impeccable/config.json @@ -0,0 +1,26 @@ +{ + "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." + }, + { + "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." + } + ] + } +} diff --git a/client/src/components/card/CardPreview.tsx b/client/src/components/card/CardPreview.tsx index a50710287b..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; @@ -455,6 +463,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; @@ -477,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 @@ -549,7 +565,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 +583,8 @@ function CardPreviewInner({ } }; }, [ + activeImageIsLoading, + activeImageSrc, altHeld, dockSide, gap, @@ -1072,7 +1092,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 +1138,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..64d062c1a2 100644 --- a/client/src/components/card/HoverCardPreview.tsx +++ b/client/src/components/card/HoverCardPreview.tsx @@ -3,12 +3,20 @@ 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; onDismiss?: () => void; mobileLayout?: "modal" | "compact"; + /** Keep this surface's desktop preview at the side, independent of the + * global game-board hover preference. */ + forceDockSide?: boolean; + dockPosition?: CardPreviewDockPosition; } /** @@ -19,6 +27,8 @@ export function HoverCardPreview({ card, onDismiss, mobileLayout, + forceDockSide = false, + dockPosition, }: HoverCardPreviewProps) { const cardPreviewMode = usePreferencesStore((s) => s.cardPreviewMode); const cardPreviewHoverDelayMs = usePreferencesStore((s) => s.cardPreviewHoverDelayMs); @@ -50,12 +60,35 @@ 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") return; + if ( + event.target instanceof Element + && event.target.closest("[data-card-preview]") != null + ) return; + if (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__/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(); diff --git a/client/src/components/card/__tests__/HoverCardPreview.test.tsx b/client/src/components/card/__tests__/HoverCardPreview.test.tsx index dd22a0608a..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}
), @@ -38,6 +51,33 @@ 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"); + expect(screen.getByTestId("preview")).toHaveAttribute("data-dock-position", "middle-right"); + }); + + 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("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/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..9c0ae0ebd2 100644 --- a/client/src/pages/DeckBuilderPage.tsx +++ b/client/src/pages/DeckBuilderPage.tsx @@ -135,6 +135,8 @@ export function DeckBuilderPage() { card={hoveredCard} onDismiss={useCallback(() => setHoveredCard(null), [])} mobileLayout="compact" + forceDockSide + dockPosition="middle-right" />
);