Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .impeccable/config.json
Original file line number Diff line number Diff line change
@@ -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."
}
]
}
}
46 changes: 27 additions & 19 deletions client/src/components/card/CardPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -138,6 +142,7 @@ export function CardPreview({
scryfallId,
sourcePrinting,
dockSide,
dockPosition,
onDismiss,
mobileLayout = "modal",
handSourceObjectId,
Expand Down Expand Up @@ -258,6 +263,7 @@ export function CardPreview({
scryfallId={scryfallId}
sourcePrinting={sourcePrinting}
dockSide={dockSide}
dockPosition={dockPosition}
onDismiss={onDismiss}
mobileLayout={mobileLayout}
handOrigin={handOrigin}
Expand All @@ -277,6 +283,7 @@ function CardPreviewInner({
scryfallId,
sourcePrinting,
dockSide,
dockPosition,
onDismiss,
mobileLayout,
handOrigin,
Expand All @@ -290,6 +297,7 @@ function CardPreviewInner({
scryfallId?: string;
sourcePrinting?: SourcePrinting;
dockSide?: boolean;
dockPosition?: CardPreviewDockPosition;
onDismiss?: () => void;
mobileLayout?: "modal" | "compact";
handOrigin: HandPreviewOrigin | null;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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())
Expand All @@ -565,6 +583,8 @@ function CardPreviewInner({
}
};
}, [
activeImageIsLoading,
activeImageSrc,
altHeld,
dockSide,
gap,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 (
<div
className={`${frameClass} ${isRotated ? "" : "aspect-[5/7]"} rounded-[4%] border border-gray-600 bg-gray-700 shadow-2xl animate-pulse`}
/>
);
}

return (
<div className={`${containerClass} border border-gray-600 overflow-hidden shadow-2xl ${renderInfoPanel ? "rounded-t-[4%] rounded-b-lg bg-gray-900" : "rounded-[4%]"}`}>
<div className={`${frameClass} relative rounded-[4%] overflow-hidden`}>
<div className={`${frameClass} ${isRotated ? "" : "aspect-[488/680]"} relative rounded-[4%] overflow-hidden`}>
{art.kind === "back" ? (
<CardBackFallback className={`${frameClass} rounded-[4%] border border-gray-600 shadow-2xl`} />
) : isLoading ? (
Expand Down
37 changes: 35 additions & 2 deletions client/src/components/card/HoverCardPreview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand All @@ -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);
Expand Down Expand Up @@ -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();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
};
window.addEventListener("pointermove", handlePointerMove);
return () => window.removeEventListener("pointermove", handlePointerMove);
}, [onDismiss, visibleCard]);

return (
<CardPreview
cardName={previewCard?.name ?? null}
scryfallId={previewCard?.scryfallId}
sourcePrinting={previewCard?.sourcePrinting}
dockSide={cardPreviewMode === "side"}
dockSide={forceDockSide || cardPreviewMode === "side"}
dockPosition={dockPosition}
onDismiss={onDismiss}
mobileLayout={mobileLayout}
/>
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/card/__tests__/CardPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down
44 changes: 42 additions & 2 deletions client/src/components/card/__tests__/HoverCardPreview.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) => (
<div data-dock-side={dockSide} data-testid="preview">
CardPreview: ({
cardName,
dockSide,
dockPosition,
}: {
cardName: string | null;
dockSide?: boolean;
dockPosition?: string;
}) => (
<div
data-card-preview=""
data-dock-position={dockPosition}
data-dock-side={dockSide}
data-testid="preview"
>
{cardName}
</div>
),
Expand Down Expand Up @@ -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(<HoverCardPreview card={CARD} forceDockSide dockPosition="middle-right" />);

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(<HoverCardPreview card={CARD} onDismiss={onDismiss} />);
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(<HoverCardPreview card={CARD} onDismiss={onDismiss} />);

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(<HoverCardPreview card={CARD} />);
Expand Down
1 change: 1 addition & 0 deletions client/src/components/deck-builder/hoverPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function mouseHoverPreview(
card: CardHoverInfo,
) {
return {
"data-deck-card-hover": "",
onPointerEnter: (e: PointerEvent) => {
if (e.pointerType === "mouse") onCardHover?.(card);
},
Expand Down
2 changes: 2 additions & 0 deletions client/src/pages/DeckBuilderPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export function DeckBuilderPage() {
card={hoveredCard}
onDismiss={useCallback(() => setHoveredCard(null), [])}
mobileLayout="compact"
forceDockSide
dockPosition="middle-right"
/>
</div>
);
Expand Down
Loading