Skip to content

feat(client): localize card art to the UI language - #7070

Merged
matthewevans merged 3 commits into
mainfrom
ship/localize-card-art
Aug 7, 2026
Merged

feat(client): localize card art to the UI language#7070
matthewevans merged 3 commits into
mainfrom
ship/localize-card-art

Conversation

@matthewevans

@matthewevans matthewevans commented Aug 7, 2026

Copy link
Copy Markdown
Member

Changing the UI language now swaps displayed card art to the same
printing in that language. The printing the user chose is preserved --
only its image is exchanged for its localized sibling -- and cards with
no sibling in that language keep their English art.

The localized printing id comes from MTGJSON foreignData[].identifiers .scryfallId, which we already download for the text sidecars, so this
needs no new upstream dependency. Scryfall image URLs are constructible
from the id alone, and the English id is already embedded in the URLs we
store, so the sidecar is a bare id->id map rather than a second copy of
the image data. English users download nothing extra.

The generator walks AllSetFiles one set at a time rather than parsing
AllPrintings whole: a whole-file jq parse peaks at ~3.5 GB RSS against
~95 MB per-set, a 36x reduction.

Localization is applied in localFaceImageUrl, the single funnel every
stored-URL path reaches, and composes with splitSizedImageUrl so the
card back and Scryfall's "image coming soon" placeholder come back
byte-identical -- isPlaceholderImageUrl compares with ===, so
rewriting them would silently disable the printing-fallback chain.

imageRequestCache keys on the art vocabulary a URL was produced with
(de vs de:pending) rather than the bare language, so the locale
map's arrival is a genuine key change; the printing-selection caches
stay language-neutral because which printing wins is a function of art
preferences, not language.

Known limitation: deck-builder search results hold URLs from search
time, so switching language mid-search leaves them English until the
next search.

Summary by CodeRabbit

  • New Features

    • Added localized card artwork for German, Spanish, French, Italian, and Portuguese.
    • Card images now automatically use available artwork for the selected language, with English artwork as a fallback.
    • Printing previews support localized artwork across card faces and image sizes.
  • Bug Fixes

    • Improved image loading when changing languages or switching cards.
    • Preserved card backs, placeholders, and fallback behavior when localized artwork is unavailable.

Changing the UI language now swaps displayed card art to the same
printing in that language. The printing the user chose is preserved --
only its image is exchanged for its localized sibling -- and cards with
no sibling in that language keep their English art.

The localized printing id comes from MTGJSON `foreignData[].identifiers
.scryfallId`, which we already download for the text sidecars, so this
needs no new upstream dependency. Scryfall image URLs are constructible
from the id alone, and the English id is already embedded in the URLs we
store, so the sidecar is a bare id->id map rather than a second copy of
the image data. English users download nothing extra.

The generator walks AllSetFiles one set at a time rather than parsing
AllPrintings whole: a whole-file jq parse peaks at ~3.5 GB RSS against
~95 MB per-set, a 36x reduction.

Localization is applied in `localFaceImageUrl`, the single funnel every
stored-URL path reaches, and composes with `splitSizedImageUrl` so the
card back and Scryfall's "image coming soon" placeholder come back
byte-identical -- `isPlaceholderImageUrl` compares with `===`, so
rewriting them would silently disable the printing-fallback chain.

`imageRequestCache` keys on the art vocabulary a URL was produced with
(`de` vs `de:pending`) rather than the bare language, so the locale
map's arrival is a genuine key change; the printing-selection caches
stay language-neutral because which printing wins is a function of art
preferences, not language.

Known limitation: deck-builder search results hold URLs from search
time, so switching language mid-search leaves them English until the
next search.
@matthewevans
matthewevans enabled auto-merge August 7, 2026 00:48
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@matthewevans, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 11 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6445f761-7a28-4e10-825b-7bca5f767e6d

📥 Commits

Reviewing files that changed from the base of the PR and between b6532db and 0d25d09.

