diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 8a101b9ac7..98dc15d135 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -340,6 +340,60 @@ jobs:
./scripts/gen-scryfall-sets.sh
./scripts/gen-scryfall-printings.sh
+ - name: Derive MTGJSON cache key
+ # Re-derived in this job rather than plumbed out of `preview-inputs`:
+ # `steps.` references cannot cross a job boundary, and a dangling one
+ # does not fail — it expands to the empty string, which would freeze the
+ # locale-map key at a constant and serve the same maps forever.
+ #
+ # Re-running is safe and stays single-authority: the action probes
+ # MTGJSON's published version, so both jobs derive the same suffix from
+ # the same source rather than forking the formula.
+ id: mtgjson-key
+ uses: ./.github/actions/mtgjson-cache-key
+
+ - name: Restore locale card-art maps
+ # Caches the OUTPUT (five small JSON maps), not the ~169 MB AllSetFiles
+ # input the generator downloads to build them. Folding that input into
+ # the `data/mtgjson` entry is not an option: that cache is saved far
+ # earlier (it is complete before this step runs) and GitHub caches are
+ # immutable, so a later hit could never contain it — and the extracted
+ # set files would add ~1 GB to an entry shared with release.yml.
+ #
+ # Same `mtgjson-` prefix as the draft-pools pair (so /clear-caches
+ # mtgjson sweeps it) and the same published-data suffix. The generator's
+ # script hash joins the key because LOCALE_MAP lives in the script: a
+ # locale added there must rebuild, and the data-version suffix alone
+ # cannot see a source change.
+ #
+ # NO restore-keys: every input to the output is in the key, so an
+ # inexact hit is by construction a stale map.
+ id: locale-images-cache
+ uses: actions/cache/restore@v4
+ with:
+ path: client/public/scryfall-images.*.json
+ key: mtgjson-locale-images-${{ steps.mtgjson-key.outputs.suffix }}-${{ hashFiles('scripts/gen-scryfall-locale-images.sh') }}
+
+ # Separate from the Scryfall step: this reads MTGJSON set files, not the
+ # Scryfall bulk exports, so it is not covered by the data/scryfall cache.
+ #
+ # The generator no-ops when all five maps are already present, so a cache
+ # hit skips the AllSetFiles download entirely. It gates on the files
+ # themselves rather than `cache-hit` (same reasoning as "Generate draft
+ # pools"): a poisoned or partial entry reports a hit with nothing on disk,
+ # and regenerating on an absent file lets the cache self-heal.
+ - name: Generate locale card-art maps
+ run: ./scripts/gen-scryfall-locale-images.sh
+
+ - name: Save locale card-art maps
+ # Banked the moment the maps exist, so the AllSetFiles download is never
+ # paid twice for the same inputs even if a later step fails.
+ if: ${{ steps.locale-images-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v4
+ with:
+ path: client/public/scryfall-images.*.json
+ key: mtgjson-locale-images-${{ steps.mtgjson-key.outputs.suffix }}-${{ hashFiles('scripts/gen-scryfall-locale-images.sh') }}
+
- name: Save Scryfall bulk data
# data/scryfall is complete once the four generators have run. Banking
# it here keeps a later failure from re-hitting the Scryfall API on the
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 270319ed77..e129b37773 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -314,6 +314,48 @@ jobs:
./scripts/gen-scryfall-sets.sh
./scripts/gen-scryfall-printings.sh
+ - name: Restore locale card-art maps
+ # Caches the OUTPUT (five small JSON maps), not the ~169 MB AllSetFiles
+ # input the generator downloads to build them. Folding that input into
+ # the `data/mtgjson` entry is not an option: that cache is saved far
+ # earlier (it is complete before this step runs) and GitHub caches are
+ # immutable, so a later hit could never contain it — and the extracted
+ # set files would add ~1 GB to an entry shared with deploy.yml.
+ #
+ # Same `mtgjson-` prefix as the draft-pools pair (so /clear-caches
+ # mtgjson sweeps it) and the same published-data suffix. The generator's
+ # script hash joins the key because LOCALE_MAP lives in the script: a
+ # locale added there must rebuild, and the data-version suffix alone
+ # cannot see a source change.
+ #
+ # NO restore-keys: every input to the output is in the key, so an
+ # inexact hit is by construction a stale map.
+ id: locale-images-cache
+ uses: actions/cache/restore@v4
+ with:
+ path: client/public/scryfall-images.*.json
+ key: mtgjson-locale-images-${{ steps.mtgjson-key.outputs.suffix }}-${{ hashFiles('scripts/gen-scryfall-locale-images.sh') }}
+
+ # Separate from the Scryfall step: this reads MTGJSON set files, not the
+ # Scryfall bulk exports, so it is not covered by the data/scryfall cache.
+ #
+ # The generator no-ops when all five maps are already present, so a cache
+ # hit skips the AllSetFiles download entirely. It gates on the files
+ # themselves rather than `cache-hit` (same reasoning as "Generate draft
+ # pools"): a poisoned or partial entry reports a hit with nothing on disk,
+ # and regenerating on an absent file lets the cache self-heal.
+ - name: Generate locale card-art maps
+ run: ./scripts/gen-scryfall-locale-images.sh
+
+ - name: Save locale card-art maps
+ # Banked the moment the maps exist, so the AllSetFiles download is never
+ # paid twice for the same inputs even if a later step fails.
+ if: ${{ steps.locale-images-cache.outputs.cache-hit != 'true' }}
+ uses: actions/cache/save@v4
+ with:
+ path: client/public/scryfall-images.*.json
+ key: mtgjson-locale-images-${{ steps.mtgjson-key.outputs.suffix }}-${{ hashFiles('scripts/gen-scryfall-locale-images.sh') }}
+
- name: Save Scryfall bulk data
# data/scryfall is complete once the four generators have run. Banking
# it here keeps a later failure from re-hitting the Scryfall API on the
diff --git a/.gitignore b/.gitignore
index 95ff364070..8188a1e52e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -44,6 +44,7 @@ client/public/card-names.json
client/public/coverage-data.json
client/public/coverage-summary.json
client/public/scryfall-data.json
+client/public/scryfall-images.*.json
client/public/scryfall-printings.json
client/public/scryfall-sets.json
client/public/scryfall-token-images.json
@@ -71,6 +72,8 @@ data/mtgjson/SetList.json
data/mtgjson/.refresh-week
data/mtgjson/decks/
data/mtgjson/sets/
+data/mtgjson/AllSetFiles.tar
+data/mtgjson/allsets/
!data/abilities/
!data/precons/
!data/learned-weights.json
diff --git a/client/src/components/deck-builder/PrintingPickerModal.tsx b/client/src/components/deck-builder/PrintingPickerModal.tsx
index 848000b359..f4178e14df 100644
--- a/client/src/components/deck-builder/PrintingPickerModal.tsx
+++ b/client/src/components/deck-builder/PrintingPickerModal.tsx
@@ -1,7 +1,8 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
-import { getCardPrintings } from "../../services/scryfall.ts";
+import { useLocaleArt } from "../../hooks/useCardImage.ts";
+import { getCardPrintings, resolvePrintingImageUrl } from "../../services/scryfall.ts";
import type { PrintingEntry } from "../../services/scryfall.ts";
import { usePreferencesStore } from "../../stores/preferencesStore.ts";
import { ModalPanelShell } from "../ui/ModalPanelShell";
@@ -28,6 +29,15 @@ export function PrintingPickerModal({
const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE);
const [query, setQuery] = useState("");
+ // Tile URLs come from `resolvePrintingImageUrl` during render, which reads the
+ // installed locale-art map. Without this the picker would render whatever
+ // vocabulary happened to be loaded when it mounted: open it while the map is
+ // still in flight and every tile shows English art with no re-render when the
+ // map lands. The hook loads the active language's map and ticks this
+ // component when it arrives; the tile URLs are recomputed inline, so a
+ // re-render is all that is needed to pick up the swap.
+ useLocaleArt();
+
const currentOverride = usePreferencesStore((s) => s.artOverrides[oracleId]);
const setArtOverride = usePreferencesStore((s) => s.setArtOverride);
const clearArtOverride = usePreferencesStore((s) => s.clearArtOverride);
@@ -132,7 +142,12 @@ export function PrintingPickerModal({
{visiblePrintings.map((printing) => {
const isSelected = currentOverride?.scryfallId === printing.id;
- const imgUrl = printing.faces[0]?.normal;
+ // Go through the shared resolver rather than reading the face URL
+ // directly: it applies the active locale's art, so the picker
+ // previews each printing in the same language the board renders.
+ // It also maps Scryfall's "image coming soon" placeholder to null,
+ // which this tile already renders as a proper "no image" cell.
+ const imgUrl = resolvePrintingImageUrl(printing, 0, "normal");
const isBorderless = printing.border_color === "borderless";
const isExtended = printing.frame_effects.includes("extendedart");
diff --git a/client/src/components/deck-builder/__tests__/PrintingPickerModal.test.tsx b/client/src/components/deck-builder/__tests__/PrintingPickerModal.test.tsx
new file mode 100644
index 0000000000..3d439b7168
--- /dev/null
+++ b/client/src/components/deck-builder/__tests__/PrintingPickerModal.test.tsx
@@ -0,0 +1,91 @@
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+
+import { PrintingPickerModal } from "../PrintingPickerModal";
+import { usePreferencesStore } from "../../../stores/preferencesStore.ts";
+
+// `vi.mock` is hoisted above the imports, so the fixture its factory closes over
+// has to be hoisted too — a plain `const` would be in the temporal dead zone.
+const { EN_ID, DE_ID, cardUrl } = vi.hoisted(() => {
+ const EN_ID = "0dbac7ce-a6fa-466e-b6ba-173cf2dec98e";
+ const DE_ID = "345a1cf0-e4de-42a9-9c72-ed16826b9067";
+ // Real five-segment `cards.scryfall.io` shape: a shorter URL is not
+ // localizable at all, so every assertion below would pass vacuously.
+ const cardUrl = (id: string) =>
+ `https://cards.scryfall.io/normal/front/${id[0]}/${id[1]}/${id}.jpg`;
+ return { EN_ID, DE_ID, cardUrl };
+});
+
+// Only `getCardPrintings` is stubbed. The localization path under test —
+// `resolvePrintingImageUrl`, `loadLocaleArt`, `isLocaleArtReady` — stays real and
+// shares one module closure, so the map a load installs is the map a tile reads.
+vi.mock("../../../services/scryfall.ts", async (importOriginal) => {
+ const actual =
+ await importOriginal
();
+ return {
+ ...actual,
+ getCardPrintings: vi.fn().mockResolvedValue([
+ {
+ id: EN_ID,
+ set: "mid",
+ set_name: "Innistrad: Midnight Hunt",
+ collector_number: "7",
+ released_at: "2021-09-24",
+ border_color: "black",
+ frame_effects: [],
+ full_art: false,
+ faces: [{ normal: cardUrl(EN_ID), art_crop: cardUrl(EN_ID) }],
+ },
+ ]),
+ };
+});
+
+describe("PrintingPickerModal localized art", () => {
+ afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+ usePreferencesStore.getState().setLanguage("en");
+ });
+
+ it("swaps tile art when the locale map arrives after the modal mounts", async () => {
+ usePreferencesStore.getState().setLanguage("de");
+
+ // Hold the locale map in flight so the modal is forced through the state
+ // this test exists for: mounted, localized language, no map yet.
+ let settle: ((r: Response) => void) | undefined;
+ vi.stubGlobal(
+ "fetch",
+ vi.fn(
+ () =>
+ new Promise((resolve) => {
+ settle = resolve;
+ }),
+ ),
+ );
+
+ render(
+ {}} />,
+ );
+
+ // Pending map: the tile renders English art rather than blocking on a
+ // fetch that may 404. This is also the reach guard for the swap below —
+ // without it, a tile that never rendered at all would satisfy the final
+ // assertion by never having been English in the first place.
+ const img = await screen.findByRole("img");
+ expect(img).toHaveAttribute("src", cardUrl(EN_ID));
+
+ settle!(
+ new Response(JSON.stringify({ [EN_ID]: DE_ID }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+
+ // The picker resolves tile URLs during render, so arrival of the map is
+ // only visible if the component subscribed to it. Drop `useLocaleArt()`
+ // from the modal and this assertion fails on stale English art.
+ await waitFor(() => {
+ expect(screen.getByRole("img")).toHaveAttribute("src", cardUrl(DE_ID));
+ });
+ });
+});
diff --git a/client/src/hooks/__tests__/useCardImage.test.tsx b/client/src/hooks/__tests__/useCardImage.test.tsx
index 1b5bd2aa53..c2d35fcc4c 100644
--- a/client/src/hooks/__tests__/useCardImage.test.tsx
+++ b/client/src/hooks/__tests__/useCardImage.test.tsx
@@ -119,6 +119,11 @@ describe("useCardImage", () => {
findPrintingById: vi.fn(),
getCardPrintings: vi.fn().mockResolvedValue([]),
isCardImageRotatedSync: vi.fn().mockReturnValue(false),
+ // Report the art locale as already resolved so the hook's background
+ // loader short-circuits — this test is about token fallback, not
+ // localization.
+ isLocaleArtReady: vi.fn().mockReturnValue(true),
+ loadLocaleArt: vi.fn().mockResolvedValue(new Map()),
resolveFaceIndexSync: vi.fn().mockReturnValue(null),
resolveOracleIdSync: vi.fn().mockReturnValue(null),
resolvePrintingImageUrl: vi.fn(),
@@ -166,6 +171,10 @@ describe("useCardImage", () => {
getCardPrintings: vi.fn().mockResolvedValue([]),
isCardImageFlipLayoutSync: vi.fn().mockReturnValue(false),
isCardImageRotatedSync: vi.fn().mockReturnValue(false),
+ // See the note on the token-fallback mock above: the art locale is
+ // reported ready so the background loader never runs here.
+ isLocaleArtReady: vi.fn().mockReturnValue(true),
+ loadLocaleArt: vi.fn().mockResolvedValue(new Map()),
pickOldestPrinting: vi.fn(),
resolveFaceIndexSync: vi.fn().mockReturnValue(null),
resolveOracleIdSync: vi.fn().mockReturnValue(null),
diff --git a/client/src/hooks/useCardImage.ts b/client/src/hooks/useCardImage.ts
index 8cebceddaa..33b816b153 100644
--- a/client/src/hooks/useCardImage.ts
+++ b/client/src/hooks/useCardImage.ts
@@ -9,6 +9,8 @@ import {
getCardPrintings,
isCardImageFlipLayoutSync,
isCardImageRotatedSync,
+ isLocaleArtReady,
+ loadLocaleArt,
pickOldestPrinting,
resolveFaceIndexSync,
resolveOracleIdSync,
@@ -106,6 +108,81 @@ registerStrategyCacheClearFn(() => {
strategyNoWinnerCache.clear();
});
+/**
+ * Locales whose card-art map is currently being fetched. Same anti-spin-loop
+ * discipline as `strategyInflight`: without it, every render before the map
+ * lands would start another fetch.
+ *
+ * A failed fetch cannot loop either — `loadLocaleArt` swallows errors and
+ * resolves an empty map, which still installs, so `isLocaleArtReady` flips true
+ * and every card simply keeps its English art.
+ */
+const localeArtInflight = new Set();
+
+/**
+ * Fetch the active locale's card-art map, then invalidate every mounted tile.
+ *
+ * The dispatch deliberately carries no `detail`: unlike a printings fetch (which
+ * concerns one oracleId), a language change re-resolves the URL of every card on
+ * screen, and the listener treats a detail-less event as a global invalidation.
+ */
+function loadLocaleArtInBackground(lang: string): void {
+ if (isLocaleArtReady(lang) || localeArtInflight.has(lang)) return;
+ localeArtInflight.add(lang);
+ loadLocaleArt(lang)
+ .then(() => {
+ localeArtInflight.delete(lang);
+ artCacheEvents.dispatchEvent(new Event("update"));
+ })
+ .catch(() => {
+ localeArtInflight.delete(lang);
+ });
+}
+
+/**
+ * Cache-key component for a resolved image URL. It encodes the *art vocabulary*
+ * the URL was produced with, not merely the language: before the locale map
+ * arrives every card legitimately resolves to English art, and caching that
+ * under a bare `"de"` key would pin it there forever — the background load
+ * dispatches an invalidation, but the request key would be unchanged, so the
+ * resolution effect would never re-run. Distinguishing pending from ready makes
+ * the map's arrival a genuine key change.
+ */
+function localeArtCacheKey(lang: string): string {
+ return isLocaleArtReady(lang) ? lang : `${lang}:pending`;
+}
+
+/**
+ * Load the active language's card-art map and re-render the caller when it
+ * lands, returning the art-locale key its URLs were resolved with.
+ *
+ * For components that resolve art through `resolvePrintingImageUrl` directly
+ * instead of through `useCardImage` — they otherwise render whatever vocabulary
+ * happened to be installed at mount and never hear about the map arriving.
+ *
+ * `useCardImage` deliberately does NOT call this: it already owns an
+ * `artCacheEvents` subscription filtered by oracleId, and an unfiltered second
+ * one per tile would resurrect the unscoped re-render storm that filter exists
+ * to prevent (see the subscription comment in the hook body). Callers of this
+ * hook subscribe once per component, not once per rendered card.
+ */
+export function useLocaleArt(): string {
+ const language = usePreferencesStore((s) => s.language);
+ const [, setLocaleArtTick] = useState(0);
+
+ useEffect(() => {
+ const handler = () => setLocaleArtTick((t) => t + 1);
+ artCacheEvents.addEventListener("update", handler);
+ return () => artCacheEvents.removeEventListener("update", handler);
+ }, []);
+
+ useEffect(() => {
+ loadLocaleArtInBackground(language);
+ }, [language]);
+
+ return localeArtCacheKey(language);
+}
+
function applyChainEntry(
entry: ArtChainEntry,
printings: PrintingEntry[],
@@ -241,6 +318,12 @@ function imageRequestKey(
tokenImageRefKey: string,
oracleId: string,
faceName: string,
+ // `imageRequestCache` stores the FINAL resolved URL, which differs per
+ // language once localized art is applied — so the art locale belongs in the
+ // key. The printing-selection caches (`strategyCacheMap`, `printingsCacheMap`)
+ // stay language-neutral on purpose: which printing wins is a function of the
+ // user's art preferences, not of their language.
+ artLocaleKey: string,
): string {
return [
oracleId || cardName,
@@ -253,6 +336,7 @@ function imageRequestKey(
filterSubtypes,
String(filterHasAbilities),
tokenImageRefKey,
+ artLocaleKey,
].join("|");
}
@@ -370,6 +454,11 @@ export function useCardImage(
const artOverrides = usePreferencesStore((s) => s.artOverrides);
const artChain = usePreferencesStore((s) => s.artChain);
+ // Card art follows the UI language: the printing the user chose is kept, and
+ // only its image is swapped for the same printing in their language. Cards
+ // with no localized sibling keep their English art.
+ const language = usePreferencesStore((s) => s.language);
+ const artLocaleKey = localeArtCacheKey(language);
const [src, setSrc] = useState(null);
const [isRotated, setIsRotated] = useState(false);
@@ -405,6 +494,20 @@ export function useCardImage(
return () => artCacheEvents.removeEventListener("update", handler);
}, []);
+ // Kick the locale-art load from an effect, not from render. It writes
+ // module-global state (`desiredArtLang`, which decides whose fetch may
+ // install itself), and under concurrent rendering React may start a render
+ // and discard it — so a render-phase call can let a language that was never
+ // committed win that race. Running after commit means only the committed
+ // language is ever requested.
+ //
+ // Deliberately placed after the subscription effect above: effects run in
+ // source order, so the `update` listener is registered before any dispatch
+ // this load can trigger, even when `loadLocaleArt` resolves from cache.
+ useEffect(() => {
+ loadLocaleArtInBackground(language);
+ }, [language]);
+
// The printings/art-strategy path indexes faces numerically, but for a
// DFC/MDFC the reliable signal is the engine's `faceName` (an MDFC cast as its
// back face reports `transformed: false`, so the caller's `faceIndex` is 0 —
@@ -456,6 +559,7 @@ export function useCardImage(
tokenImageRefKey,
oracleId,
faceName,
+ artLocaleKey,
);
useEffect(() => {
diff --git a/client/src/services/__tests__/scryfall.test.ts b/client/src/services/__tests__/scryfall.test.ts
index 802e6e8551..95d51e065b 100644
--- a/client/src/services/__tests__/scryfall.test.ts
+++ b/client/src/services/__tests__/scryfall.test.ts
@@ -13,6 +13,8 @@ import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import type { PrintingEntry } from "../scryfall.ts";
+
const REPO_ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../../../..",
@@ -1332,3 +1334,171 @@ esac
},
);
});
+
+describe("localized card art", () => {
+ // Real five-segment `cards.scryfall.io` shape. As with the size-derivation
+ // suite above, the `makeLocalDataMap` fixture is deliberately NOT reused: its
+ // one-segment `https://img.example/.jpg` URLs are not localizable, so
+ // every assertion here would pass vacuously against them.
+ const EN_ID = "0dbac7ce-a6fa-466e-b6ba-173cf2dec98e";
+ const DE_ID = "345a1cf0-e4de-42a9-9c72-ed16826b9067";
+ const UNMAPPED_ID = "11111111-2222-3333-4444-555555555555";
+
+ const cardUrl = (id: string, size = "normal", face = "front", query = "") =>
+ `https://cards.scryfall.io/${size}/${face}/${id[0]}/${id[1]}/${id}.jpg${query}`;
+
+ const printing = (id: string, query = ""): PrintingEntry => ({
+ id,
+ set: "mid",
+ set_name: "Innistrad: Midnight Hunt",
+ collector_number: "7",
+ released_at: "2021-09-24",
+ border_color: "black",
+ frame_effects: [],
+ full_art: false,
+ faces: [
+ { normal: cardUrl(id, "normal", "front", query), art_crop: cardUrl(id, "art_crop") },
+ { normal: cardUrl(id, "normal", "back", query), art_crop: cardUrl(id, "art_crop", "back") },
+ ],
+ });
+
+ function stubLocaleArt(map: Record) {
+ global.fetch = vi.fn().mockResolvedValue(
+ new Response(JSON.stringify(map), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ }
+
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("swaps the image to the localized printing, keeping the chosen printing", async () => {
+ const mod = await loadScryfallModule();
+ stubLocaleArt({ [EN_ID]: DE_ID });
+ await mod.loadLocaleArt("de");
+
+ const resolved = mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal");
+
+ expect(resolved).toBe(cardUrl(DE_ID));
+ // Non-vacuity: a no-op `localizeImageUrl` would return the English URL.
+ expect(resolved).not.toBe(cardUrl(EN_ID));
+ });
+
+ it("keeps English art when the printing has no localized sibling", async () => {
+ const mod = await loadScryfallModule();
+ stubLocaleArt({ [EN_ID]: DE_ID });
+ await mod.loadLocaleArt("de");
+
+ // Reach guard FIRST: prove the map actually loaded and the lookup runs.
+ // Without this, a failed fetch (empty map) would make the real assertion
+ // below pass for entirely the wrong reason.
+ expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(DE_ID));
+
+ expect(mod.resolvePrintingImageUrl(printing(UNMAPPED_ID), 0, "normal")).toBe(
+ cardUrl(UNMAPPED_ID),
+ );
+ });
+
+ it("localizes the back face and the art crop", async () => {
+ const mod = await loadScryfallModule();
+ stubLocaleArt({ [EN_ID]: DE_ID });
+ await mod.loadLocaleArt("de");
+
+ // A localized Scryfall id addresses the whole printing; front/back is a path
+ // segment, so one mapping covers both faces of a DFC.
+ expect(mod.resolvePrintingImageUrl(printing(EN_ID), 1, "normal")).toBe(
+ cardUrl(DE_ID, "normal", "back"),
+ );
+ expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "art_crop")).toBe(
+ cardUrl(DE_ID, "art_crop"),
+ );
+ });
+
+ it("is a no-op for English", async () => {
+ const mod = await loadScryfallModule();
+ stubLocaleArt({ [EN_ID]: DE_ID });
+ await mod.loadLocaleArt("de");
+ expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(DE_ID));
+
+ // Assert the *gate*, not just the reset. `useCardImage` skips the load
+ // entirely when this reports ready, so an unconditionally-ready English
+ // would strand the German map installed and keep serving German art —
+ // `loadLocaleArt("en")` below would never run in production.
+ expect(mod.isLocaleArtReady("en")).toBe(false);
+
+ // Switching back to English must drop the map, not keep serving German art.
+ await mod.loadLocaleArt("en");
+ expect(mod.isLocaleArtReady("en")).toBe(true);
+ expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(EN_ID));
+ });
+
+ it("never rewrites the card back or the placeholder", async () => {
+ const mod = await loadScryfallModule();
+ // Map the placeholder's and card back's own ids too, so the guard is doing
+ // the work rather than a lookup simply missing.
+ stubLocaleArt({ [EN_ID]: DE_ID, soon: DE_ID, "0aeebaf5-8c7d-4636-9e82-8c27447861f7": DE_ID });
+ await mod.loadLocaleArt("de");
+ expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(DE_ID));
+
+ // `deriveImageUrl` is the exported probe for the same `splitSizedImageUrl`
+ // guard `localizeImageUrl` relies on; a URL it rejects is one localization
+ // also leaves alone. The placeholder must stay byte-identical or
+ // `isPlaceholderImageUrl`'s `===` stops gating the printing fallback.
+ for (const input of [mod.CARD_BACK_URL, "https://errors.scryfall.com/soon.jpg"]) {
+ expect(mod.imageUrlSize(input)).toBeNull();
+ expect(mod.deriveImageUrl(input, "small")).toBe(input);
+ }
+ });
+
+ it("reports readiness and tolerates a missing locale file", async () => {
+ const mod = await loadScryfallModule();
+ expect(mod.isLocaleArtReady("en")).toBe(true);
+ expect(mod.isLocaleArtReady("de")).toBe(false);
+
+ global.fetch = vi.fn().mockResolvedValue(new Response("", { status: 404 }));
+ await mod.loadLocaleArt("de");
+
+ // A 404 still counts as resolved — otherwise `useCardImage` would refetch on
+ // every render. Every card simply keeps its English art.
+ expect(mod.isLocaleArtReady("de")).toBe(true);
+ expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(EN_ID));
+ });
+
+ it("dedupes concurrent loads of one locale into a single fetch", async () => {
+ const mod = await loadScryfallModule();
+ let settle: ((r: Response) => void) | undefined;
+ const fetchMock = vi.fn(
+ () => new Promise((resolve) => {
+ settle = resolve;
+ }),
+ );
+ global.fetch = fetchMock as unknown as typeof global.fetch;
+
+ // Both callers start while the request is still in flight. Several tiles
+ // mounting at once is the normal case, so the second must join the pending
+ // promise rather than opening its own request.
+ const first = mod.loadLocaleArt("de");
+ const second = mod.loadLocaleArt("de");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+
+ settle!(
+ new Response(JSON.stringify({ [EN_ID]: DE_ID }), {
+ status: 200,
+ headers: { "Content-Type": "application/json" },
+ }),
+ );
+ const [mapA, mapB] = await Promise.all([first, second]);
+
+ // Same Map instance, so the body was parsed once and both callers observe
+ // one shared map rather than two equal copies.
+ expect(mapA).toBe(mapB);
+ expect(mapA.get(EN_ID)).toBe(DE_ID);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ // Reach guard: the deduped map is the one that actually got installed, so
+ // the identity assertions above are not describing a map nobody uses.
+ expect(mod.resolvePrintingImageUrl(printing(EN_ID), 0, "normal")).toBe(cardUrl(DE_ID));
+ });
+});
diff --git a/client/src/services/scryfall.ts b/client/src/services/scryfall.ts
index ade25cc394..a2264ef5d1 100644
--- a/client/src/services/scryfall.ts
+++ b/client/src/services/scryfall.ts
@@ -95,6 +95,71 @@ function loadTokenImagesData(): Promise {
return tokenImagesDataPromise;
}
+/**
+ * Per-locale card-art map: English Scryfall printing id → the same printing's
+ * localized sibling id (`scryfall-images..json`, generated by
+ * `scripts/gen-scryfall-locale-images.sh` from MTGJSON `foreignData`).
+ *
+ * Only one locale is resolved at a time — the UI renders in exactly one
+ * language — mirroring how `scryfallDataResolved` holds a single module-global
+ * map rather than threading data through every call site.
+ */
+let localeArtResolved: { lang: string; map: Map } | null = null;
+const localeArtPromises = new Map>>();
+/**
+ * The locale the app currently wants. Set synchronously on every request so a
+ * slow fetch for a language the user has already switched away from cannot
+ * install itself over the newer one (de → fr → de resolves out of order).
+ */
+let desiredArtLang = "en";
+
+/**
+ * True when `localizeImageUrl` already resolves in `lang` — i.e. the installed
+ * map matches it. English is ready only when *no* map is installed, because
+ * English is defined by the absence of one: reporting it ready unconditionally
+ * would let a de → en switch skip the load that clears the German map, leaving
+ * `localizeImageUrl` serving German art under an English key.
+ *
+ * This doubles as the caller's "do I need to load?" gate, so the two meanings
+ * must not diverge.
+ */
+export function isLocaleArtReady(lang: string): boolean {
+ return lang === "en" ? localeArtResolved === null : localeArtResolved?.lang === lang;
+}
+
+/**
+ * Load the card-art map for `lang`. English clears any resolved map and resolves
+ * immediately. A missing file (404 for a locale not yet published) resolves to an
+ * empty map, so every card falls back to English art — localized art is
+ * best-effort display data, never a hard dependency.
+ */
+export function loadLocaleArt(lang: string): Promise