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"
/>
);