📒 Files selected for processing (8)
  • .github/workflows/deploy.yml
  • .github/workflows/release.yml
  • client/src/components/deck-builder/PrintingPickerModal.tsx
  • client/src/components/deck-builder/__tests__/PrintingPickerModal.test.tsx
  • client/src/hooks/useCardImage.ts
  • client/src/services/__tests__/scryfall.test.ts
  • client/vite.config.ts
  • scripts/deploy-cf.sh
📝 Walkthrough

Walkthrough

The change adds locale-specific Scryfall artwork maps for five languages. Build and deployment scripts generate and publish the maps. Client image resolution loads locale data and applies localized artwork with English and placeholder fallbacks.

Changes

Localized card-art pipeline

Layer / File(s) Summary
Generate locale image maps
scripts/gen-scryfall-locale-images.sh, .github/workflows/*, scripts/setup.sh, .gitignore
The new generator builds locale maps from MTGJSON set files, validates sample Scryfall URLs, and runs during setup and release workflows. Generated files and extracted MTGJSON data are ignored.
Deliver and cache locale maps
client/vite.config.ts, client/vitest.config.ts, data-files.json, scripts/deploy-cf.sh
Build and deployment configuration defines locale map URLs, caches map files, uploads supported locales, and removes frontend copies before deployment.
Localize Scryfall URLs
client/src/services/scryfall.ts, client/src/services/__tests__/scryfall.test.ts
The service loads locale maps, tracks readiness, prevents stale updates, and substitutes localized IDs for supported image variants. Tests cover localized faces, fallbacks, placeholders, reset behavior, and missing files.
Integrate localized images
client/src/hooks/useCardImage.ts, client/src/hooks/__tests__/useCardImage.test.tsx, client/src/components/deck-builder/PrintingPickerModal.tsx
Image requests reload when locale art becomes ready. The printing picker uses the shared resolver and preserves its no-image fallback. Tests isolate locale loading in existing hook scenarios.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UserInterface
  participant useCardImage
  participant loadLocaleArt
  participant LocaleMap
  participant ScryfallResolver
  UserInterface->>useCardImage: Request card image
  useCardImage->>loadLocaleArt: Load active language
  loadLocaleArt->>LocaleMap: Fetch locale map
  useCardImage->>ScryfallResolver: Resolve image URL
  ScryfallResolver->>LocaleMap: Find localized printing ID
  ScryfallResolver-->>useCardImage: Return localized or fallback URL
  useCardImage-->>UserInterface: Render image
Loading

Possibly related PRs

Suggested labels: feature

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 72.73% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: localizing card art to match the selected UI language.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch ship/localize-card-art

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In @.github/workflows/release.yml:
- Around line 317-320: Move the Generate locale card-art maps step before the
MTGJSON cache save so AllSetFiles.tar and allsets/ are included, and roll the
mtgjson-full-* cache key to invalidate existing incomplete entries.
Alternatively, add a separate versioned cache covering those inputs while
preserving the existing generation flow.

In `@client/src/components/deck-builder/PrintingPickerModal.tsx`:
- Around line 135-140: Subscribe PrintingPickerModal to the locale-art readiness
signal used by useCardImage before calling resolvePrintingImageUrl, so map
completion triggers tile rerender and preserves the resolver’s locale-aware
behavior. Update or add colocated frontend coverage that changes language while
the locale map is pending and verifies tiles refresh after readiness completes.

In `@client/src/hooks/useCardImage.ts`:
- Around line 413-427: Move loadLocaleArtInBackground(language) out of render
and into a [language]-dependent effect that runs after the language subscription
is registered. Keep the existing artLocaleKey calculation and ensure loading
uses the committed preference language, preventing discarded renders from
updating desiredArtLang.

In `@client/src/services/__tests__/scryfall.test.ts`:
- Around line 1338-1469: Add a request-deduplication test in the localized card
art suite that keeps the mocked fetch pending, calls loadLocaleArt("de") twice
before resolving it, and verifies both calls share one fetch request. Resolve
the response, await both promises, and assert the resulting localization map is
installed and used by resolvePrintingImageUrl.

In `@client/vite.config.ts`:
- Around line 323-338: The locale-map RegExpRoute in the Workbox runtimeCaching
configuration must match both cross-origin R2 URLs and same-origin paths. Update
the urlPattern for the card-art locale-map rule to add the anchored
DATA_BASE_URL branch used by the engine WASM rule while retaining the existing
/scryfall-images.<lng>.json$ fallback.

In `@scripts/deploy-cf.sh`:
- Around line 49-53: Serialize updates to DEPLOY_CACHE in the surrounding
cache-update block: do not let the background upload workers concurrently write
or move DEPLOY_CACHE.tmp. Collect successful tags from the workers and merge
them after all uploads complete, or use one shared lock around each cache update
while preserving existing deployment behavior.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: b77c22e5-e182-4be6-9863-e673e368bfc8

📥 Commits

Reviewing files that changed from the base of the PR and between b4c2293 and b6532db.

⛔ Files ignored due to path filters (1)
  • client/src/vite-env.d.ts is excluded by !**/*.d.ts
