diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -9,7 +9,7 @@ description = "VectorCraft egui frontend (menus, panels, tools and canvas); swap
 workspace = true
 
 [features]
-default = ["bundled-fonts", "interface-fonts"]
+default = ["bundled-fonts", "interface-fonts", "translations"]
 # The fonts vectorcraft-text bundles for documents, through the engine's `bundled-fonts`. Off, for
 # an embedder that supplies every document font at run time (`vectorcraft_text::FontDb::add_font`);
 # the interface's own faces are `interface-fonts`.
@@ -19,6 +19,35 @@ bundled-fonts = ["vectorcraft-engine/bundled-fonts"]
 # `theme::install_fonts` binds their families either way.
 interface-fonts = []
 
+# The interface's translations (`src/i18n/*.tsv`, about 6.6 MB of the binary), one feature per
+# language (`lang-<code>`, the catalog's file name); `translations` turns on every one. A language
+# whose feature is off is left out and not offered; with none on, the interface is English only.
+translations = ["lang-bg", "lang-cs", "lang-da", "lang-de", "lang-el", "lang-es", "lang-fi", "lang-fr", "lang-hu", "lang-id", "lang-it", "lang-ja", "lang-ka", "lang-ko", "lang-nl", "lang-pl", "lang-pt-br", "lang-ru", "lang-sv", "lang-tr", "lang-uk", "lang-vi", "lang-zh-hans", "lang-zh-hant"]
+lang-bg = []
+lang-cs = []
+lang-da = []
+lang-de = []
+lang-el = []
+lang-es = []
+lang-fi = []
+lang-fr = []
+lang-hu = []
+lang-id = []
+lang-it = []
+lang-ja = []
+lang-ka = []
+lang-ko = []
+lang-nl = []
+lang-pl = []
+lang-pt-br = []
+lang-ru = []
+lang-sv = []
+lang-tr = []
+lang-uk = []
+lang-vi = []
+lang-zh-hans = []
+lang-zh-hant = []
+
 [dependencies]
 craft-ui = { workspace = true }
 craft-keymap = { workspace = true, features = ["egui"] }
@@ -72,6 +101,8 @@ vectorcraft-testkit = { workspace = true }
 # A variable font built in code (no font file is committed).
 vectorcraft-text = { workspace = true, features = ["test-fonts"] }
 wat = { workspace = true }
+# i18n::tests_compiled_in reads this manifest's language features (already a build-dependency).
+toml = "1"
 
 [build-dependencies]
 serde_json = { workspace = true }
