diff --git a/client/src/components/board/AttachmentFan.tsx b/client/src/components/board/AttachmentFan.tsx
index 3b9f717270..d7140c2882 100644
--- a/client/src/components/board/AttachmentFan.tsx
+++ b/client/src/components/board/AttachmentFan.tsx
@@ -16,7 +16,6 @@ import {
deriveActivationAffordances,
resolveObjectActivation,
} from "../../viewmodel/cardActionChoice.ts";
-import { shouldRenderCardBack } from "../../viewmodel/cardProps.ts";
import { CardImage } from "../card/CardImage.tsx";
import { fanGeometry, spreadFactor } from "../card/fanGeometry.ts";
@@ -318,7 +317,7 @@ function FanCard({
tokenFilters={isToken ? tokenFiltersForObject(obj) : undefined}
tokenImageRef={isToken ? obj.token_image_ref : undefined}
oracleText={isToken ? obj.token_rules_text : undefined}
- faceDown={shouldRenderCardBack(obj)}
+ faceDown={obj.face_down === true}
faceDownCause={obj.face_down ? obj.face_down_cause : undefined}
className="!w-[var(--fan-card-w)] !h-[var(--fan-card-h)]"
/>
diff --git a/client/src/components/board/PermanentCard.tsx b/client/src/components/board/PermanentCard.tsx
index 64a9defa4a..b4d99a7d57 100644
--- a/client/src/components/board/PermanentCard.tsx
+++ b/client/src/components/board/PermanentCard.tsx
@@ -20,7 +20,7 @@ import { renderDescription } from "../../utils/description.ts";
import { usePreferencesStore } from "../../stores/preferencesStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";
import { buildGrantedKeywordSources, buildPTSources } from "../../viewmodel/attribution.ts";
-import { COUNTER_COLORS, computePTDisplay, counterIconClass, formatCounterType, shouldRenderCardBack, toRoman } from "../../viewmodel/cardProps.ts";
+import { COUNTER_COLORS, computePTDisplay, counterIconClass, formatCounterType, toRoman } from "../../viewmodel/cardProps.ts";
import { getCardDisplayColors } from "../card/cardFrame.ts";
import { ManaFontIcon } from "../icons/ManaFontIcon.tsx";
import { CounterTooltip } from "../ui/CounterTooltip.tsx";
@@ -502,7 +502,13 @@ export const PermanentCard = memo(function PermanentCard({
controllerIdentity || undefined,
);
const { name: imgName, faceIndex: imgFace, oracleId: imgOracleId, faceName: imgFaceName } = cardImageLookup(obj);
- const renderCardBack = shouldRenderCardBack(obj);
+ // The battlefield TILE of a face-down permanent always shows the cause
+ // marker / card back, exactly as the physical card lies in paper — for the
+ // controller too: the engine blanks a face-down permanent's live name and
+ // art (CR 708.2a), so there is no real face to draw here. The controller's
+ // peek lives in the hover preview, which resolves the stored face for
+ // `display_visible_to_viewer` objects (#7547).
+ const renderCardBack = obj.face_down === true;
const hasSummoningSickness = obj.has_summoning_sickness ?? false;
const ptDisplay = computePTDisplay(obj);
diff --git a/client/src/components/board/__tests__/PermanentCard.test.tsx b/client/src/components/board/__tests__/PermanentCard.test.tsx
index ef0eeae578..55ae02fb3b 100644
--- a/client/src/components/board/__tests__/PermanentCard.test.tsx
+++ b/client/src/components/board/__tests__/PermanentCard.test.tsx
@@ -2072,7 +2072,9 @@ describe("PermanentCard", () => {
expect(getByLabelText("Face-down card")).toHaveAttribute("data-face-down", "true");
});
- it("renders a face-down permanent's identity when the engine projects it to this viewer", () => {
+ it("keeps the tile backed even when the engine projects the identity to this viewer (#7547)", () => {
+ // The controller's peek lives in the hover preview; the battlefield tile
+ // shows the cause marker exactly as the physical card lies face down.
const gameState = makeState();
gameState.objects[1].face_down = true;
gameState.objects[1].display_visible_to_viewer = true;
@@ -2080,7 +2082,7 @@ describe("PermanentCard", () => {
renderPermanent();
- expect(screen.getByLabelText("Test Creature")).toHaveAttribute("data-face-down", "false");
+ expect(screen.getByLabelText("Face-down card")).toHaveAttribute("data-face-down", "true");
});
it("dispatches the engine-provided turn-face-up action", () => {
diff --git a/client/src/components/card/ArtCropCard.tsx b/client/src/components/card/ArtCropCard.tsx
index e631bca1ba..c2d4526113 100644
--- a/client/src/components/card/ArtCropCard.tsx
+++ b/client/src/components/card/ArtCropCard.tsx
@@ -8,10 +8,10 @@ import { useIsMobile } from "../../hooks/useIsMobile.ts";
import { isUnbounded, pillsOf, useCounterDisplay } from "../../hooks/useCounterDisplay.ts";
import { cardImageLookup, tokenFiltersForObject } from "../../services/cardImageLookup.ts";
import { CARD_BACK_URL } from "../../services/scryfall.ts";
-import { faceDownMarkerRef } from "./faceDownMarker.ts";
+import { faceDownMarkerName, faceDownMarkerRef } from "./faceDownMarker.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";
-import { COUNTER_COLORS, computePTDisplay, hasOtherPrintedFace, shouldRenderCardBack, toRoman } from "../../viewmodel/cardProps.ts";
+import { COUNTER_COLORS, computePTDisplay, hasOtherPrintedFace, toRoman } from "../../viewmodel/cardProps.ts";
import { CounterTooltip } from "../ui/CounterTooltip.tsx";
import { LoyaltyBadge } from "../ui/LoyaltyBadge.tsx";
import { CardArtFallback } from "./CardArtFallback.tsx";
@@ -38,8 +38,13 @@ export const ArtCropCard = memo(function ArtCropCard({ objectId }: ArtCropCardPr
(s) => obj && s.gameState?.players?.find((p) => p.id === obj.controller)?.commander_color_identity,
);
- const renderCardBack = shouldRenderCardBack(obj);
- const cardName = renderCardBack ? t("card.faceDownName") : (obj?.name ?? "");
+ // Same rule as `PermanentCard`: the tile always backs a face-down
+ // permanent (the live face is blanked per CR 708.2a); the controller's peek
+ // is the hover preview (#7547).
+ const renderCardBack = obj?.face_down === true;
+ const cardName = renderCardBack
+ ? (faceDownMarkerName(true, obj?.face_down_cause) ?? t("card.faceDownName"))
+ : (obj?.name ?? "");
const imageLookup = obj
? cardImageLookup(obj)
: { name: "", faceIndex: 0, oracleId: undefined, faceName: undefined };
diff --git a/client/src/components/card/CardImage.tsx b/client/src/components/card/CardImage.tsx
index 8846daec22..f07ad71bd5 100644
--- a/client/src/components/card/CardImage.tsx
+++ b/client/src/components/card/CardImage.tsx
@@ -5,7 +5,7 @@ import { useEngineCardData } from "../../hooks/useEngineCardData.ts";
import type { TokenSearchFilters } from "../../services/scryfall.ts";
import type { FaceDownCause, TokenImageRef } from "../../adapter/types.ts";
import { CARD_BACK_URL } from "../../services/scryfall.ts";
-import { faceDownMarkerRef } from "./faceDownMarker.ts";
+import { faceDownMarkerName, faceDownMarkerRef } from "./faceDownMarker.ts";
import { getBevelBorderStyle } from "./cardFrame.ts";
import { getCardImageSrcSetProps } from "./cardImageSrcSet.ts";
import { CardArtFallback } from "./CardArtFallback.tsx";
@@ -135,7 +135,9 @@ export function CardImage({
const renderedSrc = faceDown
? (imageError ? CARD_BACK_URL : (src ?? CARD_BACK_URL))
: (src ?? "");
- const renderedAlt = faceDown ? t("card.faceDownName") : cardName;
+ const renderedAlt = faceDown
+ ? (faceDownMarkerName(true, faceDownCause) ?? t("card.faceDownName"))
+ : cardName;
return (
diff --git a/client/src/components/card/CardPreview.tsx b/client/src/components/card/CardPreview.tsx
index 2287f83881..e18ffe84f5 100644
--- a/client/src/components/card/CardPreview.tsx
+++ b/client/src/components/card/CardPreview.tsx
@@ -16,6 +16,9 @@ import { useIsMobile } from "../../hooks/useIsMobile.ts";
import { useEngineCardData, useCardParseDetails, useCardRulings, type ParsedItem } from "../../hooks/useEngineCardData.ts";
import { isUnbounded, pillsOf, useCounterDisplay } from "../../hooks/useCounterDisplay.ts";
import { tokenFiltersForObject } from "../../services/cardImageLookup.ts";
+import { CARD_BACK_URL } from "../../services/scryfall.ts";
+import { faceDownMarkerRef } from "./faceDownMarker.ts";
+import { shouldRenderCardBack } from "../../viewmodel/cardProps.ts";
import type { CardRuling } from "../../services/engineRuntime.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { usePreferencesStore } from "../../stores/preferencesStore.ts";
@@ -314,6 +317,28 @@ function CardPreviewInner({
const backParseDetails = useCardParseDetails(backFaceName);
const isToken = obj?.display_source === "Token";
+ // Face-down permanents (#7547): opponents preview the cause MARKER full
+ // size (it carries the mechanic's reminder text); the controller previews
+ // the real card alone — the marker would only cover its rules text, and the
+ // controller already knows the mechanic (playtest call, 2026-08-19).
+ const previewMarkerRef = faceDownMarkerRef(
+ obj?.face_down ?? false,
+ obj?.face_down_cause,
+ );
+ const markerIsPrimary =
+ previewMarkerRef != null && obj != null && shouldRenderCardBack(obj);
+ // A hidden face-down PERMANENT whose cause has NO marker printing (unknown
+ // cause from an older save, or the Ixidron class) still gets a preview: the
+ // plain card back. It reveals nothing (CR 708.2a — the public face is a
+ // blank 2/2), and every art lookup below is suppressed so neither the
+ // generic label nor a blanked ref can leak into a network search.
+ // Battlefield only: a face-down card in a hidden zone (hideaway exile,
+ // issue #2889) has no public characteristics at all and keeps no preview.
+ const genericFaceDownBack =
+ obj != null
+ && obj.zone === "Battlefield"
+ && shouldRenderCardBack(obj)
+ && previewMarkerRef == null;
// For transformed DFCs, the active face is the back (Scryfall faceIndex 1).
// The engine swaps obj.name to the active face, but Scryfall always indexes
// 0=front, 1=back regardless of search name — so we must flip the index.
@@ -321,17 +346,27 @@ function CardPreviewInner({
const defaultFaceIndex = faceIndex ?? (isTransformed ? 1 : 0);
// Battlefield path: route through oracle_id when the engine attached one.
// Deck-builder path: `obj` is null, so we keep the name-based fallback.
- const { src, isLoading, isRotated, isFlip } = useCardImage(cardName, {
- size: "normal",
- faceIndex: defaultFaceIndex,
- isToken,
- tokenFilters: isToken && obj ? tokenFiltersForObject(obj) : undefined,
- tokenImageRef: isToken && obj ? obj.token_image_ref : undefined,
- oracleId: obj?.printed_ref?.oracle_id,
- faceName: obj?.printed_ref?.face_name,
- scryfallId,
- sourcePrinting,
- });
+ const suppressArtLookup = markerIsPrimary || genericFaceDownBack;
+ const { src, isLoading, isRotated, isFlip } = useCardImage(
+ genericFaceDownBack ? "" : cardName,
+ {
+ size: "normal",
+ faceIndex: defaultFaceIndex,
+ isToken: isToken || markerIsPrimary,
+ tokenFilters: isToken && obj && !genericFaceDownBack
+ ? tokenFiltersForObject(obj)
+ : undefined,
+ tokenImageRef: markerIsPrimary
+ ? previewMarkerRef
+ : isToken && obj && !genericFaceDownBack
+ ? obj.token_image_ref
+ : undefined,
+ oracleId: suppressArtLookup ? undefined : obj?.printed_ref?.oracle_id,
+ faceName: suppressArtLookup ? undefined : obj?.printed_ref?.face_name,
+ scryfallId,
+ sourcePrinting,
+ },
+ );
const classLevel = obj?.class_level;
const previewRef = useRef
(null);
const pointerRef = useRef<{ x: number; y: number } | null>(null);
@@ -383,8 +418,16 @@ function CardPreviewInner({
faceName: showOtherFace ? otherFaceName : undefined,
});
- const activeSrc = showOtherFace ? otherFaceImgResult.src : src;
- const activeLoading = showOtherFace ? otherFaceImgResult.isLoading : isLoading;
+ const activeSrc = genericFaceDownBack
+ ? CARD_BACK_URL
+ : showOtherFace
+ ? otherFaceImgResult.src
+ : src;
+ const activeLoading = genericFaceDownBack
+ ? false
+ : showOtherFace
+ ? otherFaceImgResult.isLoading
+ : isLoading;
const activeRotated = showOtherFace ? otherFaceImgResult.isRotated : isRotated;
const displayName = showOtherFace ? backFaceName! : cardName;
const showInfoPanel = obj?.zone === "Battlefield";
@@ -687,10 +730,8 @@ function CardPreviewInner({
@@ -792,35 +833,26 @@ function CardPreviewInner({
/** Mobile/tablet: card anchored right (landscape) or center (portrait), whole card visible. */
function MobilePreviewOverlay({
cardName,
- faceIndex,
- obj,
+ art,
onDismiss,
- sourcePrinting,
layout = "modal",
report,
}: {
cardName: string;
backFaceName: string | null;
- faceIndex?: number;
- obj: GameObject | null;
+ /** The parent's RESOLVED art state (marker / generic back / peek already
+ * applied). The overlay must never run its own lookup: a second
+ * `useCardImage` with raw `printed_ref` fields is exactly the mobile
+ * hidden-information bypass the PR 7551 review flagged. */
+ art: { src: string | null; isLoading: boolean; isRotated: boolean; isFlip: boolean };
onDismiss: () => void;
- sourcePrinting?: SourcePrinting;
layout?: "modal" | "compact";
/** In-game report context; absent in the deck builder. Only the full modal
* layout hosts the button — the compact peek dismisses on any tap. */
report?: CardReportContext;
}) {
const { t } = useTranslation("game");
- const { src, isLoading, isRotated, isFlip } = useCardImage(cardName, {
- size: "normal",
- faceIndex,
- isToken: obj?.display_source === "Token",
- tokenFilters: obj?.display_source === "Token" ? tokenFiltersForObject(obj) : undefined,
- tokenImageRef: obj?.display_source === "Token" ? obj.token_image_ref : undefined,
- oracleId: obj?.printed_ref?.oracle_id,
- faceName: obj?.printed_ref?.face_name,
- sourcePrinting,
- });
+ const { src, isLoading, isRotated, isFlip } = art;
// Issue #6156 on the mobile path: both arms below used to gate the art on
// `src &&`, so an artless token (no official paper printing) opened an
diff --git a/client/src/components/card/GameCardPreview.tsx b/client/src/components/card/GameCardPreview.tsx
index 0a26a03b07..f1bb90f58b 100644
--- a/client/src/components/card/GameCardPreview.tsx
+++ b/client/src/components/card/GameCardPreview.tsx
@@ -1,9 +1,12 @@
+import { useTranslation } from "react-i18next";
+
import { usePreviewDismiss } from "../../hooks/usePreviewDismiss.ts";
import { cardImageLookup } from "../../services/cardImageLookup.ts";
import { useGameStore } from "../../stores/gameStore.ts";
import { usePreferencesStore } from "../../stores/preferencesStore.ts";
import { useUiStore } from "../../stores/uiStore.ts";
import { shouldRenderCardBack } from "../../viewmodel/cardProps.ts";
+import { faceDownMarkerName } from "./faceDownMarker.ts";
import { CardPreview } from "./CardPreview.tsx";
/**
@@ -18,6 +21,7 @@ import { CardPreview } from "./CardPreview.tsx";
* it from the inspected game object, which is what this component does.
*/
export function GameCardPreview() {
+ const { t } = useTranslation("game");
// Lives here (not in GamePageContent) so its inspectedObjectId/previewSticky
// subscriptions don't re-render the whole page on every hover. This component
// is always mounted, so the dismiss listeners run for the game's full life.
@@ -44,15 +48,41 @@ export function GameCardPreview() {
// obj.name to the back-face name — cardImageLookup recovers the front name
// from obj.back_face. See services/cardImageLookup.ts (issue #90).
const inspectedLookup = inspectedObj ? cardImageLookup(inspectedObj) : null;
+ // A face-down permanent the viewer may look at (their own morph/manifest —
+ // CR 708.5): the live face is blanked per CR 708.2a, so the PREVIEW is the
+ // peek — it always shows the stored real face, no matter which face index
+ // the hover carries (#7547). The battlefield tile keeps the cause marker.
+ const inspectedPeekedFace =
+ inspectedObj && !shouldRenderCardBack(inspectedObj) && inspectedObj.face_down
+ ? (inspectedObj.back_face ?? null)
+ : null;
const inspectedCardName = inspectedObj && !shouldRenderCardBack(inspectedObj)
- ? inspectedFaceIndex === 1 && inspectedObj.back_face
- ? inspectedObj.back_face.name
- : inspectedLookup?.name ?? inspectedObj.name
- : null;
- // The "other" face: when viewing front, this is back_face; when viewing back, this is the front.
- const inspectedOtherFaceName = inspectedObj?.back_face && !shouldRenderCardBack(inspectedObj)
- ? inspectedFaceIndex === 1 ? inspectedObj.name : inspectedObj.back_face.name
- : null;
+ ? inspectedPeekedFace
+ ? inspectedPeekedFace.name
+ : inspectedFaceIndex === 1 && inspectedObj.back_face
+ ? inspectedObj.back_face.name
+ : inspectedLookup?.name ?? inspectedObj.name
+ : // An OPPONENT's face-down permanent previews as its cause MARKER (full
+ // size, reminder text included) — the identity stays hidden; the image
+ // itself resolves inside `CardPreview` from the object's cause (#7547).
+ // With no marker printing (unknown cause from an older save, or the
+ // Ixidron class — an effect turned it face down, CR 708.2a) the hover
+ // still answers: the generic label routes `CardPreview` onto the plain
+ // card back, which reveals nothing. BATTLEFIELD only — a face-down card
+ // in a hidden zone (hideaway exile, issue #2889) keeps rendering no
+ // preview at all: it has no public characteristics the back could stand
+ // in for, and that row pins exactly this.
+ (inspectedObj
+ ? faceDownMarkerName(true, inspectedObj.face_down_cause)
+ ?? (inspectedObj.zone === "Battlefield" ? t("card.faceDownName") : null)
+ : null);
+ // The "other" face: when viewing front, this is back_face; when viewing back,
+ // this is the front. A face-down permanent has no OTHER printed face — its
+ // `back_face` is the stored real face already shown by the peek.
+ const inspectedOtherFaceName =
+ inspectedObj?.back_face && !shouldRenderCardBack(inspectedObj) && !inspectedPeekedFace
+ ? inspectedFaceIndex === 1 ? inspectedObj.name : inspectedObj.back_face.name
+ : null;
const previewSuppressed = cardPreviewMode === "shift" && !shiftHeld;
diff --git a/client/src/components/card/__tests__/ArtCropCard.test.tsx b/client/src/components/card/__tests__/ArtCropCard.test.tsx
index d28f31cd24..4a0cd14c3b 100644
--- a/client/src/components/card/__tests__/ArtCropCard.test.tsx
+++ b/client/src/components/card/__tests__/ArtCropCard.test.tsx
@@ -254,9 +254,13 @@ describe("ArtCropCard", () => {
);
});
- it("renders a face-down permanent's projected identity", () => {
+ it("backs the tile of the viewer's OWN face-down permanent with its marker (#7547)", () => {
+ // The engine blanks a face-down permanent's live face (CR 708.2a), so the
+ // TILE always shows the cause marker — the controller's peek lives in the
+ // hover preview, not here. The stored real face must not raise the DFC
+ // badge either: a face-down permanent cannot be a DFC (CR 712.16).
mockUseCardImage.mockReturnValue({
- src: "card.png",
+ src: "morph-marker.png",
isLoading: false,
isRotated: false,
isFlip: false,
@@ -264,10 +268,11 @@ describe("ArtCropCard", () => {
const permanent = {
...transformedPermanent(),
face_down: true,
+ face_down_cause: "Morph" as const,
display_visible_to_viewer: true,
- name: "Hidden Sorcery",
+ name: "",
transformed: false,
- back_face: null,
+ back_face: { name: "Hooded Hydra", layout_kind: null } as never,
};
useGameStore.setState({
@@ -276,7 +281,8 @@ describe("ArtCropCard", () => {
render();
- expect(screen.getByAltText("Hidden Sorcery")).toBeInTheDocument();
+ expect(screen.getByAltText("Morph")).toHaveAttribute("src", "morph-marker.png");
+ expect(screen.queryByText("DFC")).toBeNull();
});
it("falls back to the card back when face-down marker art fails to load", () => {
@@ -305,7 +311,7 @@ describe("ArtCropCard", () => {
render();
- const marker = screen.getByAltText("Face-down card");
+ const marker = screen.getByAltText("Manifest");
expect(marker).toHaveAttribute(
"src",
"https://cards.scryfall.io/normal/front/m/a/manifest.jpg",
@@ -313,7 +319,7 @@ describe("ArtCropCard", () => {
fireEvent.error(marker);
- expect(screen.getByAltText("Face-down card")).toHaveAttribute("src", CARD_BACK_URL);
+ expect(screen.getByAltText("Manifest")).toHaveAttribute("src", CARD_BACK_URL);
});
it("keeps loyalty and P/T readable for planeswalkers and creature planeswalkers", () => {
diff --git a/client/src/components/card/__tests__/CardPreview.mobileFaceDown.test.tsx b/client/src/components/card/__tests__/CardPreview.mobileFaceDown.test.tsx
new file mode 100644
index 0000000000..e3f4fec970
--- /dev/null
+++ b/client/src/components/card/__tests__/CardPreview.mobileFaceDown.test.tsx
@@ -0,0 +1,111 @@
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import type { GameObject } from "../../../adapter/types.ts";
+import { useCardImage } from "../../../hooks/useCardImage.ts";
+import { CARD_BACK_URL } from "../../../services/scryfall.ts";
+import { useGameStore } from "../../../stores/gameStore.ts";
+import { useUiStore } from "../../../stores/uiStore.ts";
+import { buildGameObject, buildObjectMap } from "../../../test/factories/gameObjectFactory.ts";
+import { buildGameState } from "../../../test/factories/gameStateFactory.ts";
+import { CardPreview } from "../CardPreview.tsx";
+
+// The mock src ENCODES which lookup produced it: an oracle-id lookup stamps
+// the id, a marker/token lookup stamps the ref's oracle id, a bare name
+// lookup stamps the name. The hidden-information assertions below read that
+// stamp back — a leaked `printed_ref` becomes a visible "secret-oracle" src.
+vi.mock("../../../hooks/useCardImage.ts", () => ({
+ useCardImage: vi.fn((
+ cardName: string,
+ options?: {
+ oracleId?: string;
+ tokenImageRef?: { scryfall_oracle_id?: string | null } | null;
+ },
+ ) => ({
+ src: `${options?.oracleId ?? (options?.tokenImageRef ? `ref:${options.tokenImageRef.scryfall_oracle_id}` : cardName)}.png`,
+ isLoading: false,
+ isRotated: false,
+ isFlip: false,
+ })),
+}));
+
+vi.mock("../../../hooks/useEngineCardData.ts", () => ({
+ useEngineCardData: () => null,
+ useCardParseDetails: () => null,
+ useCardRulings: () => [],
+}));
+
+// The mobile overlay is the subject: force the mobile branch.
+vi.mock("../../../hooks/useIsMobile.ts", () => ({
+ useIsMobile: () => true,
+}));
+
+const SECRET_ORACLE = "secret-oracle-id";
+
+function hiddenFaceDown(overrides: Partial = {}): GameObject {
+ return buildGameObject({
+ id: 101,
+ card_id: 1,
+ zone: "Battlefield",
+ name: "",
+ face_down: true,
+ // The leak input under test: a wire that still carries the hidden card's
+ // printing (the engine clears it today — morph.rs pins that — but the
+ // display must not rely on it; a stale save or future field is enough).
+ printed_ref: { oracle_id: SECRET_ORACLE, face_name: "Hooded Hydra" } as never,
+ ...overrides,
+ });
+}
+
+function inspect(object: GameObject): void {
+ useGameStore.setState({
+ gameState: buildGameState({
+ objects: buildObjectMap(object),
+ next_object_id: 102,
+ battlefield: [object.id],
+ next_timestamp: 2,
+ }),
+ spellCosts: {},
+ });
+ useUiStore.setState({ inspectedObjectId: object.id });
+}
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ useGameStore.setState({ gameState: null, spellCosts: {} });
+ useUiStore.getState().dismissPreview();
+ useUiStore.setState({ inspectedObjectId: null });
+});
+
+describe("CardPreview mobile face-down (hidden information, #7551 review)", () => {
+ it("previews an opponent's Morph as the marker — never the printed ref", () => {
+ inspect(hiddenFaceDown({ face_down_cause: "Morph" as never }));
+
+ const { container } = render();
+
+ const srcs = [...container.querySelectorAll("img")].map((img) => img.getAttribute("src"));
+ expect(srcs.some((src) => src?.includes(SECRET_ORACLE))).toBe(false);
+ // The marker ref names the Morph token's oracle id — that IS the image.
+ expect(srcs.some((src) => src?.startsWith("ref:"))).toBe(true);
+ for (const call of vi.mocked(useCardImage).mock.calls) {
+ expect(call[1]?.oracleId).not.toBe(SECRET_ORACLE);
+ expect(call[1]?.faceName).not.toBe("Hooded Hydra");
+ }
+ });
+
+ it("previews a markerless face-down as the plain back — no name or ref lookup", () => {
+ inspect(hiddenFaceDown({ face_down_cause: "TurnedFaceDown" as never }));
+
+ const { container } = render();
+
+ const srcs = [...container.querySelectorAll("img")].map((img) => img.getAttribute("src"));
+ expect(srcs).toContain(CARD_BACK_URL);
+ expect(srcs.some((src) => src?.includes(SECRET_ORACLE))).toBe(false);
+ // The generic label must never become a card-name search either.
+ expect(srcs.some((src) => src?.includes("Face-down card.png"))).toBe(false);
+ for (const call of vi.mocked(useCardImage).mock.calls) {
+ expect(call[1]?.oracleId).not.toBe(SECRET_ORACLE);
+ }
+ });
+});
diff --git a/client/src/components/card/__tests__/GameCardPreview.test.tsx b/client/src/components/card/__tests__/GameCardPreview.test.tsx
index b342c3e5ca..74abb85c21 100644
--- a/client/src/components/card/__tests__/GameCardPreview.test.tsx
+++ b/client/src/components/card/__tests__/GameCardPreview.test.tsx
@@ -260,12 +260,33 @@ describe("GameCardPreview", () => {
expect(screen.getAllByAltText("Insectile Aberration").length).toBeGreaterThan(0);
});
- it("never previews a face-down permanent (hidden information)", () => {
+ it("previews a markerless face-down permanent as the generic back — identity stays hidden (#7551 review)", () => {
+ // No recorded cause (older saves): there is no marker printing, but the
+ // hover must still answer — with the generic card back, which reveals
+ // nothing (CR 708.2a: the public face is a blank 2/2).
inspect(battlefieldObject({ face_down: true }));
- const { container } = render();
+ render();
- expect(container.firstChild).toBeNull();
+ expect(screen.getAllByAltText("Face-down card").length).toBeGreaterThan(0);
+ expect(screen.queryByAltText("Pithing Needle")).toBeNull();
+ });
+
+ it("previews the Ixidron class (TurnedFaceDown) as the generic back (#7551 review)", () => {
+ // `TurnedFaceDown` (an effect turned it face down, CR 708.2a) has no
+ // printed marker token — same generic-back path as the unknown cause.
+ inspect(
+ battlefieldObject({
+ face_down: true,
+ face_down_cause: "TurnedFaceDown" as never,
+ name: "",
+ }),
+ );
+
+ render();
+
+ expect(screen.getAllByAltText("Face-down card").length).toBeGreaterThan(0);
+ expect(screen.queryByAltText("Pithing Needle")).toBeNull();
});
it("previews a face-down permanent when the engine projects its identity", () => {
@@ -275,4 +296,38 @@ describe("GameCardPreview", () => {
expect(screen.getAllByAltText("Pithing Needle").length).toBeGreaterThan(0);
});
+
+ it("peeks the STORED face of the viewer's own face-down permanent (#7547)", () => {
+ // The live face is blanked per CR 708.2a; the preview is the CR 708.5
+ // peek, so it resolves the stored real face — on any hovered face index.
+ inspect(
+ battlefieldObject({
+ face_down: true,
+ display_visible_to_viewer: true,
+ name: "",
+ back_face: { name: "Hooded Hydra", layout_kind: null } as never,
+ }),
+ );
+
+ render();
+
+ expect(screen.getAllByAltText("Hooded Hydra").length).toBeGreaterThan(0);
+ });
+
+ it("previews an OPPONENT's face-down permanent as its cause marker (#7547)", () => {
+ // The identity stays hidden; the marker carries the mechanic's reminder
+ // text, which is exactly what an opponent may know.
+ inspect(
+ battlefieldObject({
+ face_down: true,
+ face_down_cause: "Morph" as never,
+ name: "",
+ }),
+ );
+
+ render();
+
+ expect(screen.getAllByAltText("Morph").length).toBeGreaterThan(0);
+ expect(screen.queryByAltText("Pithing Needle")).toBeNull();
+ });
});
diff --git a/client/src/components/card/faceDownMarker.ts b/client/src/components/card/faceDownMarker.ts
index 3a793e703b..0d05e659f7 100644
--- a/client/src/components/card/faceDownMarker.ts
+++ b/client/src/components/card/faceDownMarker.ts
@@ -47,6 +47,27 @@ const MARKERS: Partial> = {
// printed for it, so it keeps the generic card back.
};
+/** Printed token names, for the tile's name bar and the preview caption. */
+const MARKER_NAMES: Partial> = {
+ Manifest: "Manifest",
+ Morph: "Morph",
+ Cloak: "A Mysterious Creature",
+ Disguise: "A Mysterious Creature",
+};
+
+/**
+ * The printed marker token's NAME for a face-down permanent, or `null` when
+ * none applies. Shown on the battlefield tile instead of the generic
+ * "Face-down card" label.
+ */
+export function faceDownMarkerName(
+ faceDown: boolean,
+ cause: FaceDownCause | null | undefined,
+): string | null {
+ if (!faceDown || !cause) return null;
+ return MARKER_NAMES[cause] ?? null;
+}
+
/**
* The marker printing for a face-down permanent, or `null` when none applies —
* the permanent is face up, the engine did not record a cause (older saves), or
diff --git a/client/src/viewmodel/cardProps.ts b/client/src/viewmodel/cardProps.ts
index 23f2317075..b8bca313de 100644
--- a/client/src/viewmodel/cardProps.ts
+++ b/client/src/viewmodel/cardProps.ts
@@ -232,8 +232,17 @@ export function formatTypeLine(cardTypes: CardType, keywords?: Keyword[]): strin
* discriminant — it ships `layout_kind` on the serialized back face (the same
* value `engine::game::transform::is_double_faced_permanent` keys on).
*/
-export function hasOtherPrintedFace(obj: Pick): boolean {
- return obj.back_face != null && obj.back_face.layout_kind !== "Flip";
+export function hasOtherPrintedFace(
+ obj: Pick,
+): boolean {
+ // CR 712.16: a double-faced permanent can't be face down — a face-down
+ // permanent's `back_face` is its STORED REAL FACE (morph/manifest), not
+ // another printed face, so it must not raise the DFC affordance (#7547).
+ return (
+ obj.face_down !== true &&
+ obj.back_face != null &&
+ obj.back_face.layout_kind !== "Flip"
+ );
}
export function computePTDisplay(obj: GameObject): PTDisplay | null {