📒 Files selected for processing (14)
  • .github/workflows/deploy.yml
  • .github/workflows/release.yml
  • .gitignore
  • client/src/components/deck-builder/PrintingPickerModal.tsx
  • client/src/hooks/__tests__/useCardImage.test.tsx
  • client/src/hooks/useCardImage.ts
  • client/src/services/__tests__/scryfall.test.ts
  • client/src/services/scryfall.ts
  • client/vite.config.ts
  • client/vitest.config.ts
  • data-files.json
  • scripts/deploy-cf.sh
  • scripts/gen-scryfall-locale-images.sh
  • scripts/setup.sh

Comment on lines +317 to +320
# 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.
- name: Generate locale card-art maps
run: ./scripts/gen-scryfall-locale-images.sh

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | ⚡ Quick win

Cache the AllSetFiles input after locale-map generation.

The mtgjson-full-* cache is saved before this step. It cannot contain AllSetFiles.tar or allsets/. Because GitHub Actions caches are immutable, later cache hits cannot add them. Each release then downloads and extracts the full MTGJSON archive again.

Generate the maps before saving the MTGJSON cache and roll the cache key, or add a separate versioned cache for the AllSetFiles inputs.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In @.github/workflows/release.yml around lines 317 - 320, Move the Generate
locale card-art maps step before the MTGJSON cache save so AllSetFiles.tar and
allsets/ are included, and roll the mtgjson-full-* cache key to invalidate
existing incomplete entries. Alternatively, add a separate versioned cache
covering those inputs while preserving the existing generation flow.

Comment thread client/src/components/deck-builder/PrintingPickerModal.tsx
Comment thread client/src/hooks/useCardImage.ts Outdated
Comment thread client/src/services/__tests__/scryfall.test.ts
Comment thread client/vite.config.ts
Comment thread scripts/deploy-cf.sh
@matthewevans
matthewevans disabled auto-merge August 7, 2026 01:04
Six findings from the PR review, all confirmed against the code first:

- Workbox never routed the locale maps in production. Workbox's
  RegExpRoute refuses a cross-origin match that does not begin at index 0
  of the href, and these are served from R2 — so the bare suffix pattern
  silently skipped the very requests the rule exists for. Now two
  anchored branches, mirroring the engine-WASM rule.

- CI re-downloaded the ~169 MB AllSetFiles archive every run: the
  MTGJSON cache is saved before the generator runs and GitHub caches are
  immutable. Adds a restore/save pair for the five generated maps,
  mirroring the draft-pools idiom — caching the small output rather than
  adding ~1 GB of extracted set files to a shared entry. The generator
  already no-ops when the maps exist, so a hit skips the download.