diff --git a/crates/ui-egui/src/chrome/tests.rs b/crates/ui-egui/src/chrome/tests.rs
--- a/crates/ui-egui/src/chrome/tests.rs
+++ b/crates/ui-egui/src/chrome/tests.rs
@@ -117,6 +117,8 @@ fn status_width_measurements_are_reused_across_frames() {
 }
 
 /// Each independent cache-key component refreshes widths, then subsequent frames reuse them.
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn status_width_measurements_refresh_for_language_units_and_dpi() {
     let (mut app, ctx) = status_readout_app();
diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs
--- a/crates/ui-egui/src/dialogs/tests.rs
+++ b/crates/ui-egui/src/dialogs/tests.rs
@@ -518,6 +518,8 @@ fn menu_parameter_dialogs_are_compact() {
 
 /// A list mixing built-in labels with names translates only the built-in entries: names that
 /// happen to be catalog keys ("Black", "Regular") are shown as they are.
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn mixed_lists_translate_only_their_built_in_entries() {
     use crate::i18n::{Lang, tr};
diff --git a/crates/ui-egui/src/dialogs/tests_modal.rs b/crates/ui-egui/src/dialogs/tests_modal.rs
--- a/crates/ui-egui/src/dialogs/tests_modal.rs
+++ b/crates/ui-egui/src/dialogs/tests_modal.rs
@@ -696,6 +696,8 @@ fn simplify_dialog_show_original_path_outlines_the_paths_before_the_preview() {
     assert!(super::path_ops::original_paths(&app).is_empty());
 }
 
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn printer_mark_labels_are_distinct_from_language_names() {
     let cs = crate::i18n::Lang::from_code("cs").unwrap();
diff --git a/crates/ui-egui/src/font_menu/tests.rs b/crates/ui-egui/src/font_menu/tests.rs
--- a/crates/ui-egui/src/font_menu/tests.rs
+++ b/crates/ui-egui/src/font_menu/tests.rs
@@ -1,5 +1,7 @@
 /// The script filter: Thai lists families with Thai; Latin, those with neither Thai nor
 /// Japanese.
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn the_thai_filter_lists_fonts_with_thai() {
     use vectorcraft_text::FontTraits;
@@ -226,6 +228,8 @@ fn expanded_style_rows_do_not_retarget_the_highlight() {
 }
 
 /// The Font Technology filter's Variable is the font adjective, not the Variables panel's noun.
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn the_variable_filter_is_named_as_fonts_are() {
     use crate::i18n::{Lang, tr, tr_ctx};
diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs
--- a/crates/ui-egui/src/i18n.rs
+++ b/crates/ui-egui/src/i18n.rs
@@ -7,7 +7,10 @@
 //! # Adding a language
 //! 1. Add `xx.tsv` next to `zh-hant.tsv` (copy its header; translate from the *meaning* of the
 //!    English text, clean-room, see `zh-hant.tsv`).
-//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule).
+//! 2. Add one row to [`LANGUAGES`] (code, native name, catalog, plural rule), its catalog as
+//!    `catalog_tsv!("i18n/xx.tsv", "lang-xx")`, and the feature `lang-xx` to `Cargo.toml`, listed in
+//!    `translations`, so an embedder may leave it out, and to `FEATURE_ON` in
+//!    `tests_compiled_in.rs`.
 //!
 //! That is all: the Preferences dropdown, the system-locale match, the catalog tests (parse,
 //! placeholders, plural forms) and the untranslated report pick it up from the registry.
@@ -26,6 +29,18 @@
 
 mod catalog;
 
+/// A language's catalog, compiled in with its feature (`lang-<code>`; `translations` turns on every
+/// one). Without it the catalog is empty, and [`Lang::all`] and [`Lang::from_code`] skip the language.
+macro_rules! catalog_tsv {
+    ($file:literal, $feature:literal) => {{
+        #[cfg(feature = $feature)]
+        let source = include_str!($file);
+        #[cfg(not(feature = $feature))]
+        let source = "";
+        source
+    }};
+}
+
 use std::sync::OnceLock;
 use std::sync::atomic::{AtomicUsize, Ordering};
 
@@ -37,11 +52,13 @@ pub struct LangInfo {
     pub code: &'static str,
     /// The language's name in itself, shown in the Preferences dropdown.
     pub name: &'static str,
-    /// Catalog file contents (empty for the built-in English).
+    /// Catalog file contents: empty for the built-in English and for a language whose feature
+    /// (`lang-<code>`) is off, which the interface then doesn't offer.
     pub source: &'static str,
     /// Plural form index for a count (English: 0 = one, 1 = other; Chinese: always 0).
     pub plural: fn(u64) -> usize,
-    /// Does the catalog aim to cover every menu string? The untranslated report lists what it lacks.
+    /// Does the catalog aim to cover every menu string? The untranslated report lists what it lacks
+    /// ([`Lang::complete_menus`] is false for a language left out).
     pub complete_menus: bool,
     catalog: OnceLock<Catalog>,
 }
@@ -93,7 +110,10 @@ fn plural_polish(n: u64) -> usize {
     }
 }
 
-/// The registry. English first: it is the fallback and the source language.
+/// The registry. English first: it is the fallback and the source language. Every registered
+/// language is here, one left out of the build too (its `source` empty, its `complete_menus` as
+/// registered): [`Lang::all`] lists the languages the build offers, and [`Lang::complete_menus`]
+/// answers for the build.
 pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
     // Greek: the whole interface and the status and error messages, keeping the same names in
@@ -101,7 +121,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "el",
         name: "Ελληνικά",
-        source: include_str!("i18n/el.tsv"),
+        source: catalog_tsv!("i18n/el.tsv", "lang-el"),
         plural: plural_one_other,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -109,20 +129,30 @@ pub static LANGUAGES: [LangInfo; 25] = [
     // Japanese: the whole interface (every menu string and `tl!` literal) and the status and error
     // messages, keeping the product, workspace and perspective preset names in English (`MENU_KEEP_AS_IS`).
     LangInfo {
-        code: "ja", name: "日本語", source: include_str!("i18n/ja.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new()
+        code: "ja",
+        name: "日本語",
+        source: catalog_tsv!("i18n/ja.tsv", "lang-ja"),
+        plural: plural_none,
+        complete_menus: true,
+        catalog: OnceLock::new(),
     },
     // Korean: the whole interface and the status and error messages, keeping the same names in
     // English as Japanese; a count takes no plural, so plurals have one form. Hangul is drawn with an
     // installed system font (craft-fonts has no Korean UI face yet); every `ko-*` locale resolves here.
     LangInfo {
-        code: "ko", name: "한국어", source: include_str!("i18n/ko.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new()
+        code: "ko",
+        name: "한국어",
+        source: catalog_tsv!("i18n/ko.tsv", "lang-ko"),
+        plural: plural_none,
+        complete_menus: true,
+        catalog: OnceLock::new(),
     },
     // Vietnamese: complete menus and messages, with one plural form.
     // Every `vi-*` locale resolves here.
     LangInfo {
         code: "vi",
         name: "Tiếng Việt",
-        source: include_str!("i18n/vi.tsv"),
+        source: catalog_tsv!("i18n/vi.tsv", "lang-vi"),
         plural: plural_none,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -132,7 +162,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "cs",
         name: "Čeština",
-        source: include_str!("i18n/cs.tsv"),
+        source: catalog_tsv!("i18n/cs.tsv", "lang-cs"),
         plural: plural_czech,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -142,7 +172,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "de",
         name: "Deutsch",
-        source: include_str!("i18n/de.tsv"),
+        source: catalog_tsv!("i18n/de.tsv", "lang-de"),
         plural: plural_one_other,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -152,7 +182,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "es",
         name: "Español",
-        source: include_str!("i18n/es.tsv"),
+        source: catalog_tsv!("i18n/es.tsv", "lang-es"),
         plural: plural_one_other,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -162,7 +192,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "fr",
         name: "Français",
-        source: include_str!("i18n/fr.tsv"),
+        source: catalog_tsv!("i18n/fr.tsv", "lang-fr"),
         plural: plural_french,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -172,7 +202,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "it",
         name: "Italiano",
-        source: include_str!("i18n/it.tsv"),
+        source: catalog_tsv!("i18n/it.tsv", "lang-it"),
         plural: plural_one_other,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -183,7 +213,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "ka",
         name: "ქართული",
-        source: include_str!("i18n/ka.tsv"),
+        source: catalog_tsv!("i18n/ka.tsv", "lang-ka"),
         plural: plural_none,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -193,7 +223,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "ru",
         name: "Русский",
-        source: include_str!("i18n/ru.tsv"),
+        source: catalog_tsv!("i18n/ru.tsv", "lang-ru"),
         plural: plural_east_slavic,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -203,7 +233,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "uk",
         name: "Українська",
-        source: include_str!("i18n/uk.tsv"),
+        source: catalog_tsv!("i18n/uk.tsv", "lang-uk"),
         plural: plural_east_slavic,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -213,7 +243,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "bg",
         name: "Български",
-        source: include_str!("i18n/bg.tsv"),
+        source: catalog_tsv!("i18n/bg.tsv", "lang-bg"),
         plural: plural_one_other,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -221,14 +251,21 @@ pub static LANGUAGES: [LangInfo; 25] = [
     // Hungarian: the whole interface and the status and error messages, keeping the same names in
     // English as Spanish; a count takes the singular ("3 réteg"), so plurals have one form. Every
     // `hu-*` locale (including `hu-HU`) resolves here.
-    LangInfo { code: "hu", name: "Magyar", source: include_str!("i18n/hu.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new() },
+    LangInfo {
+        code: "hu",
+        name: "Magyar",
+        source: catalog_tsv!("i18n/hu.tsv", "lang-hu"),
+        plural: plural_none,
+        complete_menus: true,
+        catalog: OnceLock::new(),
+    },
     // Polish: the whole interface and the status and error messages, with one/few/many plurals,
     // keeping the same names in English as Spanish; every `pl-*` locale (including `pl-PL`)
     // resolves here.
     LangInfo {
         code: "pl",
         name: "Polski",
-        source: include_str!("i18n/pl.tsv"),
+        source: catalog_tsv!("i18n/pl.tsv", "lang-pl"),
         plural: plural_polish,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -238,7 +275,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "sv",
         name: "Svenska",
-        source: include_str!("i18n/sv.tsv"),
+        source: catalog_tsv!("i18n/sv.tsv", "lang-sv"),
         plural: plural_one_other,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -249,7 +286,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "pt-br",
         name: "Português (Brasil)",
-        source: include_str!("i18n/pt-br.tsv"),
+        source: catalog_tsv!("i18n/pt-br.tsv", "lang-pt-br"),
         plural: plural_one_other,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -259,7 +296,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "zh-hant",
         name: "繁體中文",
-        source: include_str!("i18n/zh-hant.tsv"),
+        source: catalog_tsv!("i18n/zh-hant.tsv", "lang-zh-hant"),
         plural: plural_none,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -269,7 +306,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "zh-hans",
         name: "简体中文",
-        source: include_str!("i18n/zh-hans.tsv"),
+        source: catalog_tsv!("i18n/zh-hans.tsv", "lang-zh-hans"),
         plural: plural_none,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -277,7 +314,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "nl",
         name: "Nederlands",
-        source: include_str!("i18n/nl.tsv"),
+        source: catalog_tsv!("i18n/nl.tsv", "lang-nl"),
         plural: plural_one_other,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -286,7 +323,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "da",
         name: "Dansk",
-        source: include_str!("i18n/da.tsv"),
+        source: catalog_tsv!("i18n/da.tsv", "lang-da"),
         plural: plural_one_other,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -296,7 +333,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "id",
         name: "Bahasa Indonesia",
-        source: include_str!("i18n/id.tsv"),
+        source: catalog_tsv!("i18n/id.tsv", "lang-id"),
         plural: plural_none,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -307,7 +344,7 @@ pub static LANGUAGES: [LangInfo; 25] = [
     LangInfo {
         code: "fi",
         name: "Suomi",
-        source: include_str!("i18n/fi.tsv"),
+        source: catalog_tsv!("i18n/fi.tsv", "lang-fi"),
         plural: plural_one_other,
         complete_menus: true,
         catalog: OnceLock::new(),
@@ -316,16 +353,43 @@ pub static LANGUAGES: [LangInfo; 25] = [
     // English as Spanish; a count takes the singular ("3 katman"), so plurals have one form. Every
     // `tr-*` locale (including `tr-TR` and `tr-CY`) resolves here.
     LangInfo {
-        code: "tr", name: "Türkçe", source: include_str!("i18n/tr.tsv"), plural: plural_none, complete_menus: true, catalog: OnceLock::new()
+        code: "tr",
+        name: "Türkçe",
+        source: catalog_tsv!("i18n/tr.tsv", "lang-tr"),
+        plural: plural_none,
+        complete_menus: true,
+        catalog: OnceLock::new(),
     },
 ];
 
+/// Is the language `code`, whose catalog as compiled in is `source`, offered? English, the source
+/// language, always is; another only when its feature compiled its catalog in.
+fn offered(code: &str, source: &str) -> bool {
+    code == "en" || !source.is_empty()
+}
+
 impl LangInfo {
+    /// Is this language's catalog in the binary? English (the source language) always is; another
+    /// only with its feature (`lang-<code>`).
+    fn compiled_in(&self) -> bool {
+        offered(self.code, self.source)
+    }
+
     fn catalog(&self) -> &Catalog {
         self.catalog.get_or_init(|| Catalog::parse(self.source))
     }
 }
 
+/// The languages of `table` the interface offers ([`Lang::all`] over [`LANGUAGES`]).
+fn all_in(table: &'static [LangInfo]) -> impl Iterator<Item = Lang> {
+    table.iter().filter(|l| l.compiled_in()).map(Lang)
+}
+
+/// An offered language of `table` by its exact code ([`Lang::from_code`] over [`LANGUAGES`]).
+fn from_code_in(table: &'static [LangInfo], code: &str) -> Option<Lang> {
+    all_in(table).find(|l| l.code().eq_ignore_ascii_case(code))
+}
+
 /// A language the UI can be shown in (a handle into [`LANGUAGES`]).
 #[derive(Clone, Copy)]
 pub struct Lang(&'static LangInfo);
@@ -353,7 +417,7 @@ impl Lang {
 
     /// A language by its exact code.
     pub fn from_code(code: &str) -> Option<Lang> {
-        LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
+        from_code_in(&LANGUAGES, code)
     }
 
     /// Resolve the `interfaceLanguage` preference: a language code, or `auto` (and anything
@@ -362,9 +426,10 @@ impl Lang {
         Lang::from_code(pref).unwrap_or_else(system_lang)
     }
 
-    /// Every registered language.
+    /// Every language the interface offers: English and each language whose feature
+    /// (`lang-<code>`) compiled its catalog in; every registered one with `translations`.
     pub fn all() -> impl Iterator<Item = Lang> {
-        LANGUAGES.iter().map(Lang)
+        all_in(&LANGUAGES)
     }
 
     /// Every registered language in the order a picker lists them: English first (the source
@@ -381,9 +446,10 @@ impl Lang {
         self.0.name
     }
 
-    /// Does this language's catalog claim to cover every menu string and `tl!` literal?
+    /// Does this language's catalog claim to cover every menu string and `tl!` literal? Never
+    /// for a language whose catalog is left out (its `lang-<code>` feature off).
     pub fn complete_menus(self) -> bool {
-        self.0.complete_menus
+        self.0.complete_menus && self.0.compiled_in()
     }
 
     fn catalog(self) -> &'static Catalog {
@@ -412,11 +478,21 @@ fn candidates(tag: &str) -> Vec<String> {
 /// The registered language for a locale tag such as `ja_JP.UTF-8`, `ja-JP`, `zh-TW`; `None` if
 /// the language isn't supported. `C`/`POSIX` mean English.
 pub fn lang_from_tag(tag: &str) -> Option<Lang> {
+    lang_from_tag_in(&LANGUAGES, tag)
+}
+
+/// [`lang_from_tag`] among the offered languages of `table`.
+fn lang_from_tag_in(table: &'static [LangInfo], tag: &str) -> Option<Lang> {
     let cands = candidates(tag);
     if matches!(cands.first().map(String::as_str), Some("c" | "posix")) {
         return Some(Lang::EN);
     }
-    cands.iter().find_map(|c| Lang::from_code(c))
+    cands.iter().find_map(|c| from_code_in(table, c))
+}
+
+/// The first of the system's preferred languages `tags` that `table` offers, else English.
+fn preferred_in<S: AsRef<str>>(table: &'static [LangInfo], tags: &[S]) -> Lang {
+    tags.iter().find_map(|t| lang_from_tag_in(table, t.as_ref())).unwrap_or(Lang::EN)
 }
 
 /// True when `tag` names the generic `C` / `POSIX` locale (or a variant like `C.UTF-8`), which on
@@ -457,7 +533,7 @@ pub fn system_lang() -> Lang {
 /// the system language (English if none is); call it before the first frame. Has no effect once
 /// the system language is known.
 pub fn set_system_locales<S: AsRef<str>>(tags: &[S]) {
-    let lang = tags.iter().find_map(|t| lang_from_tag(t.as_ref())).unwrap_or(Lang::EN);
+    let lang = preferred_in(&LANGUAGES, tags);
     // Already set means already detected (or reported): the first answer stands.
     let _ = SYSTEM.set(lang);
 }
@@ -661,6 +737,8 @@ pub fn tn(n: u64, one: &str, other: &str) -> String {
 
 #[cfg(test)]
 mod tests;
+#[cfg(test)]
+mod tests_compiled_in;
 
 /// `name` lowercased with the Latin accents that occur in language names folded away, so a
 /// byte-wise sort reads alphabetically. Other scripts are left as they are.
diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs
--- a/crates/ui-egui/src/i18n/catalog.rs
+++ b/crates/ui-egui/src/i18n/catalog.rs
@@ -209,8 +209,8 @@ impl Catalog {
     }
 }
 
-/// `{name}` placeholders of a template, in order of appearance.
-#[cfg(test)]
+/// `{name}` placeholders of a template, in order of appearance (for the catalog tests).
+#[cfg(all(test, feature = "translations"))]
 pub fn placeholders(s: &str) -> Vec<&str> {
     let mut out = Vec::new();
     let mut rest = s;
diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs
--- a/crates/ui-egui/src/i18n/tests.rs
+++ b/crates/ui-egui/src/i18n/tests.rs
@@ -1,3 +1,6 @@
+// The catalog tests read the translations, which are compiled in with the `translations` feature.
+#![cfg(feature = "translations")]
+
 use super::catalog::{Catalog, parse_entries, placeholders};
 use super::*;
 
diff --git a/crates/ui-egui/src/i18n/tests_compiled_in.rs b/crates/ui-egui/src/i18n/tests_compiled_in.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/i18n/tests_compiled_in.rs
@@ -0,0 +1,129 @@
+//! The languages the interface offers, whichever language features (`lang-<code>`, all of them with
+//! `translations`) the build turns on: a left-out language through a registry of the tests' own,
+//! so the default test run covers it, and each catalog's feature in the manifest.
+
+use super::*;
+
+/// A registry with Japanese compiled in and Korean left out (its catalog empty, as its feature off
+/// leaves it).
+static JA_NOT_KO: [LangInfo; 3] = [
+    LangInfo { code: "en", name: "English", source: "", plural: plural_one_other, complete_menus: false, catalog: OnceLock::new() },
+    LangInfo { code: "ja", name: "日本語", source: "# catalog", plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
+    LangInfo { code: "ko", name: "한국어", source: "", plural: plural_one_other, complete_menus: true, catalog: OnceLock::new() },
+];
+
+/// A language left out of the build is not offered, not found by its code or a locale, and
+/// claims no complete menus; a system language list skips it for the next one compiled in, and
+/// with none, English. English and a compiled-in language are offered.
+#[test]
+fn a_left_out_language_is_neither_offered_nor_found() {
+    let ja = Lang(&JA_NOT_KO[1]);
+    let ko = Lang(&JA_NOT_KO[2]);
+    assert_eq!(all_in(&JA_NOT_KO).collect::<Vec<_>>(), [Lang::EN, ja]);
+    assert_eq!(from_code_in(&JA_NOT_KO, "JA"), Some(ja));
+    assert_eq!(from_code_in(&JA_NOT_KO, "ko"), None);
+    assert_eq!(lang_from_tag_in(&JA_NOT_KO, "ja_JP.UTF-8"), Some(ja));
+    assert_eq!(lang_from_tag_in(&JA_NOT_KO, "ko_KR.UTF-8"), None);
+    assert_eq!(preferred_in(&JA_NOT_KO, &["ko-KR", "ja-JP", "en-US"]), ja);
+    assert_eq!(preferred_in(&JA_NOT_KO, &["ko-KR"]), Lang::EN);
+    assert!(ja.complete_menus() && !ko.complete_menus());
+}
+
+/// English is always offered, and with `translations` (the apps' default) every registered
+/// language is.
+#[test]
+fn english_always_and_with_translations_every_language_is_offered() {
+    assert_eq!(Lang::from_code("en"), Some(Lang::EN));
+    if cfg!(feature = "translations") {
+        assert_eq!(Lang::all().count(), LANGUAGES.len());
+        assert!(Lang::from_code("ja").is_some_and(Lang::complete_menus));
+    }
+}
+
+/// A catalog whose feature is off is empty, and one whose feature is on is its file: `lang-left-out`
+/// is a feature no build has, and `translations` turns on `lang-el`.
+#[test]
+#[allow(unexpected_cfgs)]
+fn a_catalog_is_its_file_only_with_its_feature() {
+    assert_eq!(catalog_tsv!("el.tsv", "lang-left-out"), "");
+    if cfg!(feature = "lang-el") {
+        assert_eq!(catalog_tsv!("el.tsv", "lang-el"), include_str!("el.tsv"));
+    }
+}
+
+/// Whether each language's feature is on in this build, in the registry's order. `cfg!` takes a
+/// literal feature name, so each language has its row here; the test below checks the list
+/// against the registry.
+const FEATURE_ON: [(&str, bool); 24] = [
+    ("el", cfg!(feature = "lang-el")),
+    ("ja", cfg!(feature = "lang-ja")),
+    ("ko", cfg!(feature = "lang-ko")),
+    ("vi", cfg!(feature = "lang-vi")),
+    ("cs", cfg!(feature = "lang-cs")),
+    ("de", cfg!(feature = "lang-de")),
+    ("es", cfg!(feature = "lang-es")),
+    ("fr", cfg!(feature = "lang-fr")),
+    ("it", cfg!(feature = "lang-it")),
+    ("ka", cfg!(feature = "lang-ka")),
+    ("ru", cfg!(feature = "lang-ru")),
+    ("uk", cfg!(feature = "lang-uk")),
+    ("bg", cfg!(feature = "lang-bg")),
+    ("hu", cfg!(feature = "lang-hu")),
+    ("pl", cfg!(feature = "lang-pl")),
+    ("sv", cfg!(feature = "lang-sv")),
+    ("pt-br", cfg!(feature = "lang-pt-br")),
+    ("zh-hant", cfg!(feature = "lang-zh-hant")),
+    ("zh-hans", cfg!(feature = "lang-zh-hans")),
+    ("nl", cfg!(feature = "lang-nl")),
+    ("da", cfg!(feature = "lang-da")),
+    ("id", cfg!(feature = "lang-id")),
+    ("fi", cfg!(feature = "lang-fi")),
+    ("tr", cfg!(feature = "lang-tr")),
+];
+
+/// Each catalog is compiled in by the feature named after its file, the row that reads it has the
+/// file's code and every catalog file has text, each row's catalog is its file exactly when its
+/// feature is on (and empty otherwise), and
+/// the manifest declares exactly one `lang-<code>` feature per language, every one of them listed in
+/// `translations`. So no build can offer a language under another's feature, or leave one out of
+/// `translations`. (A feature the manifest lacks is also an `unexpected_cfgs` warning, which clippy
+/// refuses.)
+#[test]
+fn each_catalog_has_its_own_feature_and_translations_lists_every_one() {
+    let dir = concat!(env!("CARGO_MANIFEST_DIR"), "/src/i18n");
+    let module = std::fs::read_to_string(format!("{dir}.rs")).unwrap();
+    let mut files = Vec::new();
+    for call in module.split("catalog_tsv!(").skip(1) {
+        let args: Vec<&str> = call.split(')').next().unwrap().split(',').map(|a| a.trim().trim_matches('"')).collect();
+        let [file, feature] = args[..] else { panic!("catalog_tsv!({call:.40}…) takes a file and a feature") };
+        if file == "i18n/xx.tsv" {
+            continue; // "Adding a language", in the module's documentation
+        }
+        let code = file.strip_prefix("i18n/").and_then(|f| f.strip_suffix(".tsv")).unwrap();
+        assert_eq!(feature, format!("lang-{code}"), "{file}'s feature");
+        files.push(code);
+    }
+    let registered: Vec<&str> = LANGUAGES.iter().map(|l| l.code).filter(|c| *c != "en").collect();
+    assert_eq!(files, registered, "one catalog_tsv! per language, in the table's order");
+    for code in &registered {
+        let text = std::fs::read_to_string(format!("{dir}/{code}.tsv")).unwrap();
+        assert!(!text.is_empty(), "{code}.tsv is empty");
+        let row = LANGUAGES.iter().find(|l| l.code == *code).unwrap();
+        let on = FEATURE_ON.iter().find(|(c, _)| c == code).is_some_and(|(_, on)| *on);
+        assert_eq!(row.source, if on { text.as_str() } else { "" }, "{code}: its own catalog with lang-{code}, else none");
+    }
+    let listed: Vec<&str> = FEATURE_ON.iter().map(|(c, _)| *c).collect();
+    assert_eq!(listed, registered, "FEATURE_ON lists every language, in the table's order");
+
+    let manifest: toml::Table = std::fs::read_to_string(concat!(env!("CARGO_MANIFEST_DIR"), "/Cargo.toml")).unwrap().parse().unwrap();
+    let features = manifest["features"].as_table().unwrap();
+    let declared: std::collections::BTreeSet<&str> = features.keys().filter_map(|k| k.strip_prefix("lang-")).collect();
+    let wanted: std::collections::BTreeSet<&str> = registered.iter().copied().collect();
+    assert_eq!(declared, wanted, "one lang-<code> feature per language, and none without one");
+    for code in &registered {
+        assert_eq!(features[&format!("lang-{code}")].as_array().map(Vec::len), Some(0), "lang-{code} turns on nothing else");
+    }
+    let translations: std::collections::BTreeSet<&str> =
+        features["translations"].as_array().unwrap().iter().filter_map(|f| f.as_str()?.strip_prefix("lang-")).collect();
+    assert_eq!(translations, wanted, "translations turns on every language's feature");
+}
diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs
--- a/crates/ui-egui/src/menus.rs
+++ b/crates/ui-egui/src/menus.rs
@@ -65,7 +65,8 @@ pub use context::{context_items, context_menu_body, ruler_menu_body, ruler_unit_
 pub use entries::{MenuEntry, Row, context_entries, entry, menu_entries, pretty_shortcut};
 pub(crate) use invoke::graph_dialog;
 pub use invoke::{click_target, invoke, invoke_from_system_menu};
-#[cfg(test)]
+// Used only by the tests that read catalogs other than English.
+#[cfg(all(test, feature = "translations"))]
 pub(crate) use labels::lists_plugin;
 #[cfg(test)]
 use labels::shows_a_name;
diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs
--- a/crates/ui-egui/src/menus/tests.rs
+++ b/crates/ui-egui/src/menus/tests.rs
@@ -176,6 +176,8 @@ fn last_effect_dialog_and_view_toggles() {
 /// Edit › Undo and Edit › Redo name the step in the UI language as the History panel lists it
 /// (`panels::history::step_name`), including a step named after an effect whose catalog holds
 /// only the effect's menu row with an ellipsis, as Japanese does for Feather.
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn undo_and_redo_name_the_step_as_the_history_panel_does() {
     let ja = crate::i18n::Lang::from_code("ja").expect("ja registered");
@@ -371,6 +373,8 @@ fn app_with_a_rectangle() -> VectorcraftApp {
 
 /// Edit › Undo and Edit › Redo name a step named after a style as the History panel lists it:
 /// the template's translation with the style's name in it.
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn undo_and_redo_name_a_style_step_around_the_style_name() {
     let mut app = app_with_a_rectangle();
diff --git a/crates/ui-egui/src/palette.rs b/crates/ui-egui/src/palette.rs
--- a/crates/ui-egui/src/palette.rs
+++ b/crates/ui-egui/src/palette.rs
@@ -142,8 +142,11 @@ pub fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
 #[cfg(test)]
 mod tests {
     use super::*;
+    #[cfg(feature = "translations")]
     use crate::i18n::Lang;
 
+    // Reads catalogs other than English.
+    #[cfg(feature = "translations")]
     #[test]
     fn entries_are_built_once_per_language_and_match_english_shown_text_and_ids() {
         let ctx = egui::Context::default();
diff --git a/crates/ui-egui/src/panels/appearance/tests.rs b/crates/ui-egui/src/panels/appearance/tests.rs
--- a/crates/ui-egui/src/panels/appearance/tests.rs
+++ b/crates/ui-egui/src/panels/appearance/tests.rs
@@ -257,6 +257,8 @@ fn the_effect_editor_picks_choices_their_own_dialogs_list() {
 /// Those choices read as their dialogs show them in the UI language: in French, the channel's
 /// RGB as the "effect" row translates it, and in Spanish, Extrude & Bevel's Rounded as the "3d"
 /// row does, not the plain row. Rasterize names the document's color mode.
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn the_effect_editor_shows_those_choices_as_their_dialogs_do() {
     let fr = crate::i18n::Lang::from_code("fr").unwrap();
diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs
--- a/crates/ui-egui/src/panels/character/tests.rs
+++ b/crates/ui-egui/src/panels/character/tests.rs
@@ -132,6 +132,8 @@ fn kerning_at_a_caret_sets_the_pair_not_the_whole_text() {
 
 /// A variable font gets the Variable Font button, which opens one slider per axis named as the
 /// font names it; a drag along one sets that axis of the selected type in one undo step.
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn variable_fonts_get_a_slider_per_axis() {
     use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font};
diff --git a/crates/ui-egui/src/panels/history.rs b/crates/ui-egui/src/panels/history.rs
--- a/crates/ui-egui/src/panels/history.rs
+++ b/crates/ui-egui/src/panels/history.rs
@@ -118,6 +118,8 @@ mod tests {
 
     /// The step names the panel paints, top to bottom. The SVG loaders are installed so each row's
     /// icon draws as an image instead of egui's ⚠ placeholder.
+    // Used only by the tests that read catalogs other than English.
+    #[cfg(feature = "translations")]
     fn listed(app: &mut VectorcraftApp) -> Vec<String> {
         let ctx = egui::Context::default();
         egui_extras::install_image_loaders(&ctx);
@@ -136,6 +138,8 @@ mod tests {
     /// the document holds it, also when the name looks like a template, like a step with a row of
     /// its own (`Apply Brush`) or like another template's name; the Normal style's name is
     /// translated as the Character Styles panel shows it. Undone steps keep their names.
+    // Reads catalogs other than English.
+    #[cfg(feature = "translations")]
     #[test]
     fn steps_named_after_a_style_list_the_template_around_the_name() {
         let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
@@ -176,6 +180,8 @@ mod tests {
 
     /// A step named after an effect whose catalog row has only the menu label with its ellipsis
     /// takes that row's translation without the ellipsis; a name with no row has none.
+    // Reads catalogs other than English.
+    #[cfg(feature = "translations")]
     #[test]
     fn effect_steps_read_the_menu_row() {
         let ja = crate::i18n::Lang::from_code("ja").expect("ja registered");
diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs
--- a/crates/ui-egui/src/panels/layers.rs
+++ b/crates/ui-egui/src/panels/layers.rs
@@ -59,10 +59,13 @@ mod thumbs;
 
 use controls::{bottom_bar, filter_button};
 pub use controls::{expand, menu, set_filter};
+// Used only by the tests that read catalogs other than English.
+#[cfg(all(test, feature = "translations"))]
+use model::painted_name;
 pub(crate) use model::shown_name;
 pub use model::{Filter, ObjectKind, RowState};
 #[cfg(test)]
-use model::{Row, list_rows, painted_name};
+use model::{Row, list_rows};
 use model::{push_rows, rows_of_doc};
 use rows::{drag_ghost, key, rename_id, resolve_click, row};
 #[cfg(test)]
diff --git a/crates/ui-egui/src/panels/layers/tests.rs b/crates/ui-egui/src/panels/layers/tests.rs
--- a/crates/ui-egui/src/panels/layers/tests.rs
+++ b/crates/ui-egui/src/panels/layers/tests.rs
@@ -12,6 +12,8 @@ fn filled_targets(app: &mut VectorcraftApp, ctx: &egui::Context) -> usize {
 
 /// A generated `<Kind>` name is translated where painted; an unnamed text object's text never
 /// is, even when it reads like one.
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn only_generated_names_are_translated() {
     let zh = crate::i18n::Lang::from_code("zh-hant").unwrap();
diff --git a/crates/ui-egui/src/panels/variables.rs b/crates/ui-egui/src/panels/variables.rs
--- a/crates/ui-egui/src/panels/variables.rs
+++ b/crates/ui-egui/src/panels/variables.rs
@@ -474,6 +474,8 @@ mod tests {
 
     /// Load Variable Library's warning about variables that aren't text shows in the status bar,
     /// translated in every language but English.
+    // Reads catalogs other than English.
+    #[cfg(feature = "translations")]
     #[test]
     fn the_variable_library_warning_shows_in_the_status_bar() {
         use crate::i18n::{Lang, message};
diff --git a/crates/ui-egui/src/prefs_dialog.rs b/crates/ui-egui/src/prefs_dialog.rs
--- a/crates/ui-egui/src/prefs_dialog.rs
+++ b/crates/ui-egui/src/prefs_dialog.rs
@@ -354,7 +354,7 @@ fn category_fields(ui: &mut egui::Ui, d: &mut Dialog, cat: &str) {
                 });
             }
             PrefKind::Text if sp.key == "interfaceLanguage" => {
-                // `auto` plus every registered language, by its own name.
+                // `auto` plus every language compiled in, by its own name.
                 labeled(ui, sp.label, |ui| {
                     let cur = v.as_str().unwrap_or("auto");
                     let codes: Vec<&str> = std::iter::once("auto").chain(crate::i18n::Lang::for_picker().into_iter().map(|l| l.code())).collect();
diff --git a/crates/ui-egui/src/tests/nativemenu.rs b/crates/ui-egui/src/tests/nativemenu.rs
--- a/crates/ui-egui/src/tests/nativemenu.rs
+++ b/crates/ui-egui/src/tests/nativemenu.rs
@@ -126,6 +126,8 @@ fn settings_lists_every_preferences_page() {
 
 /// Language ▸: Automatic, then each language in its own name (in any UI language), the
 /// preference checked; Appearance ▸: the UI brightness choices, the current one checked.
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn language_and_appearance_are_in_the_app_menu() {
     let mut app = app(false);
diff --git a/crates/ui-egui/src/toolbar/tests.rs b/crates/ui-egui/src/toolbar/tests.rs
--- a/crates/ui-egui/src/toolbar/tests.rs
+++ b/crates/ui-egui/src/toolbar/tests.rs
@@ -399,6 +399,8 @@ fn double_clicking_a_selection_tool_opens_the_move_dialog() {
 
 /// #1616: in every language a flyout is wide enough that no tool's name runs into its shortcut
 /// (the Spanish Live Paint names need more than the 250 pt the English ones fit in).
+// Reads catalogs other than English.
+#[cfg(feature = "translations")]
 #[test]
 fn a_flyout_is_as_wide_as_its_longest_translated_row() {
     let ctx = egui::Context::default();
diff --git a/docs/development.md b/docs/development.md
--- a/docs/development.md
+++ b/docs/development.md
@@ -1,5 +1,6 @@
 # Development
 
+> **Last updated:** 2026-10-24 · **Change:** choosing the translations (a `lang-<code>` feature per language).
 > **Last updated:** 2026-10-25 · **Change:** Linux Wayland drag-and-drop note names eframe 0.37 (still on winit 0.30).
 > **Last updated:** 2026-10-23 · **Change:** Tests: cargo xtask test, one integration-test binary per crate, the dev profile's debug info.
 > **Last updated:** 2026-10-21 · **Change:** the interface faces as vectorcraft-ui-egui's `interface-fonts` feature, and `theme::install_fonts` for an embedder that draws the panels itself.
@@ -198,10 +199,11 @@ context's font definitions, so the embedder's own fonts are added after it.
   vectorcraft-text`, and the same for `vectorcraft-mcp`), and with them do; a test of either can't
   see that configuration, since their dev-dependencies turn the fonts on.
 - `cargo test -p vectorcraft-text --no-default-features --test without_bundled_fonts` checks the
-  configuration without them, and `cargo test -p vectorcraft-ui-egui --no-default-features --test
-  install_fonts` that the panels without their interface faces still bind every family; the same
-  step lints `vectorcraft-ui-egui` without its default features. Of the crate's other tests, those
-  about the interface faces need them.
+  configuration without them, and `cargo nextest run -p vectorcraft-ui-egui --no-default-features`
+  (`cargo test` without nextest, as the test step runs) runs the panels' tests without their
+  interface faces or any translation, among them `install_fonts` (the panels still bind every
+  family); the same step lints `vectorcraft-ui-egui` without its default features. The crate's tests about the interface faces need them, and are left out of
+  that run.
 
 ### Arabic fonts and the web build
 
@@ -257,11 +259,14 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE
   code; `auto` follows the system locale: `VECTORCRAFT_LOCALE`, then `LC_ALL`/`LC_MESSAGES`/`LANG`/`LANGUAGE`,
   the macOS preferred languages, the Windows user locale; on the web, `?lang=<code>` in the address, then
   the browser's `navigator.languages`). The Preferences dialog previews the chosen language before OK.
-- To add a language: add `<code>.tsv` and one row in `i18n::LANGUAGES` (code, native name, catalog, plural
-  rule). The Language menu, the dropdown, locale matching and the catalog tests (well-formed, no duplicates,
-  placeholders and ellipses agree, command ids exist, every row of a partial catalog is a string the UI
-  shows, the UI fonts have every glyph, no Simplified characters in `zh-hant`) pick it up. Set `complete_menus`
-  once the menus and `tl!` literals are translated; the untranslated report then lists what the catalog lacks
+- To add a language: add `<code>.tsv`, one row in `i18n::LANGUAGES` (code, native name, catalog as
+  `catalog_tsv!("<code>.tsv", "lang-<code>")`, plural rule) and the feature `lang-<code>` in
+  `crates/ui-egui/Cargo.toml`, listed in `translations` (and in `FEATURE_ON`,
+  `i18n/tests_compiled_in.rs`). The Language menu, the dropdown, locale matching and the catalog
+  tests (well-formed, no duplicates, placeholders and ellipses agree, command ids exist, every row
+  of a partial catalog is a string the UI shows, the UI fonts have every glyph, no Simplified
+  characters in `zh-hant`) pick it up. Set `complete_menus` once the menus and `tl!` literals are
+  translated; the untranslated report then lists what the catalog lacks
   (`VECTORCRAFT_I18N_DUMP=strings.txt cargo test -p vectorcraft-ui-egui dump_source_strings` lists every
   English string).
 - A missing translation never fails a test or a build: the interface shows the English text until a translator
@@ -293,6 +298,37 @@ other `sv-*` locale resolve to it), Georgian (`ka`, complete; `ka-GE` and `ka_GE
   system font; the glyph test checks Latin and Cyrillic catalogs always and the CJK ones only with craft-fonts.
   A Traditional Chinese UI font is still to be added to craft-fonts for the web build.
 
+### Choosing the translations (embedders)
+
+The interface's translations, one catalog per language in `crates/ui-egui/src/i18n/*.tsv`, are
+compiled into `vectorcraft-ui-egui` one feature per language, named after the catalog's file:
+`lang-de`, `lang-ja`, `lang-pt-br`, `lang-zh-hant` and so on. The `translations` feature turns on
+every one (about 6.6 MB of the binary), and is on by default, so the apps have every language. An
+embedder that wants only some of them depends on `vectorcraft-ui-egui` with
+`default-features = false` and lists those, for example `features = ["lang-de", "lang-fr"]`; with
+none, the interface is English only and ships no catalog. All the catalogs are about 6.6 MB of
+text, 2.0 MB with `gzip -9` and 1.1 MB with `brotli -q 11` (2026-10-21;
+`cat crates/ui-egui/src/i18n/*.tsv | gzip -9 | wc -c`, and the same with `brotli -q 11 -c`).
+
+A language whose feature is off is left out: `Lang::all` doesn't offer it, `Lang::from_code` doesn't
+know its code, and it claims no complete menus. A system locale for it is skipped: the next language
+in the system's list that is compiled in is taken, else English. A preference saved by a build that
+had it, such as `interfaceLanguage: "fr"`, is read as an unknown code is: it follows the system
+locale, as Automatic does (Preferences shows the code as it is), and `app.language` refuses the
+code. Preferences and the Language menu offer Automatic, English and the languages compiled in.
+
+- The catalog tests (`i18n/tests.rs`) and the tests that read another language's catalog run with
+  every language (`#[cfg(feature = "translations")]`); every other test of the crate also passes
+  English only.
+- `i18n/tests_compiled_in.rs` checks what a left-out language does with a registry of its own, in
+  which one language is compiled in and another is not, so the default test run covers it; that a
+  catalog is empty without its feature (one no build has) and its file with it; and that each
+  `catalog_tsv!` names the feature after its own file, each row reads its own file and
+  `translations` lists every language's feature.
+- `cargo xtask ci`'s `no-bundled-fonts` step lints and runs the crate's tests with no default
+  feature, so with no language feature, as an English-only embedder builds it: a new test that
+  reads another language's catalog without that `cfg` fails there.
+
 ## Cursors
 
 `crates/tools/src/cursors.toml` lists every `Cursor` value: when it shows, in plain behaviour terms, and where its hotspot is, so whoever draws the cursors knows what each one is for without reading the tools (CLAUDE.md › Adding a cursor). `cargo xtask cursors` (part of `cargo xtask ci`) warns about a `Cursor` value with no entry, an entry with no `Cursor` value, a repeated id or a missing field; it never fails the build. A cursor not drawn yet borrows another's look (`Cursor::stand_in`).
@@ -493,6 +529,7 @@ Scores and hour estimates are unchanged.
 | Date | Change | Summary |
 |---|---|---|
 | 2026-10-25 | minor | Linux Wayland drag-and-drop note names eframe 0.37 (still on winit 0.30); behavior unchanged. |
+| 2026-10-24 | minor | Document the language features of vectorcraft-ui-egui (Localisation › Choosing the translations). |
 | 2026-10-23 | minor | Tests: `cargo xtask test` (nextest, then doctests), one integration-test binary per crate and the files that keep their own, the dev profile's debug info. |
 | 2026-10-21 | minor | Fonts › Leaving out the bundled fonts: vectorcraft-ui-egui's `interface-fonts` feature, and `theme::install_fonts` for an embedder that draws the panels without `VectorcraftApp::logic`. |
 | 2026-10-21 | minor | M3: highlighted-text Eyedropper sampling preserves ranges and rejects unsupported source/target transparency atomically; regression-tested; scores and estimates unchanged. |
diff --git a/xtask/src/main.rs b/xtask/src/main.rs
--- a/xtask/src/main.rs
+++ b/xtask/src/main.rs
@@ -226,16 +226,25 @@ fn has_nextest() -> bool {
 /// The workspace's unit and integration tests, without examples (clippy still checks those).
 /// nextest runs each test in its own process, in parallel across all test binaries.
 fn cmd_test() -> Result<(), String> {
+    run_tests(&["--workspace"])
+}
+
+/// The unit and integration tests of `scope` (`--workspace`, or `-p <crate>` and its features),
+/// without examples, run as [`cmd_test`] runs them: by cargo nextest when installed, each test in
+/// its own process, else by cargo test.
+fn run_tests(scope: &[&str]) -> Result<(), String> {
     const TARGETS: [&str; 3] = ["--lib", "--bins", "--tests"];
     let mut c = cargo();
-    if has_nextest() {
-        c.args(["nextest", "run", "--workspace"]).args(TARGETS);
-        run(c, "cargo nextest run --workspace --lib --bins --tests")
+    let runner = if has_nextest() {
+        c.args(["nextest", "run"]);
+        "cargo nextest run"
     } else {
         eprintln!("note: cargo-nextest is not installed; running cargo test (cargo install cargo-nextest for faster runs)");
-        c.args(["test", "--workspace"]).args(TARGETS);
-        run(c, "cargo test --workspace --lib --bins --tests")
-    }
+        c.arg("test");
+        "cargo test"
+    };
+    c.args(scope).args(TARGETS);
+    run(c, &format!("{runner} {} {}", scope.join(" "), TARGETS.join(" ")))
 }
 
 /// The doctests, which nextest doesn't run.
@@ -278,14 +287,16 @@ fn cmd_ci() -> Result<(), String> {
                 // The egui panels and the MCP server as such an embedder builds them: nothing turns
                 // the fonts back on.
                 libraries_without_bundled_fonts()?;
-                // The panels without their interface faces lint clean and still bind every family
-                // they draw in.
+                // The panels without their interface faces or any language (English only), as
+                // such an embedder builds them: they lint clean, and their tests pass, among them
+                // install_fonts (every family they draw in is still bound). They run as the test
+                // step runs them: their tests share the process-wide font database, which some of
+                // them add test fonts to, so one process per test under nextest. What a left-out
+                // language does is tested in the test step (i18n::tests_compiled_in).
                 let mut c = cargo();
                 c.args(["clippy", "-p", "vectorcraft-ui-egui", "--no-default-features", "--all-targets", "--", "-D", "warnings"]);
                 run(c, "cargo clippy -p vectorcraft-ui-egui --no-default-features --all-targets -- -D warnings")?;
-                let mut c = cargo();
-                c.args(["test", "-p", "vectorcraft-ui-egui", "--no-default-features", "--test", "install_fonts"]);
-                run(c, "cargo test -p vectorcraft-ui-egui --no-default-features --test install_fonts")
+                run_tests(&["-p", "vectorcraft-ui-egui", "--no-default-features"])
             }),
         ),
         ("pdf-deps", Box::new(|| pdf_deps::run(&root()))),
