Skip to content

Internationalization

Claude edited this page Oct 26, 2026 · 10 revisions

Internationalization

English · Español

The interface comes in 15 languages. The code is written in Spanish: every user-visible string is passed through t("<Spanish source>"), and each other language is a dictionary keyed by that same Spanish string. There is no runtime i18n library; the whole mechanism is the language table src/lib/idiomas.ts, src/components/language-provider.tsx, the pure functions in src/i18n/traducir.ts, the dictionaries in src/i18n/<code>/ (loaded by src/i18n/cargar.ts) and the CI-enforced check scripts/idiomas.mjs.

Three different "languages" (do not confuse them)

Name Where it lives What it controls
Interface language LanguageProvider, localStorage key proxybuilder.lang (any code of LANGS) All UI text, number/date formatting and Rust error messages as shown to the user.
Card language useUi().lang (src/stores/ui-store.ts), key inside proxybuilder.preferencias Which language of a card's printing to fetch from Scryfall. The options come from IDIOMAS_SCRYFALL (en es fr de it pt ja ko ru zhs zht, shown by their own names). Chosen in the decklist panel "Options" ("Card language").
Design language PrintSettings.designLang, DesignLang (Rust) Which language the card designer renders text in; see Designer Overview.

This page is mostly about the first. All three share the same codes on purpose (next section).

The language table: src/lib/idiomas.ts

One table serves the interface and the design languages:

Export Purpose
LANGS The 15 codes, in table order: es (source), en, fr, de, it, pt (Brazil), ro, pl, nl, ru, uk, ja, ko, zhs, zht. type Lang is derived from it (and re-exported by language-provider.tsx).
IDIOMAS One Idioma per code: codigo, nativo (the name in its own language, never translated, so it is recognisable in a picker), pais (ISO country of its flag), locale (BCP-47) and impresas (whether Scryfall has cards printed in it; false for ro, pl, nl, uk).
esLang(x) Type guard: is x one of the codes.
idioma(codigo) The Idioma of a code; an unknown code returns English.
localeDe(lang) The BCP-47 locale (es-ES, en-GB, pt-BR, zh-Hans-CN, zh-Hant-TW…).
IDIOMAS_SCRYFALL The 11 languages with Scryfall printings, in Scryfall's list order (English first), Portuguese named just "Português" as on the cards. Replaces three formerly hard-coded lists (decklist panel, variants filters, library).

Why Scryfall codes (pt, zhs, zht) and not BCP-47: the UI language, the design language of a card (DesignLang in Rust uses the same codes in the same order) and the language of a deck are then the same key everywhere, with no mapping table. BCP-47 lives only in locale, for Intl and <html lang>.

Default language policy

Decision: the factory interface language is English; another one is chosen in Settings and remembered. Implementation: leerIdioma() (exported from language-provider.tsx) returns the stored code when esLang accepts it, and "en" otherwise (missing, unknown, storage blocked). index.html ships <html lang="en">. The main README and the GitHub release notes are in English as well (README.es.md is the Spanish copy). Inferred: English is the default because the project targets a wider audience than its Spanish-speaking source code, while Spanish remains first-class because it is the language strings are authored in.

How t() works

src/components/language-provider.tsx exports:

Export Purpose
LanguageProvider Context provider; state lang.
useLang() Returns { lang, setLang, t, mensaje }. Throws if used outside the provider.
leerIdioma() The stored language, or "en". Also used by src/main.tsx.
paraTraducir(es) Identity function that marks a string "translate later, elsewhere". For text stored in data tables (e.g. MARCAS in src/lib/cartas.ts, FASES in src/features/decklist/acciones.ts, TEXTOS in src/lib/errores.ts) that is rendered later through t(variable). The check script sees paraTraducir("...") literals.
traduccionSuelta(es, vars?) t() without the provider (reads proxybuilder.lang directly; uses that language's dictionary if it is already loaded, otherwise English). Used only by LimiteError, which must wrap the provider so a crash inside the provider still shows a translated recovery screen.
type Lang Re-export of the type from src/lib/idiomas.ts.

t(es, vars) is traducir(es, lang, vars, dic, EN) from src/i18n/traducir.ts, a pure module (no React) so it can be tested alone:

  • Fallback chain: language → English → Spanish source. A key missing from, say, the Polish dictionary shows the English entry (someone using the app in Polish is more likely to understand English than Spanish); if English lacks it too, the Spanish source is shown. Never an empty string or a technical key. That safety net also hides missing translations while developing, which is exactly why the check script exists.
  • Plurals. A dictionary value is either a string or a Plural object (src/i18n/tipos.ts: zero?, one?, two?, few?, many?, other; Traduccion = string | Plural, Diccionario = Record<string, Traduccion>). The form is chosen from vars.n with Intl.PluralRules(locale) (cached per locale); a missing form, or an n that is not a finite number, uses other. When the value came from the English fallback, English plural rules choose the form.
  • Interpolation. \{(\w+)\} placeholders are replaced from vars; a placeholder with no value (or null/undefined) is left as {name} rather than printed as undefined. Number values are formatted with Intl.NumberFormat of the interface language (always, even when the text fell back to English or Spanish). Hence the rule: counts passed to t() are numbers, never pre-formatted with toLocaleString/toFixed.
  • Number, date, currency and relative-time formatting outside t() uses localeDe(lang): formatoMoneda in src/features/stats/textos.ts, dates in src/features/stats/tipos.tsx, dates/relative times/prices in src/features/library/piezas.tsx, the symbols date in src/features/disenar/recursos-dialog.tsx.
  • The t function is recreated only when lang changes (useMemo([lang])), so it is safe in dependency lists; components re-render on language change because the context value changes.

Switching language

setLang(l) stores the code in localStorage (ignoring storage errors: the language then lives only for this session), loads the dictionary first (cargarDiccionario) and only then switches the state. While it loads, the previous language stays on screen, so there is no flicker of half-translated text. A ref (pedido) remembers the last requested language: if two are requested in a row, the last one wins even if the earlier dictionary arrives later.

<html lang> is set to the full locale (localeDe(lang), e.g. ja-JP, zh-Hant-TW) and not just the code: WebKit uses it to pick the Japanese or Chinese glyph forms for the ideographs both scripts share.

Dictionaries and lazy loading

Each language has src/i18n/<code>/ with one JSON file per area (the same 14 areas as English). Spanish is the source, so es/ is laid out the same way but maps every string to itself: pnpm idiomas checks that each requested string has an entry there too, and the app never loads it. Each file is a flat object { "Spanish source": "translation", ... }, formatted by Biome with tab indentation. The dictionaries are data, not code: no TypeScript to get wrong, and the check script reads them without transpiling. There is no index.ts any more: cargar.ts merges every area file of a language, so a new area only needs its own file. Keys are always quoted, as JSON requires. Two things the TypeScript build used to reject now fall to pnpm idiomas: a key repeated inside one file (JSON.parse would silently keep the last one) and a key present in two areas.

File Area
comun.json Shared strings; starts with the ErrorCode messages from src/lib/errores.ts.
ajustes.json Settings dialog (appearance, updates, tweakcn themes).
shell.json Top bar, panel titles, save indicator, update button and toasts.
lista.json Decklist panel, resolve phases, card marks.
vista.json Preview pages, toolbar, card menu, "move to" dialog.
exportar.json Export panel, presets, alignment.
biblioteca.json Deck library.
cartas.json Add card, variants, card editor dialogs.
extras.json Statistics entry points and set browser.
vincular.json Link custom cards to Scryfall; wheel zoom; UI scale.
estadisticas.json Statistics dialog.
disenar.json The designer view (largest file).
tutorial.json The screen tutorials: help button, step counter and every step's title and text (Tutorials).
mesa.json The game table: entrance, board, menus, dialogs, shortcuts, the sentences of the game log and Settings > «Mesa de juego» (Game Table).

An optional src/i18n/<code>/identicas.json lists Spanish source keys whose translation is legitimately identical to English in that language (e.g. "Deck" in German); see the check script.

src/i18n/cargar.ts:

  • English is bundled in the main chunk (EN): it is the fallback of every language, so it must always be there.
  • The others are lazy: import.meta.glob(["./*/*.json", "!./en/*.json", "!./*/identicas.json"], { import: "default" }) makes one lazy module per area file. cargarDiccionario loads the files of the chosen language together and merges them, so startup does not carry 14 dictionaries.
  • cargarDiccionario(lang) loads a language once and caches it. Spanish has no dictionary (it is the source); a language without a dictionary, or whose chunk fails to load, resolves to undefined and its texts fall back to English.
  • diccionarioCargado(lang) returns the cached dictionary, if any.

src/main.tsx awaits cargarDiccionario(leerIdioma()) before mounting (.finally(() => montar(raiz)), so a failed load still mounts), so the app never shows for an instant in another language; the window is shown in parallel (ensenarVentana()).

Glossary and form of address

src/i18n/GLOSARIO.md (in Spanish) fixes the terminology per language so repeated terms read as on official cards and always the same:

  • In languages with printed cards (fr, de, it, pt, ru, ja, ko, zhs, zht) game terms are taken from real printed cards on Scryfall, with the reference card noted.
  • ro, pl, nl and uk have no printed cards: terms follow community usage and are marked as having no official source.
  • It lists what is never translated (product names, file formats, .pbdeck, keyboard shortcuts, {x} placeholders) and the form of address per language (e.g. vous in French, du in German, impersonal forms in Polish, です/ます in Japanese).

Adding or changing a string

  1. In the component, write the visible text through the hook: const { t } = useLang(); then t("Texto en español"). For text outside React, in a module-level table, wrap it in paraTraducir("...") and call t(value) where it is rendered.
  2. Add the pair to the dictionary of the matching area in every language (src/i18n/<code>/<area>.json), following src/i18n/GLOSARIO.md. Reuse an existing key when the Spanish text is identical and means the same thing.
  3. For a count, use {n} and pass a number; where the language has several plural categories, write a Plural object with exactly that language's categories (Russian one/few/many/other, Japanese only other…).
  4. Run pnpm idiomas (also part of pnpm check, the pre-commit hook for staged .ts/.tsx, and CI).
  5. Use {name} placeholders for dynamic parts (t("Descargando la {version}…", { version })); never concatenate translated fragments.

Disambiguation rule: if the same Spanish word needs two different translations in different places, make the Spanish source of the less generic case longer (e.g. add context words). Never introduce a second dictionary or a context argument.

If you change an existing Spanish source string, change its key in every dictionary too; otherwise the old entries become unused (a warning) and the new string is reported as missing (an error).

scripts/idiomas.mjs (pnpm idiomas)

What it does:

  1. Requested strings (leerPedidas). Walks src/ (skipping node_modules, dot-dirs, bindings, src/i18n/ and *.test.ts(x)) and collects every double-quoted literal in t("...") and paraTraducir("...") (first argument; \" and \n unescaped), with the file where each was first seen.
  2. Languages. Loads src/lib/idiomas.ts (leerTabla), which is code, so it is still transpiled with typescript and imported. Then it reads each dictionary directory (leerDiccionarios; directories starting with _ are skipped): every <area>.json is parsed with JSON.parse, and its raw text is scanned for keys repeated at the top level of the same file, which JSON.parse would otherwise drop without a trace.
  3. Errors (exit status 1):
    • a language of the table with no directory src/i18n/<code>/, or a directory that is no language of the table;
    • a requested string missing from the merged dictionary;
    • a key present in two areas, or repeated inside one area file;
    • an English area file missing in the language;
    • placeholders {x} different from those of the Spanish key (also checked in each plural form);
    • a plural object on a key without {n}, a category the language does not have, or a missing category (the language's categories are Intl.PluralRules(locale).resolvedOptions().pluralCategories).
  4. Warnings (do not fail): keys no longer requested, and values identical to English (only when they contain letters and are not an allowed exception: the global list IDENTICAS_PERMITIDAS in the script, or the language's identicas.json).
  5. Prints one line per language (✓ <code>: N cadenas plus the warning count, or ✖ <code>: … followed by each error). --detalle also lists each warning.

leerPedidas, leerTabla, leerDiccionarios and comprobar are exported for the tests in scripts/__tests__/idiomas.test.mjs.

Limits to keep in mind:

  • Only double-quoted literals directly inside t( / paraTraducir( are seen. t(variable), single-quoted strings, template literals, or concatenations are invisible to the check, so route such text through paraTraducir("...") at its definition.
  • Spanish text written in JSX without t() is not detected at all.
  • .test.ts(x) files are excluded.
  • A wrong but well-formed translation is not detectable; the identical-to-English warning only catches untranslated copies.

Errors: useLang().mensaje(e)

Rust never writes user-facing prose. Commands return Result<T, AppError> where AppError = { code: ErrorCode, params: { [key]: string } } (src/bindings/AppError.ts, ErrorCode.ts; see IPC and Error Handling). On the screen:

// src/lib/errores.ts
export function mensajeDeError(e: unknown, t): string {
	if (esAppError(e)) {
		const clave = e.params.motivo ?? e.params.code;
		const propio = clave ? MOTIVOS[e.code]?.[clave] : undefined;
		return t(propio ?? TEXTOS[e.code], e.params);
	}
	if (e instanceof Error) return e.message;
	return String(e);
}

TEXTOS has one Spanish sentence per ErrorCode (Network, RateLimited, NotFound, Io, Db, InvalidInput, Cancelled, Pdf, Image, Unsupported, Busy, Internal, Rejected, Server), each wrapped in paraTraducir. MOTIVOS adds sentences of their own for known reasons of a code: the game table's rejections (Rejected with params.motivo = NoExiste, SinPermiso, ZonaVacia, FueraDeTurno, Limite, NoSePuede) and the update that waits for a game (Busy with motivo: partida); an unknown reason uses the code's general sentence. esAppError accepts any object whose code is a key of TEXTOS, so adding an ErrorCode in Rust forces a matching entry (the Record<ErrorCode, string> type fails tsc otherwise). Components call toast.error(mensaje(e)).

Special cases are handled by the component that knows the context, using params: for example TemasTweakcn (src/features/settings/temas-tweakcn.tsx) checks e.params.motivo === "tema_enlace" to show "That is not a tweakcn theme link or id." instead of the generic invalid-input message. SaveIndicator shows mensaje(error) in a tooltip when autosave fails.

The game table log follows the same rule: each engine event reaches the screen as data and src/features/mesa/textos-eventos.ts writes its sentence with t() (FRASES, one per Accion, plus the special changes), from the viewer's side so hidden cards are never named (Game Table).

Rust-provided data that is displayed (Scryfall names, oracle text, release notes) is shown as-is: release notes in the update panel are English Markdown from the GitHub release. Designer warnings (Aviso) carry a kind and parameters (some a DesignLang); the screen words them and names a language by its own name.

Language picker and flags

SelectorIdioma (src/components/selector-idioma.tsx) is a Select listing languages with their flag and native name (each item gets lang={locale}, so its name is drawn with the right glyphs). It is used for the interface language in SettingsDialog (src/features/settings/settings-dialog.tsx, calls setLang) and for the design language in the export panel and the Diseñar preview (see Designer UI). The change reaches all mounted components as soon as the dictionary has loaded; there is no reload.

Flags are drawn by <Bandera pais> from src/components/emoji.tsx with Noto Color Emoji images bundled in src/assets/emoji/, not with the system emoji font: Windows' Segoe UI Emoji has no flags and WebKit on macOS does not paint COLRv1 fonts. See Frontend Architecture.

Tests and guards

  • pnpm idiomas / scripts/idiomas.mjs guards completeness, placeholders and plural forms. Its logic is tested in scripts/__tests__/idiomas.test.mjs: a complete dictionary passes; a missing key, a changed placeholder and a missing plural form fail; a plural without {n} or with a category the language lacks fails; unused and identical-to-English keys only warn; a missing area file fails, and so does a key repeated inside one file.
  • src/i18n/__tests__/traducir.test.ts: fallback language → English → Spanish, the Russian plural chosen by n, a non-number n uses other, numbers formatted per language, and cargarDiccionario (English present, Spanish has none, a language without dictionary does not throw).
  • src/lib/__tests__/idiomas.test.ts: the table (15 codes without repeats in LANGS order, canonical BCP-47 locales, IDIOMAS_SCRYFALL is the 11 printed ones, esLang and the English fallback of idioma).
  • src/lib/__tests__/errores.test.ts covers esAppError and mensajeDeError.
  • Not covered: the quality of translations (reviewed against GLOSARIO.md).

Where to start when extending

  • Add a UI language: add it to LANGS and IDIOMAS in src/lib/idiomas.ts; create src/i18n/<code>/ with one JSON file per area (the 14 English files, translated); add its terms and form of address to src/i18n/GLOSARIO.md; run node scripts/emoji-noto.mjs to copy its flag; run pnpm idiomas. If it should also be a design language, DesignLang in Rust must gain the same code at the same position (see Designer Engine). Inferred: nothing else needs to change on the screen, because every consumer iterates the table.
  • Translating a new feature: see "Adding or changing a string" above.

See also

Documentation · Documentación

Language: English · Español

Use and build · Uso y desarrollo

Architecture · Arquitectura

Data · Datos

Getting cards · Conseguir cartas

Layout, images, PDF · Maquetación, imagen y PDF

Card designer · Diseñador de cartas

Game table · Mesa de juego

Frontend · Frontend

Reference · Referencia

Clone this wiki locally