- deploy-cf.sh ran every upload in its own background subshell, each
  doing a read-modify-write through one shared "$DEPLOY_CACHE.tmp": two
  workers interleaving lose entries. Workers now record a private tag
  file and a single writer merges after the wait loop. Pre-existing, but
  five more entries made it materially more likely.

- The locale-art load ran during render, where it wrote module-global
  state that decides whose fetch may install. A discarded concurrent
  render could let an uncommitted language win that race; it now runs in
  a [language] effect placed after the invalidation subscription.

- PrintingPickerModal resolved tile URLs during render with no
  subscription, so opening it while the map was in flight left English
  art with no re-render on arrival. Adds `useLocaleArt`, which owns the
  load and the invalidation tick. useCardImage deliberately does not use
  it — it has an oracleId-filtered subscription, and an unfiltered second
  one per tile would resurrect the re-render storm that filter prevents.

- Adds the missing request-deduplication test, and a picker test that
  drives a mounted modal through pending -> resolved.
deploy.yml runs the locale-map generator in `card-data`, but `mtgjson-key`
is declared in `preview-inputs`. A `steps.` reference cannot cross a job
boundary and does not fail when it dangles — it expands to the empty
string, so the cache key collapsed to a constant and the maps would never
have invalidated when MTGJSON published new data.

release.yml was already correct: there the generator and the key step
share the `build-wasm` job.
@matthewevans

Copy link
Copy Markdown
Member Author

All six review findings verified against the code and fixed in b286d8d0 + 0d25d09c.

Finding Resolution
Workbox rule not anchored for R2 Confirmed. RegExpRoute rejects a cross-origin match that doesn't start at index 0, and these are R2-served in production — the bare suffix pattern never routed the requests the rule exists for. Now two anchored branches mirroring the engine-WASM rule.
AllSetFiles re-downloaded every run Confirmed. Added a restore/save pair for the generated maps, mirroring the draft-pools idiom. Caching the ~small output rather than the input: folding extracted set files into data/mtgjson would add ~1 GB to an entry shared by both workflows, and that cache is saved before this step anyway. The generator already no-ops when the maps exist, so a hit skips the download.
DEPLOY_CACHE concurrent writes Confirmed. Every upload runs in its own subshell doing a read-modify-write through one shared .tmp path. Workers now record private tag files; a single writer merges after the wait loop. Pre-existing, but five more entries made it materially likelier.
Locale load ran during render Confirmed. Moved into a [language] effect placed after the invalidation subscription.
PrintingPickerModal had no readiness subscription Confirmed. Added useLocaleArt(), which owns the load and the invalidation tick. useCardImage deliberately does not use it: it already has an oracleId-filtered subscription, and an unfiltered second one per tile would resurrect the unscoped re-render storm that filter exists to prevent.
Missing request-dedup test Added, plus a picker test driving a mounted modal through pending → resolved.

Two notes beyond the findings:

  1. Your deploy.yml suggestion surfaced a second bug in my own fix. mtgjson-key is declared in preview-inputs but the generator runs in card-data, and a steps. reference can't cross a job boundary — it expands to the empty string rather than failing, which would have frozen the cache key at a constant and served stale maps indefinitely. Fixed in 0d25d09c by deriving the key in-job via the same single-authority action.

  2. The anchoring defect is not unique to this rule: the shipped card-data\.[a-z]{2}\.json rule and the data-json alternation have the identical unanchored shape and are also R2-served, so neither is currently routed offline in production. I've left those alone — they're pre-existing and changing them alters offline behavior for card text, which deserves its own PR and verification rather than being smuggled into this one.

Verification: full frontend suite 2641 passed / 294 files, type-check and lint clean. The picker test was mutation-checked — removing useLocaleArt() turns it red on stale English art.

@matthewevans
matthewevans enabled auto-merge August 7, 2026 01:37
@matthewevans
matthewevans added this pull request to the merge queue Aug 7, 2026
Merged via the queue into main with commit d424bdf Aug 7, 2026
14 checks passed
@matthewevans
matthewevans deleted the ship/localize-card-art branch August 7, 2026 02:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant