diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1217,10 +1217,12 @@ dependencies = [
  "deckcraft-text",
  "egui",
  "egui_kittest",
+ "js-sys",
  "log",
  "serde",
  "serde_json",
  "skrifa 0.47.0",
+ "sys-locale",
  "toml",
  "web-time",
 ]
@@ -4524,6 +4526,18 @@ dependencies = [
  "syn 3.0.6",
 ]
 
+[[package]]
+name = "sys-locale"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
+dependencies = [
+ "js-sys",
+ "libc",
+ "wasm-bindgen",
+ "web-sys",
+]
+
 [[package]]
 name = "tar"
 version = "0.4.46"
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -52,6 +52,9 @@ unicode-linebreak = "0.1"
 # Base features are wasm-safe (`std` only). The non-wasm `clock` feature is requested
 # per-crate for targets that read the local clock (see crates/text).
 chrono = { version = "0.4", default-features = false, features = ["std"] }
+# The interface language setting follows the operating system (#8): display language on Windows,
+# preferred languages on macOS, LANG/LC_* elsewhere, the browser on the web.
+sys-locale = { version = "0.3", features = ["js"] }
 
 # text / rendering / formats
 skrifa = "0.47"
diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -6,6 +6,7 @@
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: Ctrl+wheel zoom during the slide show, #1286; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** trivial (progress log: picture and embedded object fills; previous: trivial, scroll bars on the zoomed slide pane, #1157; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (fill and outline edits reach group members, #1079; stage and scores unchanged) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (Japanese interface, first step: TSV catalogs, system-language following and an Interface language setting; the menus, ribbon, status bar, command labels and Preferences are drawn in Japanese, other dialog bodies still English; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-20 · **Change:** trivial (empty chart number-format sections hide values; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-18 · **Change:** minor (Symbol-font characters (U+F0xx) remap to the installed Symbol face's real Unicode on macOS, so they draw from Symbol instead of a look-alike; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (proofing language for the whole presentation; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: ~60 landed items in the progress log, among them the Font, Paragraph, Photo Album, theme colour and font and Date and Time dialogs, presenter and show controls, the SmartArt Text Pane and PPTX corpus slices; breadth 84% after `cargo xtask parity`, ready stays ~55%, stage stays alpha; duplicated rows and headers folded; previous: minor, hours to ~95% per audience added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
@@ -90,8 +91,9 @@ EMF/SVG pictures, the data-loss and startup-crash bugs (#60, #40, #59) and the b
 
 ## Languages
 
-English only today; no string catalog yet (Ukrainian is in review as PR #34). Detail:
-[docs/localization-parity.md](docs/localization-parity.md).
+English today; the catalog system landed with Japanese as its first catalog (menus, ribbon,
+status bar, command labels and Preferences; other dialog bodies still English), and Ukrainian is
+in review as PR #34. Detail: [docs/localization-parity.md](docs/localization-parity.md).
 
 | Language | Code | UI strings | Status | Hours to full |
 |---|---|---:|---|---|
@@ -103,13 +105,14 @@ English only today; no string catalog yet (Ukrainian is in review as PR #34). De
 | French | fr | 0% | none | 3–5 |
 | Portuguese | pt | 0% | none | 3–5 |
 | Indonesian | id | 0% | none | 3–5 |
-| Japanese | ja | 0% | none | 4–7 |
+| Japanese | ja | ~53% (menus, ribbon, status bar, Preferences) | partial (in review) | 4–7 |
 | German | de | 0% | none | 3–5 |
 | Korean | ko | 0% | none | 4–7 |
 | Vietnamese | vi | 0% | none | 3–5 |
 
-Plus 8–14 h once for the catalog infrastructure. Other languages shipped: none. PowerPoint for Mac
-ships 26.
+The catalog infrastructure is in place (the sibling apps' TSV format, system-language following,
+the Interface language setting and catalog tests); a new language is its catalog file plus one row
+in `LANGUAGES`. Other languages shipped: none. PowerPoint for Mac ships 26.
 
 ## Upcoming
 
@@ -158,6 +161,7 @@ app's JSON control channel (`deckcraft --control PORT`).
 | 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide |
 | 2026-10-21 | Groups nest at most 32 deep (`arrange.group` refuses more), as deep as PPTX import reads and slides draw: a deck grouped 39 or more levels deep saved without complaint and then could not be opened again |
 | 2026-10-21 | Review › Language has *Whole presentation*: one proofing language for every text box, table cell, note, master and layout, and the default for new text (`format.languageAll`, one undo step); the per-selection language still works |
+| 2026-10-21 | Interface language: a per-language TSV catalog (the sibling apps' system), system-language following, and Preferences ▸ Interface language (auto \| English \| 日本語). The Japanese catalog covers the menus, ribbon tabs and buttons, status bar, command labels, command palette and Preferences; other dialog bodies still English |
 | 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |
 | 2026-10-22 | Chart bars and their legend keys draw the series' own pattern fill (`a:pattFill`, now read and written) and draw nothing for `a:noFill`, with the series outline where it has one, as PowerPoint for Mac does (synthetic probe); they took the palette's colour before. Lines, areas and markers use a pattern's foreground colour |
 | 2026-10-24 | Format Shape and Format Background ▸ Pattern fill: a gallery of the 48 preset patterns PowerPoint offers, drawn in the current colors, with Foreground and Background color menus. Format Shape could only set 25 % in accent 1 on background 1, and Format Background listed 10 presets by their XML names |
@@ -302,6 +306,7 @@ app's JSON control channel (`deckcraft --control PORT`).
 | 2026-10-23 | trivial | Progress log: scroll bars on the zoomed slide pane (#1157). Scores and milestone names unchanged |
 | 2026-10-24 | trivial | Progress log: the Columns dialog. Scores and milestone names unchanged |
 | 2026-10-24 | trivial | Progress log: Edit Points handle drags with Shift, Ctrl/Cmd or Alt/Option. Scores and milestone names unchanged |
+| 2026-10-21 | minor | Japanese interface, first step: TSV catalogs, system-language following, the Interface language setting and 554 Japanese strings for menus, ribbon, status bar, command labels, command palette and Preferences; other dialog bodies still English |
 | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged |
 | 2026-10-21 | trivial | Progress log: Reuse Slides picker (#1103). Scores and milestone names unchanged |
 | 2026-10-21 | trivial | Progress log: slide range selection (#1105). Scores and milestone names unchanged |
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
@@ -22,8 +22,12 @@ egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"
 serde = { workspace = true }
 serde_json = { workspace = true }
 log = { workspace = true }
+sys-locale = { workspace = true }
 web-time = { workspace = true }
 
+[target.'cfg(target_arch = "wasm32")'.dependencies]
+js-sys = "0.3"
+
 [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
 craft-update = { workspace = true }
 
diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -192,10 +192,17 @@ impl SlideApp {
     /// opening/inbox results, screenshot completions/deadlines, AutoRecover pacing and media
     /// clocks. UI events are handled in [`Self::ui`], where a forced hidden paint also arrives.
     pub fn logic(&mut self, ctx: &egui::Context) {
+        // The interface language is drawn from this frame on; `ui()` sets it too, for callers that
+        // lay the window out without running `logic` first (`ui_shot`, tests).
+        crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.ui.language));
         if !Self::root_viewport_admitted(ctx) {
             log::debug!("ignoring SlideApp::logic for render-only viewport {:?}", ctx.viewport_id());
             return;
         }
+        let scale = self.ui.ui_scale.clamp(0.5, 3.0);
+        if (ctx.zoom_factor() - scale).abs() > 1e-3 {
+            ctx.set_zoom_factor(scale);
+        }
         self.tick_hidden_show(ctx, web_time::Instant::now());
         // `raw_input_hook` runs before `App::logic`. In a full pass egui has already built
         // `PointerState` and `keys_down` from every injected event, so retracting the events after
@@ -251,6 +258,7 @@ impl SlideApp {
     /// no-document paths, reaches the same outer reset observation and finalizer below. No early
     /// route may publish or inspect IME output on its own.
     pub fn ui(&mut self, ui: &mut egui::Ui) {
+        crate::i18n::set_current(crate::i18n::Lang::from_pref(&self.ui.language));
         let ctx = ui.ctx().clone();
         if !Self::root_viewport_admitted(&ctx) {
             log::debug!("ignoring SlideApp::ui for render-only viewport {:?}", ctx.viewport_id());
diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs
--- a/crates/ui-egui/src/app/commands.rs
+++ b/crates/ui-egui/src/app/commands.rs
@@ -42,6 +42,16 @@ impl SlideApp {
 
     fn run_ui(&mut self, id: &str, p: &Value) -> Result<Value, String> {
         match id {
+            "ui.language" => {
+                let value = p.get("value").and_then(Value::as_str).ok_or("`value` must be \"auto\", \"en\" or \"ja\"")?;
+                let pref = crate::i18n::normalize_pref(value).ok_or_else(|| {
+                    format!("`value` must be \"auto\" or one of {:?}", crate::i18n::Lang::all().map(crate::i18n::Lang::code).collect::<Vec<_>>())
+                })?;
+                self.ui.language = pref.to_string();
+                // The next frame draws in it; tests and the control channel see it at once.
+                crate::i18n::set_current(crate::i18n::Lang::from_pref(pref));
+                return Ok(json!({"language": pref}));
+            }
             "view.normal" => self.ui.view = ViewMode::Normal,
             "view.outline" => self.ui.view = ViewMode::Outline,
             "view.sorter" => self.ui.view = ViewMode::Sorter,
diff --git a/crates/ui-egui/src/app/ime.rs b/crates/ui-egui/src/app/ime.rs
--- a/crates/ui-egui/src/app/ime.rs
+++ b/crates/ui-egui/src/app/ime.rs
@@ -24,6 +24,11 @@ impl SlideApp {
         if let Some(st) = self.session.active() {
             self.ui_fonts.note_doc(ctx, st);
         }
+        // The interface's own text in a CJK language needs a face for the characters the embedded
+        // faces lack (a build without craft-fonts); borrow one from the installed fonts.
+        if crate::i18n::current().uses_cjk() {
+            self.ui_fonts.note(ctx, crate::i18n::current().catalog_text());
+        }
         self.ui_fonts.apply(ctx);
     }
 
diff --git a/crates/ui-egui/src/backstage.rs b/crates/ui-egui/src/backstage.rs
--- a/crates/ui-egui/src/backstage.rs
+++ b/crates/ui-egui/src/backstage.rs
@@ -328,7 +328,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
         ui.spacing_mut().item_spacing.y = 0.0;
         if has_doc {
             let (r, resp) = ui.allocate_exact_size(vec2(NAV_W, BACK_H), Sense::click());
-            resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "Back"));
+            resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, tl!("Back")));
             let hovered = resp.hovered();
             if hovered {
                 ui.painter().rect_filled(Rect::from_center_size(pos2(r.left() + 26.0, r.center().y), vec2(34.0, 34.0)), 17.0, t.hover);
@@ -391,7 +391,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
             egui::Frame::NONE.inner_margin(egui::Margin { left: 48, right: 36, top: 22, bottom: 32 }).show(ui, |ui| {
                 ui.set_max_width(CONTENT_W);
                 if bs.page != Page::Home {
-                    ui.label(RichText::new(bs.page.label()).font(theme::bold(22.0)).color(t.text));
+                    ui.label(RichText::new(tl!(bs.page.label())).font(theme::bold(22.0)).color(t.text));
                     ui.add_space(16.0);
                 }
                 // Errors raised on a backstage page (a missing recent file, a failed open) would
@@ -432,7 +432,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
 fn nav_item(ui: &mut Ui, nav: Nav, bs: &Backstage, index: usize, t: &Tokens) -> egui::Response {
     let (rect, resp) = ui.allocate_exact_size(vec2(NAV_W, ITEM_H), Sense::click());
     let selected = nav == Nav::Page(bs.page);
-    let label = nav.label();
+    let label = tl!(nav.label());
     resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, label));
     let p = ui.painter();
     let row = rect.shrink2(vec2(6.0, 1.0));
@@ -460,6 +460,7 @@ fn separator(ui: &mut Ui, t: &Tokens) {
 
 fn heading(ui: &mut Ui, text: &str) {
     let t = Tokens::get(ui.ctx());
+    let text = tl!(text);
     ui.label(RichText::new(text).font(theme::bold(16.0)).color(t.text));
     ui.add_space(8.0);
 }
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -83,7 +83,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
     let Some(mut d) = app.dialog.take() else { return };
     let mut open = true;
     let mut close = false;
-    let mut window = egui::Window::new(title(&d.id))
+    let mut window = egui::Window::new(tl!(title(&d.id)))
         .id(egui::Id::unique(("dialog", d.id.clone())))
         .collapsible(false)
         .resizable(false)
@@ -129,6 +129,7 @@ pub fn show(app: &mut SlideApp, ctx: &egui::Context) {
 fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) {
     let mut ok = false;
     let mut cancel = false;
+    let ok_label = tl!(ok_label);
     ui.add_space(8.0);
     ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
         let t = Tokens::get(ui.ctx());
@@ -137,7 +138,7 @@ fn buttons(ui: &mut Ui, ok_label: &str) -> (bool, bool) {
         {
             ok = true;
         }
-        if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {
+        if ui.add(egui::Button::new(tl!("Cancel")).min_size(vec2(72.0, 24.0))).clicked() {
             cancel = true;
         }
     });
diff --git a/crates/ui-egui/src/dialogs/body.rs b/crates/ui-egui/src/dialogs/body.rs
--- a/crates/ui-egui/src/dialogs/body.rs
+++ b/crates/ui-egui/src/dialogs/body.rs
@@ -258,7 +258,7 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
                         }
                         done = true;
                     }
-                    if ui.add(egui::Button::new("Cancel").min_size(vec2(72.0, 24.0))).clicked() {
+                    if ui.add(egui::Button::new(tl!("Cancel")).min_size(vec2(72.0, 24.0))).clicked() {
                         done = true;
                     }
                 });
@@ -1276,9 +1276,9 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
             if d.params.get("before").is_none() {
                 d.params = json!({ "before": preferences_snapshot(app) });
             }
-            ui.label(egui::RichText::new("General").font(theme::bold(13.0)));
+            ui.label(egui::RichText::new(tl!("General")).font(theme::bold(13.0)));
             let mut dark = app.ui.brightness == theme::Brightness::Dark;
-            if ui.checkbox(&mut dark, "Dark appearance").changed() {
+            if ui.checkbox(&mut dark, tl!("Dark appearance")).changed() {
                 let _ = app.run("view.dark", json!({"on": dark}));
             }
             // While the pointer holds the slider its value only waits in the dialog: applying it
@@ -1289,7 +1289,7 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
             let r = ui.add(egui::Slider::new(&mut scale, 0.75..=1.75));
             // No visible label here (the row is laid out to fit); the accessible name only.
             let enabled = r.enabled();
-            r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), "Interface size"));
+            r.widget_info(|| egui::WidgetInfo::slider(enabled, f64::from(scale), tl!("Interface size")));
             if r.is_pointer_button_down_on() {
                 if r.changed() || pending.is_some() {
                     d.fields.insert("uiScale".into(), scale.to_string());
@@ -1300,20 +1300,47 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
                 }
                 d.fields.remove("uiScale");
             }
+            // Interface language: follow the system (the default) or pick a catalog by hand. It
+            // applies at once, so the dialog's own text changes with it; Cancel puts the opening
+            // setting back (`restore_preferences`).
+            ui.label(egui::RichText::new(tl!("Interface language:")).font(theme::bold(13.0)));
+            let auto = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", crate::i18n::system_lang().english_name())]);
+            let current =
+                if app.ui.language == crate::i18n::AUTO { auto.clone() } else { crate::i18n::Lang::from_pref(&app.ui.language).name().to_string() };
+            let current_label = current.clone();
+            let opened = egui::ComboBox::from_id_salt("interface_language").selected_text(current).width(220.0).show_ui(ui, |ui| {
+                if ui.selectable_label(app.ui.language == crate::i18n::AUTO, auto.as_str()).clicked() {
+                    let _ = app.run("ui.language", json!({"value": crate::i18n::AUTO}));
+                }
+                for lang in crate::i18n::Lang::all().filter(|l| *l != crate::i18n::Lang::EN) {
+                    if ui.selectable_label(app.ui.language == lang.code(), lang.name()).clicked() {
+                        let _ = app.run("ui.language", json!({"value": lang.code()}));
+                    }
+                }
+            });
+            // A ComboBox has no accessible label of its own: name it with the chosen option so
+            // screen readers and UI tests can find the picker.
+            let opened = opened.response;
+            opened.widget_info(|| egui::WidgetInfo::labeled(egui::Role::ComboBox, true, &current_label));
+            ui.label(
+                egui::RichText::new(tl!("Menus, commands and the status bar change. Your presentation and file names don't."))
+                    .size(11.0)
+                    .color(Tokens::get(ui.ctx()).text_dim),
+            );
             // PowerPoint takes the unit from the system's measurement setting; DeckCraft asks here.
             ui.horizontal(|ui| {
-                ui.label("Measurement units:");
+                ui.label(tl!("Measurement units:"));
                 let units = &mut app.session.prefs.units;
-                ui.radio_value(units, deckcraft_geom::Unit::Inches, "Inches");
-                ui.radio_value(units, deckcraft_geom::Unit::Centimeters, "Centimeters");
+                ui.radio_value(units, deckcraft_geom::Unit::Inches, tl!("Inches"));
+                ui.radio_value(units, deckcraft_geom::Unit::Centimeters, tl!("Centimeters"));
             });
-            ui.label(egui::RichText::new("Editing").font(theme::bold(13.0)));
-            ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides");
-            ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid");
-            ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes");
+            ui.label(egui::RichText::new(tl!("Editing")).font(theme::bold(13.0)));
+            ui.checkbox(&mut app.session.prefs.smart_guides, tl!("Show smart guides"));
+            ui.checkbox(&mut app.session.prefs.snap_to_grid, tl!("Snap objects to grid"));
+            ui.checkbox(&mut app.session.prefs.smart_quotes, tl!("Replace straight quotes with smart quotes"));
             ui.horizontal(|ui| {
-                ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type");
-                if ui.button("AutoCorrect Options…").clicked() {
+                ui.checkbox(&mut app.session.prefs.autocorrect, tl!("AutoCorrect as you type"));
+                if ui.button(tl!("AutoCorrect Options…")).clicked() {
                     *d = Dialog::new("autocorrect");
                 }
             });
@@ -1321,7 +1348,7 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
                 return false;
             }
             ui.horizontal(|ui| {
-                let label = ui.label("Your name (comments):");
+                let label = ui.label(tl!("Your name (comments):"));
                 // Name the field for assistive technology; a bare text edit next to a label is an
                 // unnamed input in egui 0.37's accessibility tree.
                 let _ = ui.add(egui::TextEdit::singleline(&mut app.session.prefs.author)).labelled_by(label.id);
@@ -1337,7 +1364,7 @@ pub(super) fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool {
         "themeFonts" => theme_fonts_dialog(app, ui, d),
         "dateTime" => date_time_dialog(app, ui, d),
         _ => {
-            ui.label(format!("“{}” is not available yet.", d.id));
+            ui.label(crate::i18n::fmt(tl!("“{dialog}” is not available yet."), &[("dialog", &d.id)]));
             let (ok, cancel) = buttons(ui, "OK");
             ok || cancel
         }
diff --git a/crates/ui-egui/src/dialogs/preferences.rs b/crates/ui-egui/src/dialogs/preferences.rs
--- a/crates/ui-egui/src/dialogs/preferences.rs
+++ b/crates/ui-egui/src/dialogs/preferences.rs
@@ -5,6 +5,7 @@ pub(super) fn preferences_snapshot(app: &SlideApp) -> Value {
     json!({
         "dark": app.ui.brightness == theme::Brightness::Dark,
         "uiScale": app.ui.ui_scale,
+        "language": app.ui.language,
         "prefs": serde_json::to_value(&app.session.prefs).unwrap_or(Value::Null),
     })
 }
@@ -20,6 +21,11 @@ pub(super) fn restore_preferences(app: &mut SlideApp, d: &Dialog) {
     if let Some(scale) = before.get("uiScale").and_then(Value::as_f64) {
         app.ui.ui_scale = scale as f32;
     }
+    if let Some(language) = before.get("language").and_then(Value::as_str)
+        && language != app.ui.language
+    {
+        let _ = app.run("ui.language", json!({ "value": language }));
+    }
     if let Some(prefs) = before.get("prefs").and_then(|p| serde_json::from_value(p.clone()).ok()) {
         app.session.prefs = prefs;
     }
diff --git a/crates/ui-egui/src/i18n/catalog.rs b/crates/ui-egui/src/i18n/catalog.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/i18n/catalog.rs
@@ -0,0 +1,125 @@
+//! Parser and lookup table for interface catalogs (`*.tsv`). The format is the Crafting Apps'
+//! shared one (PdfCraft, PhotoCraft, WordCraft): one entry per line,
+//! `English source<TAB>translation`, `#` comment lines and blank lines ignored. Escapes are
+//! `\n`, `\t` and `\\`, so a label that breaks a ribbon button over two lines keeps its newline.
+//!
+//! Validation is strict — a missing/extra column, an empty translation, a duplicate key, a
+//! `{placeholder}` that differs from the English, or a trailing ellipsis on only one side — but a
+//! bad line is only skipped and reported: the rest of the catalog still loads and the skipped
+//! string shows in English. The bundled catalogs must parse without a single error (`tests.rs`).
+//! Reproducing a product's translated UI wording is not allowed (clean-room, AGENTS.md §2): the
+//! translations are our own.
+
+use std::collections::{HashMap, HashSet};
+
+/// A catalog entry as read from the file.
+#[derive(Debug, PartialEq, Eq)]
+pub struct Entry {
+    pub source: String,
+    pub translation: String,
+}
+
+/// A parsed catalog. Values are owned for the life of the process (catalogs are built once).
+#[derive(Debug, Default)]
+pub struct Catalog {
+    /// English source → translation (the hot path, looked up every frame).
+    plain: HashMap<String, String>,
+}
+
+fn unescape(text: &str) -> Result<String, String> {
+    let mut out = String::with_capacity(text.len());
+    let mut chars = text.chars();
+    while let Some(c) = chars.next() {
+        if c != '\\' {
+            out.push(c);
+            continue;
+        }
+        match chars.next() {
+            Some('n') => out.push('\n'),
+            Some('t') => out.push('\t'),
+            Some('\\') => out.push('\\'),
+            Some(other) => return Err(format!("unknown escape \\{other}; use \\n, \\t or \\\\")),
+            None => return Err("trailing backslash; use \\\\ for a literal backslash".into()),
+        }
+    }
+    Ok(out)
+}
+
+/// `{name}` placeholders of a template, sorted (translators may reorder them).
+pub fn placeholders(text: &str) -> Vec<&str> {
+    let mut names = Vec::new();
+    let mut rest = text;
+    while let Some((_, after_open)) = rest.split_once('{') {
+        let Some((name, after_close)) = after_open.split_once('}') else { break };
+        names.push(name);
+        rest = after_close;
+    }
+    names.sort_unstable();
+    names
+}
+
+/// Check one entry against its English source.
+fn validate(entry: &Entry) -> Result<(), String> {
+    let Entry { source, translation } = entry;
+    if placeholders(source) != placeholders(translation) {
+        return Err(format!("placeholders differ from the English source: {:?} vs {:?}", placeholders(source), placeholders(translation)));
+    }
+    if source.ends_with('…') != translation.ends_with('…') {
+        return Err("trailing ellipsis differs from the English source".into());
+    }
+    Ok(())
+}
+
+fn parse_line(line: &str) -> Result<Entry, String> {
+    let mut columns = line.split('\t');
+    let (Some(source), Some(translation), None) = (columns.next(), columns.next(), columns.next()) else {
+        return Err("expected `source<TAB>translation`".into());
+    };
+    let entry = Entry { source: unescape(source)?, translation: unescape(translation)? };
+    if entry.source.is_empty() || entry.translation.is_empty() {
+        return Err("source and translation must be nonempty".into());
+    }
+    validate(&entry)?;
+    Ok(entry)
+}
+
+/// Read the entries of a catalog file. Malformed lines and duplicates (the first entry wins) are
+/// returned as errors and skipped, so a bad translation never breaks the UI.
+pub fn parse_entries(text: &str) -> (Vec<Entry>, Vec<String>) {
+    let mut entries = Vec::new();
+    let mut errors = Vec::new();
+    let mut seen = HashSet::new();
+    for (index, line) in text.lines().enumerate() {
+        if line.trim().is_empty() || line.starts_with('#') {
+            continue;
+        }
+        let line_no = index.saturating_add(1);
+        match parse_line(line) {
+            Ok(entry) if !seen.insert(entry.source.clone()) => errors.push(format!("line {line_no}: duplicate key {:?}", entry.source)),
+            Ok(entry) => entries.push(entry),
+            Err(error) => errors.push(format!("line {line_no}: {error}")),
+        }
+    }
+    (entries, errors)
+}
+
+impl Catalog {
+    /// Build a catalog from its valid entries; the errors of the skipped lines come back too.
+    pub fn parse(text: &str) -> (Catalog, Vec<String>) {
+        let (entries, errors) = parse_entries(text);
+        let mut catalog = Catalog::default();
+        for e in entries {
+            catalog.plain.insert(e.source, e.translation);
+        }
+        (catalog, errors)
+    }
+
+    pub fn plain(&self, s: &str) -> Option<&str> {
+        self.plain.get(s).map(String::as_str)
+    }
+
+    /// How many strings the catalog translates (the coverage number in the docs).
+    pub fn len(&self) -> usize {
+        self.plain.len()
+    }
+}
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -0,0 +1,558 @@
+# Japanese (ja) interface catalog. Same format and clean-room rules as the sibling Crafting
+# Apps' catalogs: `English source<TAB>translation`, `#` comments, escapes `\n`, `\t`, `\\`.
+# The English text is the key the UI looks up; a string without a row shows in English.
+# Translations are our own wording (clean-room: never copied from another product's UI).
+(not checked)	(未チェック)
+About DeckCraft	DeckCraft について
+Accept Revision	変更を承諾
+Accept\nAll	すべて\n承諾
+Accessibility Checker	アクセシビリティチェッカー
+Account	アカウント
+Action	動作
+Action Buttons	動作ボタン
+Action Settings	動作設定
+Add Animation	アニメーションの追加
+Add Guide	ガイドの追加
+Add Point	頂点の追加
+Add Section	セクションの追加
+Add Shape	図形の追加
+Add to Quick Access Toolbar	クイックアクセスツールバーに追加
+Add\nAnimation	アニメーション\nの追加
+Adjust Shape	図形の調整
+Align	配置
+Align Cell Text	セル内の文字配置
+Align Left	左揃え
+Align Right	右揃え
+Align Text	文字の配置
+Alt Text	代替テキスト
+Alt\nText	代替\nテキスト
+Animation Styles	アニメーションスタイル
+Animation\nPainter	アニメーション\nの複製
+Animation\nPane	アニメーション\nウィンドウ
+Animations	アニメーション
+Apply	適用
+Apply Animation	アニメーションを適用
+Apply Design Idea	デザインアイデアを適用
+Apply\nTo All	すべてに\n適用
+Arrange	整列
+Artistic Effects	芸術効果
+Artistic\nEffects	芸術\n効果
+Audio	音声
+Audio from File…	ファイルから音声…
+AutoCorrect	オートコレクト
+AutoCorrect Options…	オートコレクトのオプション…
+AutoCorrect as you type	入力時にオートコレクト
+Autofit	自動調整
+Automatic	自動
+Automatic ({language})	自動 ({language})
+Back	戻る
+Background\nStyles	背景\nスタイル
+Bold	太字
+Borders	罫線
+Bring Forward	前面へ移動
+Bring to Front	最前面へ移動
+Bring\nForward	前面へ\n移動
+Browse for Themes…	テーマを参照…
+Bullets	箇条書き
+Bullets and Numbering	箇条書きと段落番号
+Cameo	カメオ
+Cancel	キャンセル
+Caps	大文字
+Cell Margins	セルの余白
+Center	中央揃え
+Change Case	大文字と小文字の変更
+Change Chart Type	グラフの種類の変更
+Change Colors	色の変更
+Change Layout	レイアウトの変更
+Change Picture	図の変更
+Change Picture…	図の変更…
+Change Shape	図形の変更
+Change Spelling	スペルの修正
+Change\nChart Type	グラフの\n種類の変更
+Character Spacing	文字間隔
+Chart	グラフ
+Chart Data	グラフデータ
+Chart Design	グラフデザイン
+Chart Elements	グラフ要素
+Chart…	グラフ…
+Check\nAccessibility	アクセシビリティ\nチェック
+Choose a SmartArt Graphic	SmartArt グラフィックの選択
+Clear All Formatting	すべての書式をクリア
+Click to add notes	クリックしてノートを入力
+Close	閉じる
+Close\nMaster View	マスター\nビューを閉じる
+Collapse the Ribbon	リボンを折りたたむ
+Color	色
+Colors	色
+Columns	列
+Command Palette	コマンドパレット
+Comment	コメント
+Community Links	コミュニティリンク
+Compare	比較
+Compare…	比較…
+Compress Pictures	図の圧縮
+Connect Shapes	図形の接続
+Convert	変換
+Convert to Shapes	図形に変換
+Convert to SmartArt	SmartArt に変換
+Convert to Text	テキストに変換
+Copy	コピー
+Corrections	修正
+Create New Theme Colors	新しいテーマの色を作成
+Create New Theme Fonts	新しいテーマのフォントを作成
+Crop	トリミング
+Custom Shows	ユーザー設定のスライドショー
+Custom Slide Show	ユーザー設定のスライドショー
+Custom\nShow	ユーザー設定の\nスライドショー
+Cut	切り取り
+Dark Mode	ダークモード
+Dark appearance	ダーク表示
+Dark\nMode	ダーク\nモード
+Date & Time	日付と時刻
+Date &\nTime	日付と\n時刻
+Date and Time	日付と時刻
+Decrease Font Size	フォントサイズの縮小
+Decrease List Level	リストレベルの上げ
+Delete	削除
+Delete Columns	列の削除
+Delete Comment	コメントの削除
+Delete Guide	ガイドの削除
+Delete Layout	レイアウトの削除
+Delete Point	頂点の削除
+Delete Rows	行の削除
+Delete Slide	スライドの削除
+Delete Table	表の削除
+Delete Text	テキストの削除
+Demote	レベル下げ
+Design	デザイン
+Design\nIdeas	デザイン\nアイデア
+Distribute	分布
+Distribute Columns	列の分布
+Distribute Horizontally	左右に分布
+Distribute Rows	行の分布
+Distribute Vertically	上下に分布
+Done	完了
+Draw	描画
+Draw Table	表の作成
+Duplicate	複製
+Duplicate Slide	スライドの複製
+Edit	編集
+Edit Data	データの編集
+Edit Photo Album…	フォトアルバムの編集…
+Edit Points	頂点の編集
+Edit SmartArt Text	SmartArt テキストの編集
+Edit\nData	データの\n編集
+Edit\nShape	図形の\n編集
+Editing	編集
+Effect Options	効果のオプション
+Effect\nOptions	効果の\nオプション
+Effects	効果
+End Show	スライドショーの終了
+End\nReview	校閲の\n終了
+English	英語
+Equation	数式
+Eraser	消しゴム
+Export	エクスポート
+Export…	エクスポート…
+File	ファイル
+File (Alt+F)	ファイル (Alt+F)
+Find	検索
+Find and Replace	検索と置換
+Find…	検索…
+Fit slide to current window	スライドをウィンドウに合わせる
+Fit to Window	ウィンドウに合わせる
+Fit to\nWindow	ウィンドウに\n合わせる
+Flip	反転
+Flip Horizontal	左右反転
+Flip Vertical	上下反転
+Font	フォント
+Font Color	フォントの色
+Font Size	フォントサイズ
+Fonts	フォント
+Font…	フォント…
+Format	書式
+Format Background	背景の書式設定
+Format\nBackground	背景の\n書式設定
+Format\nPane	書式設定\nウィンドウ
+Freeform	フリーフォーム
+From\nBeginning	最初から\n再生
+From\nCurrent Slide	現在のスライド\nから再生
+General	一般
+Grayscale	グレースケール
+Grid Options	グリッドオプション
+Grid Options…	グリッドオプション…
+Grid\nOptions	グリッド\nオプション
+Gridlines	グリッド線
+Group	グループ化
+Guides	ガイド
+Handout Master	配布資料マスター
+Handout Placeholders	配布資料のプレースホルダー
+Handout\nMaster	配布資料\nマスター
+Header &\nFooter	ヘッダーと\nフッター
+Header and Footer	ヘッダーとフッター
+Header and Footer…	ヘッダーとフッター…
+Height	高さ
+Help	ヘルプ
+Hide All	すべて非表示
+Hide Background Graphics	背景のグラフィックを非表示
+Hide Slide	スライドを非表示
+Hide\nSlide	スライドを\n非表示
+Home	ホーム
+Icons	アイコン
+Increase Font Size	フォントサイズの拡大
+Increase List Level	リストレベルの下げ
+Info	情報
+Ink to Shape	インクを図形に変換
+Ink to\nMath	インクを\n数式に変換
+Ink to\nShape	インクを\n図形に変換
+Ink to\nText	インクを\nテキストに変換
+Insert	挿入
+Insert Above	上に挿入
+Insert Below	下に挿入
+Insert Hyperlink	ハイパーリンクの挿入
+Insert Layout	レイアウトの挿入
+Insert Left	左に挿入
+Insert Picture	図の挿入
+Insert Picture in SmartArt	SmartArt に図を挿入
+Insert Picture in SmartArt…	SmartArt に図を挿入…
+Insert Placeholder	プレースホルダーの挿入
+Insert Right	右に挿入
+Insert Synonym	類語の挿入
+Insert Table	表の挿入
+Insert\nAbove	上に\n挿入
+Insert\nBelow	下に\n挿入
+Insert\nLayout	レイアウトの\n挿入
+Insert\nLeft	左に\n挿入
+Insert\nPlaceholder	プレース\nホルダーの挿入
+Insert\nRight	右に\n挿入
+Interface language:	表示言語:
+Interface size	表示サイズ
+Italic	斜体
+Japanese	日本語
+Justify	両端揃え
+Language	言語
+Layout	レイアウト
+Layout Placeholders	レイアウトのプレースホルダー
+Layouts	レイアウト
+Learn\nMore	詳細\n情報
+Line Spacing	行間
+Link	リンク
+Lock	ロック
+Menus, commands and the status bar change. Your presentation and file names don't.	メニュー・コマンド・ステータスバーが変わります。プレゼンテーションとファイル名は変わりません。
+Merge Cells	セルの結合
+Merge Shapes	図形の結合
+Merge\nCells	セルの\n結合
+Merge\nShapes	図形の\n結合
+Move	移動
+Move Guide	ガイドの移動
+Move Handle	移動ハンドル
+Move Point	頂点の移動
+Move Section	セクションの移動
+Move Slide	スライドの移動
+New	新規作成
+New Comment	新しいコメント
+New Photo Album…	新しいフォトアルバム…
+New Presentation	新しいプレゼンテーション
+New Slide	新しいスライド
+New Window	新しいウィンドウ
+New\nComment	新しい\nコメント
+New\nSlide	新しい\nスライド
+New\nWindow	新しい\nウィンドウ
+Normal	標準
+Notes	ノート
+Notes Master	ノートマスター
+Notes Page	ノートページ
+Notes Placeholders	ノートのプレースホルダー
+Notes\nMaster	ノート\nマスター
+Nudge	ナッジ
+Numbering	段落番号
+OK	OK
+Open	開く
+Open Dialog	ダイアログを開く
+Open…	開く…
+Options	オプション
+Outline View	アウトラインビュー
+Paragraph	段落
+Paragraph…	段落…
+Paste	貼り付け
+Paste Formatting	書式を貼り付け
+Paste Special	形式を選択して貼り付け
+Paste Special…	形式を選択して貼り付け…
+Paste and Match Formatting	書式を合わせて貼り付け
+Photo Album	フォトアルバム
+Photo Album…	フォトアルバム…
+Photo\nAlbum	フォト\nアルバム
+Picture	図
+Picture Border	図の枠線
+Picture Format	図の書式設定
+Picture Styles	図のスタイル
+Picture from File…	ファイルから図…
+Pictures	図
+Play from Current Slide	現在のスライドから再生
+Play from Start	最初から再生
+Play from\nCurrent Slide	現在のスライド\nから再生
+Play from\nStart	最初から\n再生
+Playback	再生
+Point Type	頂点の種類
+Position and Size	位置とサイズ
+Poster Frame	ポスターフレーム
+Preferences	環境設定
+Preferences…	環境設定…
+Presenter View	発表者ビュー
+Presenter\nView	発表者\nビュー
+Preserve	保持
+Preview	プレビュー
+Preview Animations	アニメーションのプレビュー
+Print	印刷
+Print…	印刷…
+Promote	レベル上げ
+Properties	プロパティ
+Quick Access Toolbar	クイックアクセスツールバー
+Quick Styles	クイックスタイル
+Quick\nStyles	クイック\nスタイル
+Reading View	閲覧ビュー
+Recolor	色の変更
+Record	記録
+Redo	やり直し
+Regroup	再グループ化
+Rehearse\nTimings	タイミングの\nリハーサル
+Remove All Animations	すべてのアニメーションを削除
+Remove All Sections	すべてのセクションを削除
+Remove Animation	アニメーションの削除
+Remove Background	背景の削除
+Remove Section	セクションの削除
+Remove Shape	図形の削除
+Remove from Quick Access Toolbar	クイックアクセスツールバーから削除
+Remove\nBackground	背景の\n削除
+Rename	名前の変更
+Rename Layout	レイアウト名の変更
+Rename Section	セクション名の変更
+Rename Slide	スライド名の変更
+Reorder Animation	アニメーションの並べ替え
+Reorder Objects	オブジェクトの並べ替え
+Replace	置換
+Replace Font	フォントの置換
+Replace Fonts	フォントの置換
+Replace Fonts…	フォントの置換…
+Replace straight quotes with smart quotes	直線引用符をスマート引用符に置換
+Replace…	置換…
+Reply	返信
+Reset	リセット
+Reset Picture	図のリセット
+Resolve Thread	スレッドを解決
+Reuse Slides…	スライドの再利用…
+Review	校閲
+Ribbon Tab	リボンタブ
+Right-to-Left Text Direction	テキストを右から左に
+Rotate	回転
+Rotate Left 90°	左に 90° 回転
+Rotate Right 90°	右に 90° 回転
+Rotation	回転角度
+Ruler	ルーラー
+Save	保存
+Save As	名前を付けて保存
+Save As…	名前を付けて保存…
+Screenshot	スクリーンショット
+Search commands…	コマンドを検索…
+Section	セクション
+Select	選択
+Select All	すべて選択
+Selection\nPane	選択\nウィンドウ
+Send Backward	背面へ移動
+Send to Back	最背面へ移動
+Send\nBackward	背面へ\n移動
+Set Text	テキストの設定
+Set Transparent Color	透明色の設定
+Set Up Show…	スライドショーの設定…
+Set Up Slide Show	スライドショーの設定
+Set Up Slide Show…	スライドショーの設定…
+Set Up\nSlide Show	スライドショーの\n設定
+Set Zoom	ズームの設定
+Set as Default Shape	既定の図形に設定
+Shading	網掛け
+Shape Effects	図形の効果
+Shape Fill	図形の塗りつぶし
+Shape Format	図形の書式
+Shape Outline	図形の輪郭
+Shape\nStyles	図形\nスタイル
+Shapes	図形
+Show All	すべて表示
+Show smart guides	スマートガイドを表示
+Show/Hide	表示/非表示
+Show\nComments	コメントを\n表示
+Size	サイズ
+Size and Position	サイズと位置
+Size and Position…	サイズと位置…
+Slide Master	スライドマスター
+Slide Number	スライド番号
+Slide Show	スライドショー
+Slide Size	スライドサイズ
+Slide Sorter	スライド一覧
+Slide {current} of {total}	スライド {current} / {total}
+Slide\nMaster	スライド\nマスター
+Slide\nNumber	スライド\n番号
+Slide\nSize	スライド\nサイズ
+Slides from Outline	アウトラインからのスライド
+Slides from Outline…	アウトラインからのスライド…
+SmartArt	SmartArt
+SmartArt Design	SmartArt デザイン
+SmartArt Styles	SmartArt スタイル
+Smooth Corners	角を丸める
+Snap objects to grid	図形をグリッドに合わせる
+Sound	サウンド
+Spelling	スペルチェック
+Split Cells	セルの分割
+Split\nCells	セルの\n分割
+Strikethrough	取り消し線
+Subscript	下付き
+Superscript	上付き
+Symbol	記号
+Table	表
+Table Design	表デザイン
+Table Eraser	表の消しゴム
+Table Style Options	表スタイルのオプション
+Table Styles	表のスタイル
+Table…	表…
+Tabs	タブ
+Tabs…	タブ…
+Task Pane	作業ウィンドウ
+Text Box	テキストボックス
+Text Box Margins	テキストボックスの余白
+Text Direction	テキストの方向
+Text Effects	テキスト効果
+Text Fill	テキストの塗りつぶし
+Text Highlight Color	テキストの強調表示色
+Text Outline	テキストの輪郭
+Text Shadow	テキストの影
+Text Transform	テキストの変形
+Text Transparency	テキストの透過
+Text\nBox	テキスト\nボックス
+Text\nPane	テキスト\nウィンドウ
+Themes	テーマ
+Thesaurus	類語辞典
+Timing	タイミング
+Transition	画面切り替え
+Transitions	画面切り替え
+Translate	翻訳
+Transparency	透過
+Trigger	トリガー
+Trim	トリミング
+Trim Media	メディアのトリム
+Type Text	テキストを入力
+Underline	下線
+Undo	元に戻す
+Ungroup	グループ解除
+Variants	バリエーション
+Video	ビデオ
+Video from File…	ファイルからビデオ…
+View	表示
+Volume	音量
+Width	幅
+WordArt	ワードアート
+Wrap Text in Shape	図形内でテキストを折り返す
+Your name (comments):	名前 (コメント用):
+Zoom	ズーム
+Zoom In	拡大
+Zoom Out	縮小
+Zoom…	ズーム…
+“{dialog}” is not available yet.	「{dialog}」はまだ利用できません。
+ (not checked)	 (未チェック)
+Appearance	外観
+Customize Quick Access Toolbar	クイックアクセスツールバーのカスタマイズ
+Interface Language	表示言語
+Photo Album options	フォトアルバムのオプション
+Presentations	プレゼンテーション
+Product Information	製品情報
+Reset to Defaults	既定値に戻す
+Right-click any ribbon button to add it.	リボンの任意のボタンを右クリックして追加できます。
+Settings	設定
+Start from Beginning	最初から再生
+Templates	テンプレート
+User Information	ユーザー情報
+  Present  	  プレゼン  
+3-D	3-D
+Action Settings…	動作設定…
+After:	後:
+Animation Painter (double-click to keep it on)	アニメーションの複製 (ダブルクリックで固定)
+Apply only to this picture	この図にのみ適用
+Arrows	矢印
+Banded Columns	縞模様 (列)
+Banded Rows	縞模様 (行)
+Bevel	面取り
+Bullets and Numbering…	箇条書きと段落番号…
+Camera capture is not available yet	カメラ入力はまだ利用できません
+Comments	コメント
+Compress	圧縮
+Crop to Shape	図形に合わせてトリミング
+Customize Colors…	色のカスタマイズ…
+Customize Fonts…	フォントのカスタマイズ…
+Dashes	破線
+Delay:	遅延:
+Delete cropped areas of pictures	トリミングした部分を削除
+Duplicate Selected Slides	選択したスライドの複製
+Duration:	継続時間:
+First Column	最初の列
+Fit	合わせる
+Follow Path	パスに沿う
+Footers	フッター
+Format Painter (double-click to keep it on)	書式のコピー (ダブルクリックで固定)
+Format Shape…	図形の書式設定…
+Glow	光彩
+Gradient…	グラデーション…
+Header Row	見出し行
+Height:	高さ:
+Hide During Show	スライドショー中は非表示
+Ink to Math (coming soon)	インクを数式に変換 (近日対応)
+Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes	インクを図形に変換: 描いた線・四角形・楕円・三角形を図形にします
+Ink to Text (coming soon)	インクをテキストに変換 (近日対応)
+Insert Table…	表の挿入…
+Last Column	最後の列
+Line Spacing Options…	行間のオプション…
+Loop until Stopped	停止するまで繰り返す
+New Slide (⇧⌘N)	新しいスライド (⇧⌘N)
+No Glow	光彩なし
+No icons match.	一致するアイコンがありません。
+None	なし
+On Click of… (none)	クリック時… (なし)
+On Mouse Click	マウスクリック時
+Open another window on this presentation	このプレゼンテーションを新しいウィンドウで開く
+Other Sound…	その他のサウンド…
+Page Setup…	ページ設定…
+Paste (⌘V)	貼り付け (⌘V)
+Pen Color	ペンの色
+Pen Style	ペンのスタイル
+Pen Weight	ペンの太さ
+Pixels of this colour become transparent:	この色のピクセルを透明にします:
+Play Across Slides	複数のスライドで再生
+Play Full Screen	全画面で再生
+Play Narrations	ナレーションを再生
+Play this slide's animations on the canvas	このスライドのアニメーションをキャンバスで再生
+Put a picture in the selected node's picture placeholder	選択したノードの図のプレースホルダーに図を配置します
+Recording narration needs audio capture (coming soon)	ナレーションの録音には音声入力が必要です (近日対応)
+Reflection	反射
+Remove the picture style	図のスタイルを解除
+Rescan Installed Fonts	インストール済みフォントを再読み込み
+Reset Slide Background	スライドの背景をリセット
+Rewind after Playing	再生後に巻き戻す
+Screenshot (not available yet)	スクリーンショット (未対応)
+Selection Pane…	選択ウィンドウ…
+Shadow	影
+Show Media Controls	メディアコントロールを表示
+Soft Edges	ぼかし
+Sound:	サウンド:
+Standard (4:3)	標準 (4:3)
+Start Automatically	自動的に開始
+Start:	開始:
+Title	タイトル
+Total Row	集計行
+Translation needs a language model (not bundled)	翻訳には言語モデルが必要です (同梱されていません)
+Type and organise the diagram's text	図のテキストを入力・整理します
+Use Timings	タイミングを使用
+Warp	ワープ
+Weight	太さ
+Widescreen (16:9)	ワイド画面 (16:9)
+Width:	幅:
+Width: 	幅: 
+[No Sound]	[サウンドなし]
+[Stop Previous Sound]	[前のサウンドを停止]
+Reset Style	スタイルをリセット
diff --git a/crates/ui-egui/src/i18n/mod.rs b/crates/ui-egui/src/i18n/mod.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/i18n/mod.rs
@@ -0,0 +1,252 @@
+//! Interface languages: the UI is written in English, and a catalog per language (`*.tsv`)
+//! translates what is drawn when the interface is shown in another language. English strings stay
+//! the keys, so command ids, the document, file names, the control channel, the CLI and MCP never
+//! see translated text: scripts and agents are unaffected, and a string without a translation
+//! simply shows in English, so coverage can grow one entry at a time.
+//!
+//! The `language` setting is `auto` by default: the UI follows the operating system's language
+//! (the Windows display language, macOS preferred languages, `LANG`/`LC_*` elsewhere, the
+//! browser on the web) when there is a catalog for it, and English otherwise. Preferences ▸
+//! Interface language picks one by hand.
+//!
+//! This is the system the sibling Crafting Apps use (`wordcraft/crates/ui-egui/src/i18n`,
+//! `pdfcraft/.../i18n`, `photocraft/.../i18n`); the catalog format and habits carry over between
+//! the apps, so strings and translations port between them.
+//!
+//! # Adding a language
+//! 1. Add `xx.tsv` next to `ja.tsv` (copy its header; translate from the *meaning* of the English
+//!    text — clean-room, never from Microsoft PowerPoint's or another product's localisation).
+//! 2. Add one row to [`LANGUAGES`]. The Preferences dropdown, the system-language match and the
+//!    catalog tests pick it up from there.
+//!
+//! # Looking strings up
+//! - [`tl!`](crate::tl) / [`t`]: a string in the current language; [`tr`]: in a given one.
+//! - [`fmt`]: fill `{name}` placeholders after a lookup, so translators may reorder them.
+
+mod catalog;
+
+use std::cell::Cell;
+use std::sync::OnceLock;
+
+use catalog::Catalog;
+
+/// The `language` setting that follows the system.
+pub const AUTO: &str = "auto";
+
+/// One supported interface language.
+pub struct LangInfo {
+    /// BCP 47 code, lowercase (`ja`). Also the `language` setting's value.
+    pub code: &'static str,
+    /// The language's name in itself, shown in the Preferences dropdown.
+    pub name: &'static str,
+    /// The English name, shown beside "Automatic" so an English speaker can confirm the system
+    /// language it followed.
+    pub english_name: &'static str,
+    /// Catalog file contents (empty for the built-in English).
+    pub source: &'static str,
+    catalog: OnceLock<Catalog>,
+}
+
+/// The registry. English first: it is the fallback and the source language.
+pub static LANGUAGES: [LangInfo; 2] = [
+    LangInfo { code: "en", name: "English", english_name: "English", source: "", catalog: OnceLock::new() },
+    LangInfo { code: "ja", name: "日本語", english_name: "Japanese", source: include_str!("ja.tsv"), catalog: OnceLock::new() },
+];
+
+impl LangInfo {
+    fn catalog(&self) -> &Catalog {
+        self.catalog.get_or_init(|| {
+            let (catalog, errors) = Catalog::parse(self.source);
+            for error in errors {
+                log::warn!("{} interface catalog: {error}; that entry shows in English", self.code);
+            }
+            catalog
+        })
+    }
+}
+
+/// A language the UI can be shown in (a handle into [`LANGUAGES`]).
+#[derive(Clone, Copy)]
+pub struct Lang(&'static LangInfo);
+
+impl std::fmt::Debug for Lang {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "Lang({})", self.0.code)
+    }
+}
+
+impl PartialEq for Lang {
+    fn eq(&self, other: &Self) -> bool {
+        self.0.code == other.0.code
+    }
+}
+
+impl Eq for Lang {}
+
+impl Lang {
+    pub const EN: Lang = Lang(&LANGUAGES[0]);
+
+    pub fn code(self) -> &'static str {
+        self.0.code
+    }
+
+    pub fn name(self) -> &'static str {
+        self.0.name
+    }
+
+    /// The language's English name (for the "Automatic (Japanese)" line).
+    pub fn english_name(self) -> &'static str {
+        self.0.english_name
+    }
+
+    /// A language by its exact code (any case).
+    pub fn from_code(code: &str) -> Option<Lang> {
+        LANGUAGES.iter().find(|l| l.code.eq_ignore_ascii_case(code)).map(Lang)
+    }
+
+    /// Resolve the `language` setting: a language code, or [`AUTO`] (and anything unknown, e.g. a
+    /// code from a newer version) to follow the system.
+    pub fn from_pref(pref: &str) -> Lang {
+        Lang::from_code(pref).unwrap_or_else(system_lang)
+    }
+
+    /// Every registered language.
+    pub fn all() -> impl Iterator<Item = Lang> {
+        LANGUAGES.iter().map(Lang)
+    }
+
+    fn catalog(self) -> &'static Catalog {
+        self.0.catalog()
+    }
+
+    /// Does the interface written in this language use CJK script? (It needs a CJK face, which a
+    /// build without craft-fonts borrows from the installed fonts, see `uifonts`.)
+    pub fn uses_cjk(self) -> bool {
+        self.0.source.chars().any(|c| ('\u{2E80}'..='\u{9FFF}').contains(&c) || ('\u{AC00}'..='\u{D7AF}').contains(&c))
+    }
+
+    /// The catalog file's text: the characters the interface draws in this language.
+    pub fn catalog_text(self) -> &'static str {
+        self.0.source
+    }
+
+    /// How many strings this language translates (the coverage number in the docs).
+    pub fn translated_strings(self) -> usize {
+        self.catalog().len()
+    }
+}
+
+/// The canonical `language` setting for user input: [`AUTO`] or a registered code (any case).
+/// `None` for anything else, so callers keep the current setting.
+pub fn normalize_pref(pref: &str) -> Option<&'static str> {
+    if pref.eq_ignore_ascii_case(AUTO) {
+        return Some(AUTO);
+    }
+    Lang::from_code(pref).map(Lang::code)
+}
+
+/// Candidate codes for a locale tag, most specific first: `ja_JP.UTF-8` → `ja-jp`, `ja`.
+fn candidates(tag: &str) -> Vec<String> {
+    let base = tag.split(['.', '@']).next().unwrap_or("").replace('_', "-").to_ascii_lowercase();
+    let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).collect();
+    let mut out = Vec::new();
+    for n in (1..=parts.len()).rev() {
+        out.push(parts.get(..n).unwrap_or_default().join("-"));
+    }
+    out
+}
+
+/// The registered language for a locale tag such as `ja_JP.UTF-8` or `ja-JP`; `None` when there is
+/// no catalog for it. `C`/`POSIX` mean English.
+pub fn lang_from_tag(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))
+}
+
+/// The first of the system's preferred languages DeckCraft has, in the user's order: someone who
+/// prefers French, then Japanese, gets Japanese rather than English.
+pub fn first_supported<'a>(tags: impl IntoIterator<Item = &'a str>) -> Option<Lang> {
+    tags.into_iter().find_map(lang_from_tag)
+}
+
+/// The system language (looked up once). English when it can't be determined or isn't supported.
+pub fn system_lang() -> Lang {
+    // Tests drive the UI by its English labels whatever the machine's language is.
+    if cfg!(test) {
+        return Lang::EN;
+    }
+    static SYSTEM: OnceLock<Lang> = OnceLock::new();
+    *SYSTEM.get_or_init(detect_system_lang)
+}
+
+fn detect_system_lang() -> Lang {
+    // An explicit override, then the platform's preferred UI languages (Windows display language,
+    // macOS preferred languages, `LC_ALL`/`LC_MESSAGES`/`LANG` on Unix, the browser on the web).
+    if let Some(l) = std::env::var("DECKCRAFT_LANGUAGE").ok().and_then(|v| lang_from_tag(&v)) {
+        return l;
+    }
+    let tags: Vec<String> = sys_locale::get_locales().take(16).collect();
+    first_supported(tags.iter().map(String::as_str)).unwrap_or(Lang::EN)
+}
+
+thread_local! {
+    // Per thread: parallel test harnesses must not change each other's language.
+    static CURRENT: Cell<Lang> = const { Cell::new(Lang::EN) };
+}
+
+/// Set the language the UI is drawn in (the app does this every frame from its setting).
+pub fn set_current(lang: Lang) {
+    CURRENT.set(lang);
+}
+
+/// The language the UI is drawn in.
+pub fn current() -> Lang {
+    CURRENT.get()
+}
+
+/// Does `lang` have a catalog entry for this string? (English never does: it is the source.)
+pub fn has(lang: Lang, s: &str) -> bool {
+    lang.catalog().plain(s).is_some()
+}
+
+/// `s` in the current language ([`tr`] with [`current`]).
+pub fn t(s: &str) -> &str {
+    tr(current(), s)
+}
+
+/// `s` in `lang`; strings without a translation come back unchanged.
+pub fn tr(lang: Lang, s: &str) -> &str {
+    lang.catalog().plain(s).unwrap_or(s)
+}
+
+/// Fill `{name}` placeholders in one pass. Unknown placeholders stay as written, and inserted
+/// values are never read as templates again, so a file name containing `{n}` stays intact.
+pub fn fmt(template: &str, args: &[(&str, &str)]) -> String {
+    let mut out = String::with_capacity(template.len());
+    let mut rest = template;
+    while let Some((before, after_open)) = rest.split_once('{') {
+        out.push_str(before);
+        let Some((name, after_close)) = after_open.split_once('}') else {
+            out.push('{');
+            out.push_str(after_open);
+            return out;
+        };
+        match args.iter().find(|(key, _)| *key == name) {
+            Some((_, value)) => out.push_str(value),
+            None => {
+                out.push('{');
+                out.push_str(name);
+                out.push('}');
+            }
+        }
+        rest = after_close;
+    }
+    out.push_str(rest);
+    out
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/i18n/tests.rs
@@ -0,0 +1,162 @@
+//! Interface language tests: catalog format, system-language detection, the setting's fallback,
+//! switching the UI language and English fallback for strings without a translation.
+
+use super::catalog::{Catalog, parse_entries, placeholders};
+use super::*;
+
+fn lang(code: &str) -> Lang {
+    Lang::from_code(code).unwrap_or_else(|| panic!("{code} registered"))
+}
+
+/// A language tag no catalog will ever have: `qaa`–`qtz` are reserved for local use (ISO 639-2),
+/// so these tests keep working as real languages land.
+const UNSUPPORTED: &str = "qaa-QM";
+
+#[test]
+fn system_locales_map_to_languages() {
+    assert_eq!(lang_from_tag("ja_JP.UTF-8"), Some(lang("ja")));
+    assert_eq!(lang_from_tag("ja-JP"), Some(lang("ja")));
+    assert_eq!(lang_from_tag("ja"), Some(lang("ja")));
+    assert_eq!(lang_from_tag("JA-jp"), Some(lang("ja")));
+    assert_eq!(lang_from_tag("en-GB"), Some(Lang::EN));
+    assert_eq!(lang_from_tag("C"), Some(Lang::EN));
+    assert_eq!(lang_from_tag("POSIX"), Some(Lang::EN));
+    assert_eq!(lang_from_tag(UNSUPPORTED), None);
+    assert_eq!(lang_from_tag(""), None);
+    assert_eq!(lang_from_tag("_"), None);
+}
+
+#[test]
+fn the_first_supported_preferred_language_wins() {
+    // Windows and macOS list several preferred languages in order.
+    assert_eq!(first_supported([UNSUPPORTED, "ja-JP", "en-US"]), Some(lang("ja")));
+    assert_eq!(first_supported(["en-US", "ja-JP"]), Some(Lang::EN));
+    assert_eq!(first_supported([UNSUPPORTED, "qtz"]), None);
+    assert_eq!(first_supported(["qtz", "ja-JP"]), Some(lang("ja")));
+    assert_eq!(first_supported([]), None);
+}
+
+#[test]
+fn a_cjk_interface_asks_for_a_cjk_face() {
+    assert!(lang("ja").uses_cjk(), "Japanese needs a CJK interface face");
+    assert!(!Lang::EN.uses_cjk());
+    assert!(lang("ja").catalog_text().contains("挿入"), "the catalog text is what the interface draws");
+}
+
+#[test]
+fn settings_resolve_with_fallback() {
+    assert_eq!(Lang::from_pref("ja"), lang("ja"));
+    assert_eq!(Lang::from_pref("JA"), lang("ja"));
+    assert_eq!(Lang::from_pref("en"), Lang::EN);
+    // `auto` and unknown codes follow the system (English under test).
+    assert_eq!(Lang::from_pref(AUTO), Lang::EN);
+    assert_eq!(Lang::from_pref("xx-unknown"), Lang::EN);
+    assert_eq!(normalize_pref("Ja"), Some("ja"));
+    assert_eq!(normalize_pref("Auto"), Some(AUTO));
+    assert_eq!(normalize_pref("xx"), None);
+    assert_eq!(normalize_pref("jp"), None, "only exact codes are settings");
+}
+
+#[test]
+fn lookups_fall_back_to_english() {
+    let ja = lang("ja");
+    assert_eq!(tr(ja, "File"), "ファイル");
+    assert_eq!(tr(ja, "Undo"), "元に戻す");
+    assert_eq!(tr(ja, "no such label"), "no such label");
+    assert_eq!(tr(Lang::EN, "File"), "File");
+    assert!(has(ja, "Save") && !has(Lang::EN, "Save"));
+}
+
+#[test]
+fn switching_the_interface_language_changes_what_is_drawn() {
+    let ja = lang("ja");
+    set_current(ja);
+    assert_eq!(t("Insert"), "挿入");
+    assert_eq!(t("Slide Sorter"), "スライド一覧");
+    assert_eq!(fmt(t("Slide {current} of {total}"), &[("current", "3"), ("total", "9")]), "スライド 3 / 9");
+    // An untranslated string stays English: coverage can grow one entry at a time.
+    assert_eq!(t("A label no catalog has"), "A label no catalog has");
+    set_current(Lang::EN);
+    assert_eq!(t("Insert"), "Insert");
+}
+
+#[test]
+fn current_language_is_per_thread() {
+    set_current(lang("ja"));
+    assert_eq!(t("Edit"), "編集");
+    std::thread::spawn(|| assert_eq!(current(), Lang::EN)).join().unwrap();
+    set_current(Lang::EN);
+    assert_eq!(t("Edit"), "Edit");
+}
+
+#[test]
+fn placeholders_fill_without_reinterpreting_values() {
+    let args = [("path", "a-{pages}.pptx"), ("pages", "2")];
+    assert_eq!(fmt("{pages}: {path}; {unknown}", &args), "2: a-{pages}.pptx; {unknown}");
+    assert_eq!(fmt("text {unfinished", &args), "text {unfinished");
+    assert_eq!(fmt(tr(lang("ja"), "Slide {current} of {total}"), &[("current", "3"), ("total", "9")]), "スライド 3 / 9");
+}
+
+#[test]
+fn malformed_catalog_lines_are_skipped_not_fatal() {
+    let (c, errors) = Catalog::parse("# c\nHello\tこんにちは\nOpen\t開く\nPage {n}\tページ\nno tabs\nHello\t重複\nLine\\nTwo\t一\\n二\n");
+    // `\tHello\t...` has an empty source column; `Page {n}` keeps a different placeholder.
+    assert_eq!(errors.len(), 3, "{errors:?}");
+    assert_eq!(c.plain("Hello"), Some("こんにちは"), "the first entry wins");
+    assert_eq!(c.plain("Line\nTwo"), Some("一\n二"));
+    assert_eq!(c.plain("Page {n}"), None, "a different placeholder set is rejected");
+    assert_eq!(c.plain("Open"), Some("開く"));
+}
+
+/// Every bundled catalog parses without errors, and its entries keep the English placeholders.
+#[test]
+fn bundled_catalogs_are_well_formed() {
+    let mut codes = std::collections::HashSet::new();
+    for l in &LANGUAGES {
+        assert!(codes.insert(l.code), "duplicate code {}", l.code);
+        if l.code == "en" {
+            assert!(l.source.is_empty(), "English is the source language, not a catalog");
+            continue;
+        }
+        let (entries, errors) = parse_entries(l.source);
+        assert!(errors.is_empty(), "{}: {errors:?}", l.code);
+        assert!(entries.len() > 100, "{}: {} entries", l.code, entries.len());
+        for e in &entries {
+            assert!(!e.source.trim().is_empty() && !e.translation.trim().is_empty(), "{}: empty entry", l.code);
+            assert_eq!(placeholders(&e.source), placeholders(&e.translation), "{}: {:?}", l.code, e.source);
+        }
+    }
+}
+
+/// The first step's coverage: the Japanese catalog translates the menus, the ribbon's tabs and
+/// buttons and the status bar, not just a handful of strings.
+#[test]
+fn japanese_covers_the_menus_ribbon_and_status_bar() {
+    let ja = lang("ja");
+    for (english, japanese) in [
+        ("File", "ファイル"),
+        ("Edit", "編集"),
+        ("View", "表示"),
+        ("Insert", "挿入"),
+        ("Home", "ホーム"),
+        ("Design", "デザイン"),
+        ("Transitions", "画面切り替え"),
+        ("Animations", "アニメーション"),
+        ("Slide Show", "スライドショー"),
+        ("Review", "校閲"),
+        ("Undo", "元に戻す"),
+        ("Redo", "やり直し"),
+        ("Paste", "貼り付け"),
+        ("Bold", "太字"),
+        ("Italic", "斜体"),
+        ("Align Left", "左揃え"),
+        ("Paragraph", "段落"),
+        ("Slide Sorter", "スライド一覧"),
+        ("Fit slide to current window", "スライドをウィンドウに合わせる"),
+        ("Interface language:", "表示言語:"),
+        ("Cancel", "キャンセル"),
+    ] {
+        assert_eq!(tr(ja, english), japanese, "{english}");
+    }
+    assert_eq!(ja.translated_strings(), parse_entries(ja.0.source).0.len());
+}
diff --git a/crates/ui-egui/src/language_tests.rs b/crates/ui-egui/src/language_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/language_tests.rs
@@ -0,0 +1,81 @@
+//! Interface language: switching draws the ribbon and a dialog in Japanese, an untranslated label
+//! stays in English, and a damaged `language` setting falls back to following the system.
+
+use deckcraft_engine::Session;
+use egui_kittest::Harness;
+use egui_kittest::kittest::Queryable;
+use serde_json::json;
+
+use crate::{Services, SlideApp};
+
+fn harness() -> Harness<'static, SlideApp> {
+    let mut app = SlideApp::new(Session::with_new(), Services::default());
+    app.ui.tab = "Home".into();
+    let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).allow_missing_glyphs().build_ui_state(
+        |ui, app: &mut SlideApp| {
+            app.logic(ui.ctx());
+            app.ui(ui);
+        },
+        app,
+    );
+    h.run_steps(4);
+    h
+}
+
+#[test]
+fn switching_to_japanese_redraws_the_ribbons_buttons_in_japanese() {
+    let mut h = harness();
+    // English before the switch (`auto` picks English under test). The ribbon tabs are painted,
+    // so the buttons are what the accessibility tree (and this test) can see.
+    assert!(h.query_all_by_label("Paste").next().is_some(), "the English Paste button is drawn");
+    assert!(h.query_all_by_label("貼り付け").next().is_none());
+    h.state_mut().run("ui.language", json!({"value": "ja"})).unwrap();
+    h.run_steps(2);
+    for japanese in ["貼り付け", "レイアウト", "リセット", "セクション"] {
+        assert!(h.query_all_by_label(japanese).next().is_some(), "the Japanese label {japanese} is drawn");
+    }
+    assert!(h.query_all_by_label("Paste").next().is_none(), "the English button label is gone");
+    // Back to English: the same buttons draw in English again.
+    h.state_mut().run("ui.language", json!({"value": "en"})).unwrap();
+    h.run_steps(2);
+    assert!(h.query_all_by_label("Paste").next().is_some());
+    assert!(h.query_all_by_label("貼り付け").next().is_none());
+}
+
+#[test]
+fn the_preferences_picker_switches_the_interface_language() {
+    let mut h = harness();
+    h.state_mut().run("app.preferences", json!({})).unwrap();
+    h.run_steps(2);
+    // The dialog opens in English; its Interface language list follows the system by default.
+    assert!(h.query_all_by_label("Interface language:").next().is_some(), "the language section is shown");
+    h.get_by_label("Automatic (English)").click_accesskit();
+    h.run_steps(2);
+    h.get_by_label("日本語").click();
+    h.run_steps(2);
+    assert!(h.query_all_by_label("表示言語:").next().is_some(), "the dialog itself is redrawn in Japanese");
+    assert_eq!(h.state().ui.language, "ja");
+}
+
+#[test]
+fn interface_language_setting_takes_codes_or_auto_and_rejects_junk() {
+    let mut app = SlideApp::new(Session::with_new(), Services::default());
+    assert_eq!(app.ui.language, "auto", "the default follows the system");
+    assert!(app.run("ui.language", json!({"value": "klingon"})).is_err());
+    assert_eq!(app.ui.language, "auto", "a hostile value changes nothing");
+    assert!(app.run("ui.language", json!({"value": 1})).is_err());
+    app.run("ui.language", json!({"value": "JA"})).unwrap();
+    assert_eq!(app.ui.language, "ja", "a code is stored in its canonical form");
+    app.run("ui.language", json!({"value": "auto"})).unwrap();
+    assert_eq!(app.ui.language, "auto");
+    // A string without a translation is drawn in English, never blank.
+    assert_eq!(crate::i18n::t("A label no catalog has"), "A label no catalog has");
+}
+
+#[test]
+fn a_damaged_saved_language_setting_falls_back_to_automatic() {
+    let junk = crate::UiState { language: "klingon".into(), ..Default::default() }.sanitized();
+    assert_eq!(junk.language, crate::i18n::AUTO);
+    let code = crate::UiState { language: "JA".into(), ..Default::default() }.sanitized();
+    assert_eq!(code.language, "ja");
+}
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -6,6 +6,14 @@
 #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
 #![forbid(unsafe_code)]
 
+/// An English UI string in the current interface language ([`i18n::t`]).
+#[macro_export]
+macro_rules! tl {
+    ($s:expr) => {
+        $crate::i18n::t($s)
+    };
+}
+
 pub mod backstage;
 pub mod canvas;
 pub mod capture;
@@ -13,6 +21,7 @@ pub mod control;
 pub mod dialogs;
 pub mod doc_window;
 pub mod eyedropper;
+pub mod i18n;
 pub mod icons;
 pub mod media;
 pub mod menus;
@@ -105,6 +114,9 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[
     ("review.thesaurusPane", "Thesaurus", Some("Shift+F7"), "{word?}: open the Thesaurus pane on the selected word"),
     ("view.tab", "Ribbon Tab", None, "{tab}"),
     ("view.collapseRibbon", "Collapse the Ribbon", Some("Cmd+F1"), "{}"),
+    // Interface language: `auto` follows the system; `en`/`ja` pick a catalog. Menus, the ribbon,
+    // the status bar and command labels change; command ids, the presentation and file names don't.
+    ("ui.language", "Interface Language", None, "{value: auto|en|ja}"),
     ("view.dark", "Dark Mode", None, "{on?: bool}"),
     ("view.grayscale", "Grayscale", None, "{on?: bool}"),
     ("view.newWindow", "New Window", None, "{on?: bool, slide?: index, delta?: int}"),
@@ -466,5 +478,8 @@ mod datetime_tests;
 #[cfg(test)]
 mod inbox_tests;
 
+#[cfg(test)]
+mod language_tests;
+
 #[cfg(test)]
 mod tests;
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
@@ -81,14 +81,24 @@ pub fn ui_shortcut(app: &mut SlideApp, key: egui::Key, m: Mods) -> bool {
 /// The palette's entries for the lowercase query `q`. An engine command is hidden when a UI command has the same label: Open…, Save As…,
 /// Export… and Audio/Video from File… need a `path` the palette can't give, so the one entry left opens the dialog instead of failing.
 fn palette_items(session: &deckcraft_engine::Session, q: &str) -> Vec<(String, String, Option<&'static str>)> {
+    // Search the English label (the catalog key) and the translated one, so a query typed in either
+    // language finds the command; what is listed is the current interface language's label.
+    let matched = |label: &str, id: &str| {
+        q.is_empty() || label.to_lowercase().contains(q) || crate::i18n::t(label).to_lowercase().contains(q) || id.to_lowercase().contains(q)
+    };
     let mut items: Vec<(String, String, Option<&'static str>)> = session
         .commands()
         .into_iter()
         // Commands that need a path or other value fail when the palette runs them with `{}`.
         .filter(|c| c.enabled && !crate::UI_COMMANDS.iter().any(|u| u.1 == c.label) && !deckcraft_engine::cmd::needs_arguments(c.params))
-        .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut))
-        .chain(crate::UI_COMMANDS.iter().filter(|c| !deckcraft_engine::cmd::needs_arguments(c.3)).map(|c| (c.0.to_string(), c.1.to_string(), c.2)))
-        .filter(|(id, label, _)| q.is_empty() || label.to_lowercase().contains(q) || id.to_lowercase().contains(q))
+        .filter(|c| matched(c.label, c.id))
+        .map(|c| (c.id.to_string(), tl!(c.label).to_string(), c.shortcut))
+        .chain(
+            crate::UI_COMMANDS
+                .iter()
+                .filter(|c| !deckcraft_engine::cmd::needs_arguments(c.3) && matched(c.1, c.0))
+                .map(|c| (c.0.to_string(), tl!(c.1).to_string(), c.2)),
+        )
         .collect();
     items.sort_by_key(|(_, l, _)| !l.to_lowercase().starts_with(q));
     items.truncate(60);
@@ -106,7 +116,8 @@ pub fn palette(app: &mut SlideApp, ctx: &egui::Context) {
         .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 90.0))
         .fixed_size(egui::vec2(520.0, 380.0))
         .show(ctx, |ui| {
-            let r = ui.add(egui::TextEdit::singleline(&mut query).hint_text("Search commands…").desired_width(f32::INFINITY).font(theme::font(15.0)));
+            let r = ui
+                .add(egui::TextEdit::singleline(&mut query).hint_text(tl!("Search commands…")).desired_width(f32::INFINITY).font(theme::font(15.0)));
             r.request_focus();
             let q = query.to_lowercase();
             let items = palette_items(&app.session, &q);
@@ -161,135 +172,138 @@ pub fn palette(app: &mut SlideApp, ctx: &egui::Context) {
 pub fn menu_tree() -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
     vec![
         (
-            "File",
+            tl!("File"),
             vec![
-                ("New Presentation", "file.new"),
-                ("Open…", "app.openDialog"),
+                (tl!("New Presentation"), "file.new"),
+                (tl!("Open…"), "app.openDialog"),
                 ("-", ""),
-                ("Close", "app.closePresentation"),
-                ("Save", "file.save"),
-                ("Save As…", "app.saveAsDialog"),
-                ("Export…", "app.exportDialog"),
-                ("Print…", "app.print"),
+                (tl!("Close"), "app.closePresentation"),
+                (tl!("Save"), "file.save"),
+                (tl!("Save As…"), "app.saveAsDialog"),
+                (tl!("Export…"), "app.exportDialog"),
+                (tl!("Print…"), "app.print"),
                 ("-", ""),
-                ("Properties", "file.properties"),
+                (tl!("Properties"), "file.properties"),
             ],
         ),
         (
-            "Edit",
+            tl!("Edit"),
             vec![
-                ("Undo", "edit.undo"),
-                ("Redo", "edit.redo"),
+                (tl!("Undo"), "edit.undo"),
+                (tl!("Redo"), "edit.redo"),
                 ("-", ""),
-                ("Cut", "edit.cut"),
-                ("Copy", "edit.copy"),
-                ("Paste", "edit.paste"),
-                ("Paste and Match Formatting", "edit.pasteText"),
-                ("Paste Special…", "app.pasteSpecial"),
+                (tl!("Cut"), "edit.cut"),
+                (tl!("Copy"), "edit.copy"),
+                (tl!("Paste"), "edit.paste"),
+                (tl!("Paste and Match Formatting"), "edit.pasteText"),
+                (tl!("Paste Special…"), "app.pasteSpecial"),
                 ("-", ""),
-                ("Select All", "edit.selectAll"),
-                ("Duplicate", "edit.duplicate"),
-                ("Delete Slide", "slide.delete"),
+                (tl!("Select All"), "edit.selectAll"),
+                (tl!("Duplicate"), "edit.duplicate"),
+                (tl!("Delete Slide"), "slide.delete"),
                 ("-", ""),
-                ("Find…", "app.find"),
-                ("Replace…", "app.replace"),
+                (tl!("Find…"), "app.find"),
+                (tl!("Replace…"), "app.replace"),
             ],
         ),
         (
-            "View",
+            tl!("View"),
             vec![
-                ("Normal", "view.normal"),
-                ("Slide Sorter", "view.sorter"),
-                ("Notes Page", "view.notesPage"),
-                ("Outline View", "view.outline"),
-                ("Reading View", "view.reading"),
-                ("Slide Show", "show.start"),
+                (tl!("Normal"), "view.normal"),
+                (tl!("Slide Sorter"), "view.sorter"),
+                (tl!("Notes Page"), "view.notesPage"),
+                (tl!("Outline View"), "view.outline"),
+                (tl!("Reading View"), "view.reading"),
+                (tl!("Slide Show"), "show.start"),
                 ("-", ""),
-                ("Slide Master", "view.slideMaster"),
-                ("Handout Master", "view.handoutMaster"),
-                ("Notes Master", "view.notesMaster"),
+                (tl!("Slide Master"), "view.slideMaster"),
+                (tl!("Handout Master"), "view.handoutMaster"),
+                (tl!("Notes Master"), "view.notesMaster"),
                 ("-", ""),
-                ("Ruler", "view.ruler"),
-                ("Gridlines", "view.gridlines"),
-                ("Guides", "view.guides"),
-                ("Grid Options…", "view.gridOptions"),
+                (tl!("Ruler"), "view.ruler"),
+                (tl!("Gridlines"), "view.gridlines"),
+                (tl!("Guides"), "view.guides"),
+                (tl!("Grid Options…"), "view.gridOptions"),
                 ("-", ""),
-                ("Zoom…", "view.zoomDialog"),
-                ("Zoom In", "view.zoomIn"),
-                ("Zoom Out", "view.zoomOut"),
-                ("Fit to Window", "view.fit"),
+                (tl!("Zoom…"), "view.zoomDialog"),
+                (tl!("Zoom In"), "view.zoomIn"),
+                (tl!("Zoom Out"), "view.zoomOut"),
+                (tl!("Fit to Window"), "view.fit"),
                 ("-", ""),
-                ("New Window", "view.newWindow"),
+                (tl!("New Window"), "view.newWindow"),
             ],
         ),
         (
-            "Insert",
+            tl!("Insert"),
             vec![
-                ("New Slide", "slide.new"),
-                ("Duplicate Slide", "slide.duplicate"),
+                (tl!("New Slide"), "slide.new"),
+                (tl!("Duplicate Slide"), "slide.duplicate"),
                 ("-", ""),
-                ("Section", "section.add"),
-                ("Text Box", "insert.textBox"),
-                ("WordArt", "insert.wordArt"),
-                ("Date and Time", "insert.dateTime"),
-                ("Slide Number", "insert.slideNumber"),
+                (tl!("Section"), "section.add"),
+                (tl!("Text Box"), "insert.textBox"),
+                (tl!("WordArt"), "insert.wordArt"),
+                (tl!("Date and Time"), "insert.dateTime"),
+                (tl!("Slide Number"), "insert.slideNumber"),
                 ("-", ""),
-                ("Table…", "insert.table"),
-                ("Chart", "insert.chart"),
-                ("Picture from File…", "app.insertPictureDialog"),
-                ("Photo Album…", "insert.photoAlbum"),
-                ("Edit Photo Album…", "insert.photoAlbumEdit"),
-                ("Audio from File…", "app.insertAudioDialog"),
-                ("Video from File…", "app.insertVideoDialog"),
+                (tl!("Table…"), "insert.table"),
+                (tl!("Chart"), "insert.chart"),
+                (tl!("Picture from File…"), "app.insertPictureDialog"),
+                (tl!("Photo Album…"), "insert.photoAlbum"),
+                (tl!("Edit Photo Album…"), "insert.photoAlbumEdit"),
+                (tl!("Audio from File…"), "app.insertAudioDialog"),
+                (tl!("Video from File…"), "app.insertVideoDialog"),
             ],
         ),
         (
-            "Format",
+            tl!("Format"),
             vec![
-                ("Font…", "format.fontDialog"),
-                ("Paragraph…", "format.paragraphDialog"),
-                ("Bold", "format.bold"),
-                ("Italic", "format.italic"),
-                ("Underline", "format.underline"),
+                (tl!("Font…"), "format.fontDialog"),
+                (tl!("Paragraph…"), "format.paragraphDialog"),
+                (tl!("Bold"), "format.bold"),
+                (tl!("Italic"), "format.italic"),
+                (tl!("Underline"), "format.underline"),
                 ("-", ""),
-                ("Align Left", "format.alignLeft"),
-                ("Center", "format.alignCenter"),
-                ("Align Right", "format.alignRight"),
-                ("Justify", "format.justify"),
+                (tl!("Align Left"), "format.alignLeft"),
+                (tl!("Center"), "format.alignCenter"),
+                (tl!("Align Right"), "format.alignRight"),
+                (tl!("Justify"), "format.justify"),
                 ("-", ""),
-                ("Size and Position…", "shape.sizePosition"),
-                ("Right-to-Left Text Direction", "format.rtl"),
+                (tl!("Size and Position…"), "shape.sizePosition"),
+                (tl!("Right-to-Left Text Direction"), "format.rtl"),
             ],
         ),
         (
-            "Arrange",
+            tl!("Arrange"),
             vec![
-                ("Bring to Front", "arrange.bringToFront"),
-                ("Send to Back", "arrange.sendToBack"),
-                ("Bring Forward", "arrange.bringForward"),
-                ("Send Backward", "arrange.sendBackward"),
+                (tl!("Bring to Front"), "arrange.bringToFront"),
+                (tl!("Send to Back"), "arrange.sendToBack"),
+                (tl!("Bring Forward"), "arrange.bringForward"),
+                (tl!("Send Backward"), "arrange.sendBackward"),
                 ("-", ""),
-                ("Group", "arrange.group"),
-                ("Ungroup", "arrange.ungroup"),
-                ("Regroup", "arrange.regroup"),
+                (tl!("Group"), "arrange.group"),
+                (tl!("Ungroup"), "arrange.ungroup"),
+                (tl!("Regroup"), "arrange.regroup"),
                 ("-", ""),
-                ("Rotate Left 90°", "arrange.rotateLeft"),
-                ("Rotate Right 90°", "arrange.rotateRight"),
-                ("Flip Horizontal", "arrange.flipHorizontal"),
-                ("Flip Vertical", "arrange.flipVertical"),
+                (tl!("Rotate Left 90°"), "arrange.rotateLeft"),
+                (tl!("Rotate Right 90°"), "arrange.rotateRight"),
+                (tl!("Flip Horizontal"), "arrange.flipHorizontal"),
+                (tl!("Flip Vertical"), "arrange.flipVertical"),
             ],
         ),
         (
-            "Slide Show",
+            tl!("Slide Show"),
             vec![
-                ("Play from Start", "show.fromStart"),
-                ("Play from Current Slide", "show.fromCurrent"),
+                (tl!("Play from Start"), "show.fromStart"),
+                (tl!("Play from Current Slide"), "show.fromCurrent"),
                 ("-", ""),
-                ("Hide Slide", "slide.hide"),
-                ("Set Up Show…", "show.setup"),
+                (tl!("Hide Slide"), "slide.hide"),
+                (tl!("Set Up Show…"), "show.setup"),
             ],
         ),
-        ("Help", vec![("About DeckCraft", "app.about"), ("Check for Updates…", "app.checkUpdates"), ("Command Palette", "app.palette")]),
+        (
+            tl!("Help"),
+            vec![(tl!("About DeckCraft"), "app.about"), (tl!("Check for Updates…"), "app.checkUpdates"), (tl!("Command Palette"), "app.palette")],
+        ),
     ]
 }
 
diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs
--- a/crates/ui-egui/src/ribbon.rs
+++ b/crates/ui-egui/src/ribbon.rs
@@ -134,23 +134,26 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
         let mut x = rect.min.x + 12.0;
         let y = rect.min.y + 2.0;
         {
-            let w = ui.fonts_mut(|f| f.layout_no_wrap("File".to_string(), theme::font(13.0), t.text).size().x) + 18.0;
+            let file = tl!("File");
+            let w = ui.fonts_mut(|f| f.layout_no_wrap(file.to_string(), theme::font(13.0), t.text).size().x) + 18.0;
             let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0));
             let resp = ui.interact(r, ui.scope_id().with("tab_file"), Sense::click());
-            resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, "File"));
+            resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, file));
             if resp.hovered() {
                 ui.painter().rect_filled(r.shrink2(vec2(2.0, 2.0)), CornerRadius::same(4), t.hover);
             }
-            ui.painter().text(r.center(), Align2::CENTER_CENTER, "File", theme::font(13.0), t.text);
-            if resp.on_hover_text("File (Alt+F)").clicked() {
+            ui.painter().text(r.center(), Align2::CENTER_CENTER, file, theme::font(13.0), t.text);
+            if resp.on_hover_text(tl!("File (Alt+F)")).clicked() {
                 app.open_backstage(crate::backstage::Page::Home);
             }
             x += w + 2.0;
         }
         let all: Vec<(&str, bool)> = TABS.iter().map(|s| (*s, false)).chain(ctx_tabs.iter().map(|s| (*s, true))).collect();
         for (name, contextual) in all {
+            // The tab id stays English (`app.ui.tab`); only what is painted is translated.
+            let shown = tl!(name);
             let font = theme::font(13.0);
-            let w = ui.fonts_mut(|f| f.layout_no_wrap(name.to_string(), font.clone(), t.text).size().x) + 18.0;
+            let w = ui.fonts_mut(|f| f.layout_no_wrap(shown.to_string(), font.clone(), t.text).size().x) + 18.0;
             let r = Rect::from_min_size(pos2(x, y), vec2(w, 24.0));
             let resp = ui.interact(r, ui.scope_id().with(("tab", name)), Sense::click());
             resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, name));
@@ -160,7 +163,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
             }
             let col = if contextual { t.contextual } else { t.text };
             let f = if active { theme::bold(13.0) } else { font };
-            ui.painter().text(r.center(), Align2::CENTER_CENTER, name, f, col);
+            ui.painter().text(r.center(), Align2::CENTER_CENTER, shown, f, col);
             if active {
                 let uw = (w - 20.0).max(16.0);
                 ui.painter().rect_filled(Rect::from_center_size(pos2(r.center().x, r.max.y - 1.0), vec2(uw, 3.0)), CornerRadius::same(1), t.accent);
@@ -279,7 +282,7 @@ fn big(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p: Va
     let en = enabled(app, id);
     let r = big_button(ui, icon, label, en);
     if id == "insert.dateTime" {
-        let name = label.replace('\n', " ");
+        let name = crate::i18n::t(label).replace('\n', " ");
         r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, &name));
     }
     let r = r.on_hover_text(tip(id, label));
@@ -299,6 +302,7 @@ fn small(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, p:
 }
 
 fn toggle(app: &mut SlideApp, ui: &mut Ui, icon: Icon, label: &str, id: &str, on: bool) {
+    let label = crate::i18n::t(label);
     let en = enabled(app, id);
     if icon_toggle(ui, icon, &tip(id, label), en, on).clicked() {
         run(app, id, json!({}));
diff --git a/crates/ui-egui/src/ribbon/animations.rs b/crates/ui-egui/src/ribbon/animations.rs
--- a/crates/ui-egui/src/ribbon/animations.rs
+++ b/crates/ui-egui/src/ribbon/animations.rs
@@ -14,7 +14,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         let playing = app.anim_preview_active();
         let (resp, _) = big_button_ex(ui, Icon::Preview, "Preview", true, false, playing);
-        if resp.on_hover_text("Play this slide's animations on the canvas").clicked() {
+        if resp.on_hover_text(tl!("Play this slide's animations on the canvas")).clicked() {
             run(app, "animation.preview", json!({}));
         }
     });
@@ -122,7 +122,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) {
         big(app, ui, Icon::AnimationPane, "Animation\nPane", "view.pane", json!({"pane": "animation"}));
         let r = big_button(ui, Icon::Trigger, "Trigger", cur.is_some());
         egui::Popup::menu(&r).show(|ui| {
-            if ui.button("On Click of… (none)").clicked() {
+            if ui.button(tl!("On Click of… (none)")).clicked() {
                 run(app, "animation.timing", json!({"trigger": null}));
             }
             let shapes: Vec<(u32, String)> =
@@ -136,7 +136,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) {
         let on = app.session.anim_painter.is_some();
         let en = enabled(app, "animation.painter");
         let (r, _) = big_button_ex(ui, Icon::AnimationPainter, "Animation\nPainter", en, false, on);
-        let r = r.on_hover_text("Animation Painter (double-click to keep it on)");
+        let r = r.on_hover_text(tl!("Animation Painter (double-click to keep it on)"));
         if r.double_clicked() {
             run(app, "animation.painter", json!({"sticky": true}));
         } else if r.clicked() {
@@ -150,7 +150,7 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         rows(ui, |ui| {
             ui.horizontal(|ui| {
-                ui.label("Start:");
+                ui.label(tl!("Start:"));
                 let s = cur.as_ref().map(|c| match c.start {
                     deckcraft_model::AnimStart::OnClick => "On Click",
                     deckcraft_model::AnimStart::WithPrevious => "With Previous",
@@ -166,17 +166,17 @@ pub(super) fn animations(app: &mut SlideApp, ui: &mut Ui) {
                 });
             });
             ui.horizontal(|ui| {
-                ui.label("Duration:");
+                ui.label(tl!("Duration:"));
                 if let Some(v) =
-                    widgets::spinner(ui, "adur", "Duration", cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0)
+                    widgets::spinner(ui, "adur", tl!("Duration"), cur.as_ref().map(|c| c.duration_ms as f64 / 1000.0).unwrap_or(0.5), 0.25, "", 46.0)
                 {
                     run(app, "animation.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64}));
                 }
             });
             ui.horizontal(|ui| {
-                ui.label("Delay:");
+                ui.label(tl!("Delay:"));
                 if let Some(v) =
-                    widgets::spinner(ui, "adelay", "Delay", cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0)
+                    widgets::spinner(ui, "adelay", tl!("Delay"), cur.as_ref().map(|c| c.delay_ms as f64 / 1000.0).unwrap_or(0.0), 0.25, "", 46.0)
                 {
                     run(app, "animation.timing", json!({"delay": (v.max(0.0) * 1000.0).round() as u64}));
                 }
diff --git a/crates/ui-egui/src/ribbon/arrange.rs b/crates/ui-egui/src/ribbon/arrange.rs
--- a/crates/ui-egui/src/ribbon/arrange.rs
+++ b/crates/ui-egui/src/ribbon/arrange.rs
@@ -17,14 +17,14 @@ pub fn arrange_menu(app: &mut SlideApp, ui: &mut Ui) {
     menu_item(app, ui, Icon::Group, "Regroup", "arrange.regroup", json!({}));
     ui.separator();
     ui.label(egui::RichText::new("Position Objects").font(theme::bold(12.0)));
-    ui.menu_button("Align", |ui| align_menu(app, ui));
-    ui.menu_button("Rotate", |ui| {
+    ui.menu_button(tl!("Align"), |ui| align_menu(app, ui));
+    ui.menu_button(tl!("Rotate"), |ui| {
         menu_item(app, ui, Icon::RotateRight, "Rotate Right 90°", "arrange.rotateRight", json!({}));
         menu_item(app, ui, Icon::RotateLeft, "Rotate Left 90°", "arrange.rotateLeft", json!({}));
         menu_item(app, ui, Icon::FlipV, "Flip Vertical", "arrange.flipVertical", json!({}));
         menu_item(app, ui, Icon::FlipH, "Flip Horizontal", "arrange.flipHorizontal", json!({}));
     });
-    if ui.button("Selection Pane…").clicked() {
+    if ui.button(tl!("Selection Pane…")).clicked() {
         let _ = app.run("view.pane", json!({"pane": "selection"}));
         ui.close();
     }
@@ -130,8 +130,8 @@ pub(super) fn size_group(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         rows(ui, |ui| {
             let Some(x) = bx else {
-                ui.label("Height:");
-                ui.label("Width:");
+                ui.label(tl!("Height:"));
+                ui.label(tl!("Width:"));
                 return;
             };
             let len = app.session.prefs.units;
@@ -139,19 +139,19 @@ pub(super) fn size_group(app: &mut SlideApp, ui: &mut Ui) {
             // " cm" needs more room than the inch mark.
             let width = if len == deckcraft_geom::Unit::Inches { 54.0 } else { 66.0 };
             ui.horizontal(|ui| {
-                ui.label("Height:");
-                if let Some(v) = widgets::spinner(ui, "sh", "Height", x.h / ppu, 0.1, suffix, width) {
+                ui.label(tl!("Height:"));
+                if let Some(v) = widgets::spinner(ui, "sh", tl!("Height"), x.h / ppu, 0.1, suffix, width) {
                     run(app, "shape.resize", json!({"h": v.max(0.0) * ppu}));
                 }
             });
             ui.horizontal(|ui| {
-                ui.label("Width: ");
-                if let Some(v) = widgets::spinner(ui, "sw", "Width", x.w / ppu, 0.1, suffix, width) {
+                ui.label(tl!("Width: "));
+                if let Some(v) = widgets::spinner(ui, "sw", tl!("Width"), x.w / ppu, 0.1, suffix, width) {
                     run(app, "shape.resize", json!({"w": v.max(0.0) * ppu}));
                 }
             });
         });
-        if ui.add_enabled(bx.is_some(), egui::Button::new("Size and Position…")).clicked() {
+        if ui.add_enabled(bx.is_some(), egui::Button::new(tl!("Size and Position…"))).clicked() {
             run(app, "shape.sizePosition", json!({}));
         }
     });
diff --git a/crates/ui-egui/src/ribbon/design.rs b/crates/ui-egui/src/ribbon/design.rs
--- a/crates/ui-egui/src/ribbon/design.rs
+++ b/crates/ui-egui/src/ribbon/design.rs
@@ -75,7 +75,7 @@ pub(super) fn design(app: &mut SlideApp, ui: &mut Ui) {
                 });
             }
             ui.separator();
-            if ui.button("Customize Colors…").clicked() {
+            if ui.button(tl!("Customize Colors…")).clicked() {
                 run(app, "design.colors", json!({}));
                 ui.close();
             }
@@ -89,7 +89,7 @@ pub(super) fn design(app: &mut SlideApp, ui: &mut Ui) {
                 }
             }
             ui.separator();
-            if ui.button("Customize Fonts…").clicked() {
+            if ui.button(tl!("Customize Fonts…")).clicked() {
                 run(app, "design.fonts", json!({}));
                 ui.close();
             }
@@ -132,7 +132,7 @@ pub(super) fn design(app: &mut SlideApp, ui: &mut Ui) {
                     }
                 }
             });
-            if ui.button("Reset Slide Background").clicked() {
+            if ui.button(tl!("Reset Slide Background")).clicked() {
                 run(app, "design.background", json!({"reset": true}));
             }
         });
@@ -140,13 +140,13 @@ pub(super) fn design(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         let r = big_button(ui, Icon::SlideSize, "Slide\nSize", true);
         egui::Popup::menu(&r).show(|ui| {
-            if ui.button("Standard (4:3)").clicked() {
+            if ui.button(tl!("Standard (4:3)")).clicked() {
                 run(app, "design.slideSize", json!({"preset": "standard"}));
             }
-            if ui.button("Widescreen (16:9)").clicked() {
+            if ui.button(tl!("Widescreen (16:9)")).clicked() {
                 run(app, "design.slideSize", json!({"preset": "widescreen"}));
             }
-            if ui.button("Page Setup…").clicked() {
+            if ui.button(tl!("Page Setup…")).clicked() {
                 app.dialog = Some(crate::dialogs::Dialog::new("slideSize"));
             }
         });
diff --git a/crates/ui-egui/src/ribbon/draw.rs b/crates/ui-egui/src/ribbon/draw.rs
--- a/crates/ui-egui/src/ribbon/draw.rs
+++ b/crates/ui-egui/src/ribbon/draw.rs
@@ -33,13 +33,13 @@ pub(super) fn draw(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         let has_ink = app.session.doc().is_ok_and(|d| d.shapes().iter().any(|s| matches!(s.kind, deckcraft_model::ShapeKind::Ink { .. })));
         let r = big_button(ui, Icon::InkToShape, "Ink to\nShape", has_ink);
-        if r.on_hover_text("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes").clicked() {
+        if r.on_hover_text(tl!("Ink to Shape: turn drawn lines, rectangles, ovals and triangles into shapes")).clicked() {
             app.session.set_tool(ToolKind::Select);
             run(app, "draw.inkToShape", json!({}));
         }
         let r = big_button(ui, Icon::InkToText, "Ink to\nText", false);
-        let _ = r.on_hover_text("Ink to Text (coming soon)");
+        let _ = r.on_hover_text(tl!("Ink to Text (coming soon)"));
         let r = big_button(ui, Icon::InkToMath, "Ink to\nMath", false);
-        let _ = r.on_hover_text("Ink to Math (coming soon)");
+        let _ = r.on_hover_text(tl!("Ink to Math (coming soon)"));
     });
 }
diff --git a/crates/ui-egui/src/ribbon/galleries.rs b/crates/ui-egui/src/ribbon/galleries.rs
--- a/crates/ui-egui/src/ribbon/galleries.rs
+++ b/crates/ui-egui/src/ribbon/galleries.rs
@@ -94,7 +94,7 @@ pub(super) fn smartart_gallery(
                                 Some(t) => {
                                     ui.add(egui::Button::image(egui::Image::new(t).fit_to_exact_size(egui::vec2(96.0, 60.0))).selected(*k == current))
                                 }
-                                None => ui.add_sized([96.0, 60.0], egui::Button::new("").selected(*k == current)),
+                                None => ui.add_sized([96.0, 60.0], egui::Button::new(tl!("")).selected(*k == current)),
                             };
                             ui.add(egui::Label::new(egui::RichText::new(*label).small()).truncate().selectable(false));
                             r.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, true, *label));
@@ -233,7 +233,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
             None => run(app, "shape.line", json!({"none": true})),
         }
     }
-    ui.menu_button("Weight", |ui| {
+    ui.menu_button(tl!("Weight"), |ui| {
         for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
             if ui.button(format!("{w} pt")).clicked() {
                 run(app, "shape.line", json!({"width": w}));
@@ -241,7 +241,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
             }
         }
     });
-    ui.menu_button("Dashes", |ui| {
+    ui.menu_button(tl!("Dashes"), |ui| {
         for (l, d) in [
             ("Solid", "solid"),
             ("Round Dot", "sysDot"),
@@ -257,7 +257,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
             }
         }
     });
-    ui.menu_button("Arrows", |ui| {
+    ui.menu_button(tl!("Arrows"), |ui| {
         for (l, h, tl) in [
             ("None", "none", "none"),
             ("Arrow at end", "none", "triangle"),
@@ -275,7 +275,7 @@ pub fn outline_menu(app: &mut SlideApp, ui: &mut Ui, sc: &deckcraft_color::Color
 }
 
 pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) {
-    ui.menu_button("Shadow", |ui| {
+    ui.menu_button(tl!("Shadow"), |ui| {
         for (l, v) in [
             ("No Shadow", "none"),
             ("Outer", "outer"),
@@ -289,15 +289,15 @@ pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) {
             }
         }
     });
-    ui.menu_button("Reflection", |ui| {
+    ui.menu_button(tl!("Reflection"), |ui| {
         for (l, v) in [("No Reflection", "none"), ("Tight", "tight"), ("Half", "half"), ("Full", "full")] {
             if ui.button(l).clicked() {
                 run(app, "shape.effects", json!({"reflection": v}));
             }
         }
     });
-    ui.menu_button("Glow", |ui| {
-        if ui.button("No Glow").clicked() {
+    ui.menu_button(tl!("Glow"), |ui| {
+        if ui.button(tl!("No Glow")).clicked() {
             run(app, "shape.effects", json!({"glow": null}));
         }
         for r in [5.0, 8.0, 11.0, 18.0] {
@@ -306,14 +306,14 @@ pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) {
             }
         }
     });
-    ui.menu_button("Soft Edges", |ui| {
+    ui.menu_button(tl!("Soft Edges"), |ui| {
         for r in [0.0, 1.0, 2.5, 5.0, 10.0, 25.0] {
             if ui.button(if r == 0.0 { "No Soft Edges".to_string() } else { format!("{r} pt") }).clicked() {
                 run(app, "shape.effects", json!({"softEdges": r}));
             }
         }
     });
-    ui.menu_button("Bevel", |ui| {
+    ui.menu_button(tl!("Bevel"), |ui| {
         for b in [
             "none",
             "circle",
@@ -334,7 +334,7 @@ pub fn effects_menu(app: &mut SlideApp, ui: &mut Ui) {
             }
         }
     });
-    if ui.button("Format Shape…").clicked() {
+    if ui.button(tl!("Format Shape…")).clicked() {
         let _ = app.run("view.pane", json!({"pane": "format", "tab": "effects"}));
     }
 }
diff --git a/crates/ui-egui/src/ribbon/home.rs b/crates/ui-egui/src/ribbon/home.rs
--- a/crates/ui-egui/src/ribbon/home.rs
+++ b/crates/ui-egui/src/ribbon/home.rs
@@ -7,21 +7,21 @@ use super::*;
 fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         let (main, arrow) = big_button_ex(ui, Icon::Paste, "Paste", enabled(app, "edit.paste"), true, false);
-        if main.on_hover_text("Paste (⌘V)").clicked() {
+        if main.on_hover_text(tl!("Paste (⌘V)")).clicked() {
             run(app, "edit.paste", json!({}));
         }
         if let Some(a) = arrow {
             egui::Popup::menu(&a).show(|ui| {
-                if ui.button("Paste").clicked() {
+                if ui.button(tl!("Paste")).clicked() {
                     run(app, "edit.paste", json!({}));
                 }
-                if ui.button("Paste and Match Formatting").clicked() {
+                if ui.button(tl!("Paste and Match Formatting")).clicked() {
                     run(app, "edit.pasteText", json!({}));
                 }
-                if ui.button("Paste Special…").clicked() {
+                if ui.button(tl!("Paste Special…")).clicked() {
                     run(app, "app.pasteSpecial", json!({}));
                 }
-                if ui.button("Paste Formatting").clicked() {
+                if ui.button(tl!("Paste Formatting")).clicked() {
                     run(app, "format.painterApply", json!({}));
                 }
             });
@@ -31,7 +31,7 @@ fn clipboard_group(app: &mut SlideApp, ui: &mut Ui) {
             small(app, ui, Icon::Copy, "Copy", "edit.copy", json!({}));
             let on = app.session.painter.is_some();
             let en = enabled(app, "format.painter");
-            let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text("Format Painter (double-click to keep it on)");
+            let r = small_button(ui, Icon::FormatPainter, "Format", en, on).on_hover_text(tl!("Format Painter (double-click to keep it on)"));
             if r.double_clicked() {
                 run(app, "format.painter", json!({"sticky": true}));
             } else if r.clicked() {
@@ -97,7 +97,7 @@ pub(super) fn layout_menu(app: &mut SlideApp, ui: &mut Ui, cmd: &str) {
 fn slides_group(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         let (main, arrow) = big_button_ex(ui, Icon::NewSlide, "New\nSlide", enabled(app, "slide.new"), true, false);
-        if main.on_hover_text("New Slide (⇧⌘N)").clicked() {
+        if main.on_hover_text(tl!("New Slide (⇧⌘N)")).clicked() {
             run(app, "slide.new", json!({}));
         }
         if let Some(a) = arrow {
@@ -121,10 +121,10 @@ fn slides_group(app: &mut SlideApp, ui: &mut Ui) {
 fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) {
     layout_menu(app, ui, "slide.new");
     ui.separator();
-    if ui.button("Duplicate Selected Slides").clicked() {
+    if ui.button(tl!("Duplicate Selected Slides")).clicked() {
         run(app, "slide.duplicate", json!({}));
     }
-    if ui.button("Slides from Outline…").clicked() {
+    if ui.button(tl!("Slides from Outline…")).clicked() {
         app.dialog = Some(crate::dialogs::Dialog::new("outline"));
     }
     let desktop = app.services.pick_open.is_some();
@@ -139,10 +139,10 @@ fn new_slide_menu(app: &mut SlideApp, ui: &mut Ui) {
 
 /// The Section split button's menu.
 fn section_menu(app: &mut SlideApp, ui: &mut Ui) {
-    if ui.button("Add Section").clicked() {
+    if ui.button(tl!("Add Section")).clicked() {
         run(app, "section.add", json!({}));
     }
-    if ui.button("Remove All Sections").clicked() {
+    if ui.button(tl!("Remove All Sections")).clicked() {
         run(app, "section.removeAll", json!({}));
     }
 }
@@ -187,7 +187,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
                     }
                     ui.separator();
                     // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts).
-                    if ui.button("Rescan Installed Fonts").clicked() {
+                    if ui.button(tl!("Rescan Installed Fonts")).clicked() {
                         run(app, "font.rescan", json!({}));
                         ui.close();
                     }
@@ -256,7 +256,7 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
                         run(app, "format.color", json!({"color": cref_param(&c)}));
                     }
                 });
-                if ui.add_enabled(en, egui::Button::new("Font…").min_size(vec2(52.0, 22.0))).on_hover_text("Font").clicked() {
+                if ui.add_enabled(en, egui::Button::new(tl!("Font…")).min_size(vec2(52.0, 22.0))).on_hover_text(tl!("Font")).clicked() {
                     run(app, "format.fontDialog", json!({}));
                 }
             });
@@ -272,13 +272,13 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
             ui.horizontal(|ui| {
                 ui.spacing_mut().item_spacing.x = 1.0;
                 let b = |k: &str| st.get(k).and_then(Value::as_bool).unwrap_or(false);
-                let (m, a) = small_button_named(ui, Icon::Bullets, "", "Bullets", en, b("bullets"), true);
-                if m.on_hover_text("Bullets").clicked() {
+                let (m, a) = small_button_named(ui, Icon::Bullets, "", tl!("Bullets"), en, b("bullets"), true);
+                if m.on_hover_text(tl!("Bullets")).clicked() {
                     run(app, "format.bullets", json!({}));
                 }
                 if let Some(a) = a {
                     egui::Popup::menu(&a).show(|ui| {
-                        if ui.button("None").clicked() {
+                        if ui.button(tl!("None")).clicked() {
                             run(app, "format.bullets", json!({"on": false}));
                         }
                         for ch in ["•", "○", "■", "□", "◆", "➢", "✓", "–", "★"] {
@@ -287,20 +287,20 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
                             }
                         }
                         ui.separator();
-                        if ui.button("Bullets and Numbering…").clicked() {
+                        if ui.button(tl!("Bullets and Numbering…")).clicked() {
                             let mut dlg = crate::dialogs::Dialog::new("bullets");
                             dlg.fields.insert("tab".into(), "bulleted".into());
                             app.dialog = Some(dlg);
                         }
                     });
                 }
-                let (m, a) = small_button_named(ui, Icon::Numbering, "", "Numbering", en, b("numbering"), true);
-                if m.on_hover_text("Numbering").clicked() {
+                let (m, a) = small_button_named(ui, Icon::Numbering, "", tl!("Numbering"), en, b("numbering"), true);
+                if m.on_hover_text(tl!("Numbering")).clicked() {
                     run(app, "format.numbering", json!({}));
                 }
                 if let Some(a) = a {
                     egui::Popup::menu(&a).show(|ui| {
-                        if ui.button("None").clicked() {
+                        if ui.button(tl!("None")).clicked() {
                             run(app, "format.numbering", json!({"on": false}));
                         }
                         for (label, sch) in [
@@ -317,7 +317,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
                             }
                         }
                         ui.separator();
-                        if ui.button("Bullets and Numbering…").clicked() {
+                        if ui.button(tl!("Bullets and Numbering…")).clicked() {
                             let mut dlg = crate::dialogs::Dialog::new("bullets");
                             dlg.fields.insert("tab".into(), "numbered".into());
                             app.dialog = Some(dlg);
@@ -333,10 +333,10 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
                             run(app, "format.lineSpacing", json!({"lines": v}));
                         }
                     }
-                    if ui.button("Line Spacing Options…").clicked() {
+                    if ui.button(tl!("Line Spacing Options…")).clicked() {
                         run(app, "format.paragraphDialog", json!({}));
                     }
-                    if ui.button("Tabs…").clicked() {
+                    if ui.button(tl!("Tabs…")).clicked() {
                         app.dialog = Some(crate::dialogs::Dialog::new("tabs"));
                     }
                 });
@@ -383,7 +383,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
                 });
                 let r = icon_toggle(ui, Icon::SmartArt, "Convert to SmartArt", enabled(app, "smartArt.fromText"), false);
                 egui::Popup::menu(&r).show(|ui| smartart_gallery(app, ui, "smartArt.fromText", json!({}), "", "", "", None));
-                if ui.add_enabled(en, egui::Button::new("Paragraph…").min_size(vec2(88.0, 22.0))).on_hover_text("Paragraph").clicked() {
+                if ui.add_enabled(en, egui::Button::new(tl!("Paragraph…")).min_size(vec2(88.0, 22.0))).on_hover_text(tl!("Paragraph")).clicked() {
                     run(app, "format.paragraphDialog", json!({}));
                 }
             });
@@ -395,7 +395,7 @@ fn paragraph_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) {
 fn drawing_group(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         let en = enabled(app, "shape.insert");
-        let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text("Shapes");
+        let r = big_button(ui, Icon::Shapes, "Shapes", en).on_hover_text(tl!("Shapes"));
         egui::Popup::menu(&r).show(|ui| {
             if let Some(p) = shapes_gallery(ui) {
                 app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: p.to_string() });
diff --git a/crates/ui-egui/src/ribbon/insert.rs b/crates/ui-egui/src/ribbon/insert.rs
--- a/crates/ui-egui/src/ribbon/insert.rs
+++ b/crates/ui-egui/src/ribbon/insert.rs
@@ -8,10 +8,10 @@ fn photo_album_button(app: &mut SlideApp, ui: &mut Ui) {
     let en = enabled(app, "insert.photoAlbum");
     let (main, arrow) = big_button_ex(ui, Icon::Picture, "Photo\nAlbum", en, true, false);
     let _ = main.clone().on_hover_text(tip("insert.photoAlbum", "Photo Album…"));
-    main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album…"));
+    main.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, tl!("Photo Album…")));
     if let Some(a) = &arrow {
-        let _ = a.clone().on_hover_text("Photo Album options");
-        a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, "Photo Album options"));
+        let _ = a.clone().on_hover_text(tl!("Photo Album options"));
+        a.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, en, tl!("Photo Album options")));
     }
     if main.clicked() {
         run(app, "insert.photoAlbum", json!({}));
@@ -44,11 +44,11 @@ pub(super) fn insert(app: &mut SlideApp, ui: &mut Ui) {
                 ui.close();
             }
             ui.separator();
-            if ui.button("Insert Table…").clicked() {
+            if ui.button(tl!("Insert Table…")).clicked() {
                 app.dialog = Some(crate::dialogs::Dialog::new("table"));
                 ui.close();
             }
-            if ui.button("Draw Table").clicked() {
+            if ui.button(tl!("Draw Table")).clicked() {
                 app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: deckcraft_engine::tools::DRAW_TABLE.into() });
                 ui.close();
             }
@@ -58,7 +58,7 @@ pub(super) fn insert(app: &mut SlideApp, ui: &mut Ui) {
         big(app, ui, Icon::Picture, "Pictures", "app.insertPictureDialog", json!({}));
         photo_album_button(app, ui);
         let r = big_button(ui, Icon::Screenshot, "Screenshot", false);
-        let _ = r.on_hover_text("Screenshot (not available yet)");
+        let _ = r.on_hover_text(tl!("Screenshot (not available yet)"));
     });
     group(ui, |ui| {
         let r = big_button(ui, Icon::Shapes, "Shapes", enabled(app, "shape.insert"));
@@ -97,7 +97,7 @@ pub(super) fn insert(app: &mut SlideApp, ui: &mut Ui) {
             }
             ui.separator();
             let has_sel = app.session.active().is_some_and(|s| !s.selection.shapes.is_empty());
-            if ui.add_enabled(has_sel, egui::Button::new("Action Settings…")).clicked() {
+            if ui.add_enabled(has_sel, egui::Button::new(tl!("Action Settings…"))).clicked() {
                 app.dialog = Some(crate::dialogs::Dialog::new("action"));
             }
         });
@@ -188,7 +188,7 @@ fn render_filtered_icons(app: &mut SlideApp, ui: &mut Ui, icons: &[deckcraft_eng
         }
     });
     if n == 0 {
-        ui.label("No icons match.");
+        ui.label(tl!("No icons match."));
     }
 }
 
diff --git a/crates/ui-egui/src/ribbon/masters.rs b/crates/ui-egui/src/ribbon/masters.rs
--- a/crates/ui-egui/src/ribbon/masters.rs
+++ b/crates/ui-egui/src/ribbon/masters.rs
@@ -23,15 +23,15 @@ pub(super) fn slide_master(app: &mut SlideApp, ui: &mut Ui) {
         rows(ui, |ui| {
             let (preserve, title, footers) = master_checks(app);
             let mut on = preserve;
-            if ui.checkbox(&mut on, "Preserve").changed() {
+            if ui.checkbox(&mut on, tl!("Preserve")).changed() {
                 run(app, "master.preserve", json!({"preserve": on}));
             }
             let mut on = title;
-            if ui.checkbox(&mut on, "Title").changed() {
+            if ui.checkbox(&mut on, tl!("Title")).changed() {
                 run(app, "master.placeholders", json!({"title": on}));
             }
             let mut on = footers;
-            if ui.checkbox(&mut on, "Footers").changed() {
+            if ui.checkbox(&mut on, tl!("Footers")).changed() {
                 run(app, "master.placeholders", json!({"footers": on}));
             }
         });
@@ -57,7 +57,7 @@ pub(super) fn slide_master(app: &mut SlideApp, ui: &mut Ui) {
                 }
             }
             ui.separator();
-            if ui.button("Browse for Themes…").clicked() {
+            if ui.button(tl!("Browse for Themes…")).clicked() {
                 run(app, "app.browseThemeDialog", json!({}));
             }
         });
diff --git a/crates/ui-egui/src/ribbon/picture_format.rs b/crates/ui-egui/src/ribbon/picture_format.rs
--- a/crates/ui-egui/src/ribbon/picture_format.rs
+++ b/crates/ui-egui/src/ribbon/picture_format.rs
@@ -36,7 +36,7 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
                 }
             }
             ui.separator();
-            ui.label("Recolor");
+            ui.label(tl!("Recolor"));
             let sc = scheme(app);
             let presets = deckcraft_engine::cmd::recolor::presets();
             let names: Vec<String> = presets.iter().map(|(_, l)| l.clone()).collect();
@@ -58,14 +58,15 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
             ui.separator();
             // As in PowerPoint, the pointer picks the colour in the picture itself: a colour
             // chosen from a grid almost never matches a photo's pixels.
-            if ui.button("Set Transparent Color").on_hover_text("Click a colour in the picture to make it transparent").clicked() {
+            let hover = tl!("Click a colour in the picture to make it transparent");
+            if ui.button(tl!("Set Transparent Color")).on_hover_text(hover).clicked() {
                 crate::eyedropper::request_transparent_color(ui.ctx());
                 ui.close();
             }
         });
         let r = big_button(ui, Icon::ArtisticEffects, "Artistic\nEffects", enabled(app, "picture.artisticEffect"));
         egui::Popup::menu(&r).show(|ui| {
-            if ui.button("None").clicked() {
+            if ui.button(tl!("None")).clicked() {
                 run(app, "picture.artisticEffect", json!({"effect": "none"}));
             }
             for (e, l) in deckcraft_engine::cmd::artistic::EFFECTS {
@@ -123,14 +124,14 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
                 }
             }
             ui.separator();
-            ui.menu_button("Weight", |ui| {
+            ui.menu_button(tl!("Weight"), |ui| {
                 for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
                     if ui.button(format!("{w} pt")).clicked() {
                         run(app, "shape.line", json!({"width": w}));
                     }
                 }
             });
-            ui.menu_button("Dashes", |ui| {
+            ui.menu_button(tl!("Dashes"), |ui| {
                 for (l, d) in [
                     ("Solid", "solid"),
                     ("Round Dot", "sysDot"),
@@ -145,7 +146,7 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
                 }
             });
         });
-        if ui.small_button("Reset Style").on_hover_text("Remove the picture style").clicked() {
+        if ui.small_button(tl!("Reset Style")).on_hover_text(tl!("Remove the picture style")).clicked() {
             run(app, "picture.style", json!({"style": "none"}));
         }
         big(app, ui, Icon::AltText, "Alt\nText", "app.dialog", json!({"id": "altText"}));
@@ -154,7 +155,7 @@ pub(super) fn picture_format(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         let r = big_button(ui, Icon::Crop, "Crop", true);
         egui::Popup::menu(&r).show(|ui| {
-            ui.label("Crop to Shape");
+            ui.label(tl!("Crop to Shape"));
             if let Some(p) = shapes_gallery(ui) {
                 run(app, "shape.change", json!({"preset": p}));
             }
@@ -246,8 +247,8 @@ fn compress_menu(app: &mut SlideApp, ui: &mut Ui) {
     let id = egui::Id::unique("compress_pictures_opts");
     let (mut ppi, mut only_selected, mut delete_cropped): (u32, bool, bool) = ui.data_mut(|d| d.get_temp(id).unwrap_or((220, true, true)));
     ui.label(egui::RichText::new("Compression options").strong());
-    ui.checkbox(&mut only_selected, "Apply only to this picture");
-    ui.checkbox(&mut delete_cropped, "Delete cropped areas of pictures");
+    ui.checkbox(&mut only_selected, tl!("Apply only to this picture"));
+    ui.checkbox(&mut delete_cropped, tl!("Delete cropped areas of pictures"));
     ui.separator();
     ui.label(egui::RichText::new("Resolution").strong());
     for (v, label) in [
@@ -260,7 +261,7 @@ fn compress_menu(app: &mut SlideApp, ui: &mut Ui) {
     }
     ui.data_mut(|d| d.insert_temp(id, (ppi, only_selected, delete_cropped)));
     ui.separator();
-    if ui.button("Compress").clicked() {
+    if ui.button(tl!("Compress")).clicked() {
         let p = json!({"ppi": ppi, "deleteCropped": delete_cropped, "all": !only_selected});
         match app.session.execute("picture.compress", &p) {
             Ok(r) => {
diff --git a/crates/ui-egui/src/ribbon/playback.rs b/crates/ui-egui/src/ribbon/playback.rs
--- a/crates/ui-egui/src/ribbon/playback.rs
+++ b/crates/ui-egui/src/ribbon/playback.rs
@@ -15,29 +15,29 @@ pub(super) fn playback(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         rows(ui, |ui| {
             let mut auto = m.as_ref().is_some_and(|x| x.autoplay);
-            if ui.checkbox(&mut auto, "Start Automatically").changed() {
+            if ui.checkbox(&mut auto, tl!("Start Automatically")).changed() {
                 run(app, "media.options", json!({"autoplay": auto}));
             }
             let mut across = m.as_ref().is_some_and(|x| x.play_across_slides);
-            if ui.checkbox(&mut across, "Play Across Slides").changed() {
+            if ui.checkbox(&mut across, tl!("Play Across Slides")).changed() {
                 run(app, "media.options", json!({"acrossSlides": across}));
             }
             let mut lp = m.as_ref().is_some_and(|x| x.loop_play);
-            if ui.checkbox(&mut lp, "Loop until Stopped").changed() {
+            if ui.checkbox(&mut lp, tl!("Loop until Stopped")).changed() {
                 run(app, "media.options", json!({"loop": lp}));
             }
         });
         rows(ui, |ui| {
             let mut hide = m.as_ref().is_some_and(|x| x.hide_while_not_playing);
-            if ui.checkbox(&mut hide, "Hide During Show").changed() {
+            if ui.checkbox(&mut hide, tl!("Hide During Show")).changed() {
                 run(app, "media.options", json!({"hide": hide}));
             }
             let mut rw = m.as_ref().is_some_and(|x| x.rewind);
-            if ui.checkbox(&mut rw, "Rewind after Playing").changed() {
+            if ui.checkbox(&mut rw, tl!("Rewind after Playing")).changed() {
                 run(app, "media.options", json!({"rewind": rw}));
             }
             let mut fs = m.as_ref().is_some_and(|x| x.full_screen);
-            if ui.checkbox(&mut fs, "Play Full Screen").changed() {
+            if ui.checkbox(&mut fs, tl!("Play Full Screen")).changed() {
                 run(app, "media.options", json!({"fullScreen": fs}));
             }
         });
diff --git a/crates/ui-egui/src/ribbon/qat.rs b/crates/ui-egui/src/ribbon/qat.rs
--- a/crates/ui-egui/src/ribbon/qat.rs
+++ b/crates/ui-egui/src/ribbon/qat.rs
@@ -102,11 +102,12 @@ fn run(app: &mut SlideApp, q: &QatItem) {
 }
 
 fn tip(q: &QatItem) -> String {
+    let label = crate::i18n::t(&q.label);
     let sc =
         deckcraft_engine::find_command(&q.id).and_then(|c| c.shortcut).or_else(|| crate::UI_COMMANDS.iter().find(|c| c.0 == q.id).and_then(|c| c.2));
     match sc {
-        Some(s) => format!("{} ({})", q.label, crate::ribbon::pretty_shortcut(s)),
-        None => q.label.clone(),
+        Some(s) => format!("{label} ({})", crate::ribbon::pretty_shortcut(s)),
+        None => label.to_string(),
     }
 }
 
@@ -125,7 +126,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
             run(app, q);
         }
         r.context_menu(|ui| {
-            if ui.button("Remove from Quick Access Toolbar").clicked() {
+            if ui.button(tl!("Remove from Quick Access Toolbar")).clicked() {
                 let _ = command(app, &json!({"remove": q.id, "params": q.params}));
                 ui.close();
             }
@@ -133,10 +134,10 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
     }
     let r = crate::widgets::icon_toggle(ui, Icon::ChevronDown, "Customize Quick Access Toolbar", true, false);
     egui::Popup::menu(&r).show(|ui| {
-        ui.label(egui::RichText::new("Customize Quick Access Toolbar").strong());
+        ui.label(egui::RichText::new(tl!("Customize Quick Access Toolbar")).strong());
         for c in common() {
             let mut on = app.ui.qat.iter().any(|q| q.same(&c.id, &Value::Null));
-            if ui.checkbox(&mut on, &c.label).changed() {
+            if ui.checkbox(&mut on, crate::i18n::t(&c.label)).changed() {
                 let key = if on { "add" } else { "remove" };
                 let _ = command(app, &json!({key: c.id}));
             }
@@ -146,14 +147,14 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
             ui.separator();
             for q in extra {
                 let mut on = true;
-                if ui.checkbox(&mut on, &q.label).changed() {
+                if ui.checkbox(&mut on, crate::i18n::t(&q.label)).changed() {
                     let _ = command(app, &json!({"remove": q.id, "params": q.params}));
                 }
             }
         }
         ui.separator();
-        ui.label(egui::RichText::new("Right-click any ribbon button to add it.").weak());
-        if ui.button("Reset to Defaults").clicked() {
+        ui.label(egui::RichText::new(tl!("Right-click any ribbon button to add it.")).weak());
+        if ui.button(tl!("Reset to Defaults")).clicked() {
             let _ = command(app, &json!({"reset": true}));
             ui.close();
         }
@@ -164,7 +165,7 @@ pub fn show(app: &mut SlideApp, ui: &mut Ui) {
 pub fn ribbon_menu(app: &mut SlideApp, r: &egui::Response, icon: Icon, label: &str, id: &str, p: &Value) {
     r.context_menu(|ui| {
         let has = app.ui.qat.iter().any(|q| q.same(id, p));
-        let text = if has { "Remove from Quick Access Toolbar" } else { "Add to Quick Access Toolbar" };
+        let text = if has { tl!("Remove from Quick Access Toolbar") } else { tl!("Add to Quick Access Toolbar") };
         if ui.button(text).clicked() {
             let label = label.replace('\n', " ");
             let key = if has { "remove" } else { "add" };
diff --git a/crates/ui-egui/src/ribbon/review.rs b/crates/ui-egui/src/ribbon/review.rs
--- a/crates/ui-egui/src/ribbon/review.rs
+++ b/crates/ui-egui/src/ribbon/review.rs
@@ -27,7 +27,7 @@ pub(super) fn review(app: &mut SlideApp, ui: &mut Ui) {
     });
     group(ui, |ui| {
         let r = big_button(ui, Icon::Translate, "Translate", false);
-        let _ = r.on_hover_text("Translation needs a language model (not bundled)");
+        let _ = r.on_hover_text(tl!("Translation needs a language model (not bundled)"));
         big(app, ui, Icon::Language, "Language", "app.dialog", json!({"id": "language"}));
     });
     group(ui, |ui| {
diff --git a/crates/ui-egui/src/ribbon/shape_format.rs b/crates/ui-egui/src/ribbon/shape_format.rs
--- a/crates/ui-egui/src/ribbon/shape_format.rs
+++ b/crates/ui-egui/src/ribbon/shape_format.rs
@@ -8,7 +8,7 @@ use super::*;
 fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) {
     let current = selected_warp(app);
     ui.label(egui::RichText::new("Transform").font(theme::bold(12.0)));
-    ui.label("Follow Path");
+    ui.label(tl!("Follow Path"));
     let mut picked = false;
     ui.horizontal_wrapped(|ui| {
         ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
@@ -19,7 +19,7 @@ fn transform_gallery(app: &mut SlideApp, ui: &mut Ui) {
             }
         }
     });
-    ui.label("Warp");
+    ui.label(tl!("Warp"));
     egui::ScrollArea::vertical().max_height(220.0).id_salt("text_warp_gallery").show(ui, |ui| {
         ui.horizontal_wrapped(|ui| {
             ui.spacing_mut().item_spacing = vec2(2.0, 2.0);
@@ -117,12 +117,12 @@ pub(super) fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
         big(app, ui, Icon::TextBox, "Text\nBox", "insert.textBox", json!({}));
         let r = big_button(ui, Icon::EditShape, "Edit\nShape", sel);
         egui::Popup::menu(&r).show(|ui| {
-            ui.label("Change Shape");
+            ui.label(tl!("Change Shape"));
             if let Some(p) = shapes_gallery(ui) {
                 run(app, "shape.change", json!({"preset": p}));
             }
             ui.separator();
-            if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new("Edit Points")).clicked() {
+            if ui.add_enabled(enabled(app, "shape.editPoints"), egui::Button::new(tl!("Edit Points"))).clicked() {
                 run(app, "shape.editPoints", json!({"on": true}));
                 ui.close();
             }
@@ -130,7 +130,7 @@ pub(super) fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
             smooth_corners_row(app, ui);
         });
         let can_merge = enabled(app, "shape.merge");
-        let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text("Merge Shapes");
+        let r = big_button(ui, Icon::MergeShapes, "Merge\nShapes", can_merge).on_hover_text(tl!("Merge Shapes"));
         egui::Popup::menu(&r).show(|ui| {
             for (op, label) in deckcraft_engine::cmd::merge::OPS {
                 if ui.button(label).clicked() {
@@ -160,7 +160,7 @@ pub(super) fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
                             None => run(app, "shape.fill", json!({"none": true})),
                         }
                     }
-                    if ui.button("Gradient…").clicked() {
+                    if ui.button(tl!("Gradient…")).clicked() {
                         let _ = app.run("view.pane", json!({"pane": "format", "tab": "fill"}));
                         ui.close();
                     }
@@ -204,7 +204,7 @@ pub(super) fn shape_format(app: &mut SlideApp, ui: &mut Ui) {
             }
             let r = small_button(ui, Icon::TextEffects, "Text Effects", sel, false);
             egui::Popup::menu(&r).width(312.0).show(|ui| {
-                if ui.button("Shadow").clicked() {
+                if ui.button(tl!("Shadow")).clicked() {
                     run(app, "format.textShadow", json!({}));
                     ui.close();
                 }
@@ -250,7 +250,7 @@ fn smooth_corners_row(app: &mut SlideApp, ui: &mut egui::Ui) {
     }
     let usable = radius.is_some() || ui.data(|d| d.get_temp::<(f64, f64)>(key)).is_some();
     ui.horizontal(|ui| {
-        let label = ui.label("Smooth Corners");
+        let label = ui.label(tl!("Smooth Corners"));
         let mut pct = state.1;
         let resp = ui.add_enabled(usable, egui::Slider::new(&mut pct, 0.0..=100.0).suffix("%").integer()).labelled_by(label.id);
         let resp = if usable { resp } else { resp.on_disabled_hover_text("For rectangles and rounded rectangles") };
diff --git a/crates/ui-egui/src/ribbon/slide_show.rs b/crates/ui-egui/src/ribbon/slide_show.rs
--- a/crates/ui-egui/src/ribbon/slide_show.rs
+++ b/crates/ui-egui/src/ribbon/slide_show.rs
@@ -32,20 +32,20 @@ pub(super) fn slide_show(app: &mut SlideApp, ui: &mut Ui) {
             }
         }
         let r = big_button(ui, Icon::Record, "Record", false);
-        let _ = r.on_hover_text("Recording narration needs audio capture (coming soon)");
+        let _ = r.on_hover_text(tl!("Recording narration needs audio capture (coming soon)"));
     });
     group(ui, |ui| {
         rows(ui, |ui| {
             let mut use_t = app.session.active().is_none_or(|d| d.doc.show.use_timings);
-            if ui.checkbox(&mut use_t, "Use Timings").changed() {
+            if ui.checkbox(&mut use_t, tl!("Use Timings")).changed() {
                 run(app, "show.setup", json!({"useTimings": use_t}));
             }
             let mut narr = app.session.active().is_none_or(|d| !d.doc.show.without_narration);
-            if ui.checkbox(&mut narr, "Play Narrations").changed() {
+            if ui.checkbox(&mut narr, tl!("Play Narrations")).changed() {
                 run(app, "show.setup", json!({"noNarration": !narr}));
             }
             let mut mc = app.session.active().is_none_or(|d| d.doc.show.show_media_controls);
-            if ui.checkbox(&mut mc, "Show Media Controls").changed() {
+            if ui.checkbox(&mut mc, tl!("Show Media Controls")).changed() {
                 run(app, "show.setup", json!({"showMediaControls": mc}));
             }
         });
@@ -55,7 +55,7 @@ pub(super) fn slide_show(app: &mut SlideApp, ui: &mut Ui) {
 pub(super) fn record(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         let r = big_button(ui, Icon::Cameo, "Cameo", false);
-        let _ = r.on_hover_text("Camera capture is not available yet");
+        let _ = r.on_hover_text(tl!("Camera capture is not available yet"));
         if big_button(ui, Icon::PlayFromStart, "From\nBeginning", true).clicked() {
             app.start_show(app.show_start_index(), false);
             if let Some(s) = app.show.as_mut() {
diff --git a/crates/ui-egui/src/ribbon/smartart.rs b/crates/ui-egui/src/ribbon/smartart.rs
--- a/crates/ui-egui/src/ribbon/smartart.rs
+++ b/crates/ui-egui/src/ribbon/smartart.rs
@@ -12,7 +12,7 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
     };
     let at = row.min(n.saturating_sub(1));
     group(ui, |ui| {
-        if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text("Type and organise the diagram's text").clicked() {
+        if big_button(ui, Icon::SmartArt, "Text\nPane", gid.is_some()).on_hover_text(tl!("Type and organise the diagram's text")).clicked() {
             run(app, "view.pane", json!({"pane": "smartart", "toggle": true}));
         }
         rows(ui, |ui| {
@@ -42,7 +42,7 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
         });
         let pictures = kind.starts_with("picture");
         if small_button(ui, Icon::Picture, "Insert Picture", pictures && n > 0, false)
-            .on_hover_text("Put a picture in the selected node's picture placeholder")
+            .on_hover_text(tl!("Put a picture in the selected node's picture placeholder"))
             .clicked()
         {
             run(app, "app.smartArtPictureDialog", json!({"id": gid, "index": at}));
@@ -59,10 +59,10 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
         let (main, arrow) = small_button_ex(ui, Icon::Layout, "Convert", gid.is_some(), false, true);
         if let Some(arrow) = arrow {
             widgets::split_menu(&main, &arrow, |ui| {
-                if ui.button("Convert to Text").clicked() {
+                if ui.button(tl!("Convert to Text")).clicked() {
                     run(app, "smartArt.toText", json!({"id": gid}));
                 }
-                if ui.button("Convert to Shapes").clicked() {
+                if ui.button(tl!("Convert to Shapes")).clicked() {
                     run(app, "smartArt.toShapes", json!({"id": gid}));
                 }
             });
@@ -89,7 +89,7 @@ pub(super) fn smartart_design(app: &mut SlideApp, ui: &mut Ui) {
                 for (k, label) in deckcraft_engine::cmd::smartart::STYLES {
                     if k == "polished" {
                         ui.separator();
-                        ui.label("3-D");
+                        ui.label(tl!("3-D"));
                     }
                     let on = style == k || (style.is_empty() && k == "flat");
                     if ui.selectable_label(on, label).clicked() {
diff --git a/crates/ui-egui/src/ribbon/table.rs b/crates/ui-egui/src/ribbon/table.rs
--- a/crates/ui-egui/src/ribbon/table.rs
+++ b/crates/ui-egui/src/ribbon/table.rs
@@ -12,31 +12,31 @@ pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) {
             let flags = tbl.as_ref().map(|t| (t.first_row, t.last_row, t.band_row, t.first_col, t.last_col, t.band_col)).unwrap_or_default();
             ui.horizontal(|ui| {
                 let mut a = flags.0;
-                if ui.checkbox(&mut a, "Header Row").changed() {
+                if ui.checkbox(&mut a, tl!("Header Row")).changed() {
                     run(app, "table.options", json!({"headerRow": a}));
                 }
                 let mut d = flags.3;
-                if ui.checkbox(&mut d, "First Column").changed() {
+                if ui.checkbox(&mut d, tl!("First Column")).changed() {
                     run(app, "table.options", json!({"firstColumn": d}));
                 }
             });
             ui.horizontal(|ui| {
                 let mut b = flags.1;
-                if ui.checkbox(&mut b, "Total Row").changed() {
+                if ui.checkbox(&mut b, tl!("Total Row")).changed() {
                     run(app, "table.options", json!({"totalRow": b}));
                 }
                 let mut e = flags.4;
-                if ui.checkbox(&mut e, "Last Column").changed() {
+                if ui.checkbox(&mut e, tl!("Last Column")).changed() {
                     run(app, "table.options", json!({"lastColumn": e}));
                 }
             });
             ui.horizontal(|ui| {
                 let mut c = flags.2;
-                if ui.checkbox(&mut c, "Banded Rows").changed() {
+                if ui.checkbox(&mut c, tl!("Banded Rows")).changed() {
                     run(app, "table.options", json!({"bandedRows": c}));
                 }
                 let mut f = flags.5;
-                if ui.checkbox(&mut f, "Banded Columns").changed() {
+                if ui.checkbox(&mut f, tl!("Banded Columns")).changed() {
                     run(app, "table.options", json!({"bandedColumns": f}));
                 }
             });
@@ -115,7 +115,7 @@ pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) {
                 menu_item(app, ui, Icon::Borders, label, "table.borders", p);
             }
             ui.separator();
-            ui.menu_button("Pen Weight", |ui| {
+            ui.menu_button(tl!("Pen Weight"), |ui| {
                 for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
                     let cur = app.table_pen.get("width").and_then(Value::as_f64) == Some(w);
                     if ui.radio(cur, format!("{w} pt")).clicked() {
@@ -123,7 +123,7 @@ pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) {
                     }
                 }
             });
-            ui.menu_button("Pen Style", |ui| {
+            ui.menu_button(tl!("Pen Style"), |ui| {
                 for (l, d) in [("Solid", "solid"), ("Dash", "dash"), ("Dot", "sysDot"), ("Dash Dot", "dashDot"), ("Long Dash", "lgDash")] {
                     let cur = app.table_pen.get("dash").and_then(Value::as_str).unwrap_or("solid") == d;
                     if ui.radio(cur, l).clicked() {
@@ -131,7 +131,7 @@ pub(super) fn table_design(app: &mut SlideApp, ui: &mut Ui) {
                     }
                 }
             });
-            ui.menu_button("Pen Color", |ui| {
+            ui.menu_button(tl!("Pen Color"), |ui| {
                 let sc = scheme(app);
                 if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
                     app.table_pen["color"] = cref_param(&c);
@@ -221,7 +221,7 @@ pub(super) fn table_layout(app: &mut SlideApp, ui: &mut Ui) {
                         run(app, "table.cellAlign", json!({"anchor": a}));
                     }
                 }
-                ui.menu_button("Cell Margins", |ui| {
+                ui.menu_button(tl!("Cell Margins"), |ui| {
                     for (l, preset) in [("Normal", "normal"), ("None", "none"), ("Narrow", "narrow"), ("Wide", "wide")] {
                         if ui.button(l).clicked() {
                             run(app, "table.cellMargins", json!({"preset": preset}));
diff --git a/crates/ui-egui/src/ribbon/title_bar.rs b/crates/ui-egui/src/ribbon/title_bar.rs
--- a/crates/ui-egui/src/ribbon/title_bar.rs
+++ b/crates/ui-egui/src/ribbon/title_bar.rs
@@ -68,7 +68,7 @@ pub fn title_bar(app: &mut SlideApp, ui: &mut Ui) {
             app.palette = Some((String::new(), 0));
         }
         let share = right.add(
-            egui::Button::new(egui::RichText::new("  Present  ").color(t.accent_text).font(theme::bold(13.0)))
+            egui::Button::new(egui::RichText::new(tl!("  Present  ")).color(t.accent_text).font(theme::bold(13.0)))
                 .fill(t.accent)
                 .corner_radius(control_h / 2.0)
                 .min_size(vec2(84.0, control_h)),
diff --git a/crates/ui-egui/src/ribbon/transitions.rs b/crates/ui-egui/src/ribbon/transitions.rs
--- a/crates/ui-egui/src/ribbon/transitions.rs
+++ b/crates/ui-egui/src/ribbon/transitions.rs
@@ -69,7 +69,7 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) {
         // Sound in its own column, so the timing column keeps its three rows.
         rows(ui, |ui| {
             ui.horizontal(|ui| {
-                let field = ui.label("Sound:");
+                let field = ui.label(tl!("Sound:"));
                 let stop = cur.as_ref().is_some_and(|t| t.stop_sound);
                 let name = cur
                     .as_ref()
@@ -78,10 +78,10 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) {
                     .unwrap_or_else(|| if stop { "[Stop Previous Sound]".into() } else { "[No Sound]".into() });
                 let combo = egui::ComboBox::from_id_salt("transition_sound").width(140.0).selected_text(name).show_ui(ui, |ui| {
                     let none = cur.as_ref().is_none_or(|t| t.sound.is_none() && !t.stop_sound);
-                    if ui.selectable_label(none, "[No Sound]").clicked() {
+                    if ui.selectable_label(none, tl!("[No Sound]")).clicked() {
                         run(app, "transition.sound", json!({"none": true}));
                     }
-                    if ui.selectable_label(stop, "[Stop Previous Sound]").clicked() {
+                    if ui.selectable_label(stop, tl!("[Stop Previous Sound]")).clicked() {
                         run(app, "transition.sound", json!({"stopPrevious": true}));
                     }
                     ui.separator();
@@ -93,7 +93,7 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) {
                     }
                     ui.separator();
                     let can_pick = app.services.pick_open.is_some();
-                    let r = ui.add_enabled(can_pick, egui::Button::new("Other Sound…"));
+                    let r = ui.add_enabled(can_pick, egui::Button::new(tl!("Other Sound…")));
                     if r.clicked() {
                         app.pick_transition_sound();
                     }
@@ -110,7 +110,7 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) {
         });
         rows(ui, |ui| {
             ui.horizontal(|ui| {
-                ui.label("Duration:");
+                ui.label(tl!("Duration:"));
                 let d = cur.as_ref().map(|t| t.duration_ms as f64 / 1000.0).unwrap_or(1.0);
                 if let Some(v) = widgets::spinner(ui, "tdur", "Duration", d, 0.25, "", 46.0) {
                     run(app, "transition.timing", json!({"duration": (v.max(0.0) * 1000.0).round() as u64}));
@@ -118,14 +118,14 @@ pub(super) fn transitions(app: &mut SlideApp, ui: &mut Ui) {
             });
             ui.horizontal(|ui| {
                 let mut click = cur.as_ref().is_none_or(|t| t.advance_on_click);
-                if ui.checkbox(&mut click, "On Mouse Click").changed() {
+                if ui.checkbox(&mut click, tl!("On Mouse Click")).changed() {
                     run(app, "transition.timing", json!({"onClick": click}));
                 }
             });
             ui.horizontal(|ui| {
                 let after = cur.as_ref().and_then(|t| t.advance_after_ms);
                 let mut on = after.is_some();
-                if ui.checkbox(&mut on, "After:").changed() {
+                if ui.checkbox(&mut on, tl!("After:")).changed() {
                     run(app, "transition.timing", json!({"after": if on { json!(after.unwrap_or(0)) } else { Value::Null }}));
                 }
                 if let Some(v) = widgets::spinner(ui, "tafter", "After", after.unwrap_or(0) as f64 / 1000.0, 1.0, "", 46.0) {
diff --git a/crates/ui-egui/src/ribbon/view.rs b/crates/ui-egui/src/ribbon/view.rs
--- a/crates/ui-egui/src/ribbon/view.rs
+++ b/crates/ui-egui/src/ribbon/view.rs
@@ -33,15 +33,15 @@ pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) {
     group(ui, |ui| {
         rows(ui, |ui| {
             let mut r = app.ui.ruler;
-            if ui.checkbox(&mut r, "Ruler").changed() {
+            if ui.checkbox(&mut r, tl!("Ruler")).changed() {
                 app.ui.ruler = r;
             }
             let mut g = app.ui.gridlines;
-            if ui.checkbox(&mut g, "Gridlines").changed() {
+            if ui.checkbox(&mut g, tl!("Gridlines")).changed() {
                 app.ui.gridlines = g;
             }
             let mut gd = app.ui.guides;
-            if ui.checkbox(&mut gd, "Guides").changed() {
+            if ui.checkbox(&mut gd, tl!("Guides")).changed() {
                 app.ui.guides = gd;
             }
         });
@@ -55,7 +55,7 @@ pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) {
     });
     group(ui, |ui| {
         let (main, arrow) = big_button_ex(ui, Icon::ZoomGlass, "Zoom", true, true, false);
-        if main.on_hover_text("Zoom").clicked() {
+        if main.on_hover_text(tl!("Zoom")).clicked() {
             let _ = app.run("view.zoomDialog", json!({}));
         }
         if let Some(a) = arrow {
@@ -66,7 +66,7 @@ pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) {
                         ui.close();
                     }
                 }
-                if ui.button("Fit").clicked() {
+                if ui.button(tl!("Fit")).clicked() {
                     let _ = app.run("view.fit", json!({}));
                     ui.close();
                 }
@@ -80,7 +80,7 @@ pub(super) fn view(app: &mut SlideApp, ui: &mut Ui) {
         let has = app.session.active().is_some();
         let on = app.doc_window.is_some();
         let resp = big_button_ex(ui, Icon::NewWindow, "New\nWindow", has, false, on).0;
-        if resp.on_hover_text("Open another window on this presentation").clicked() {
+        if resp.on_hover_text(tl!("Open another window on this presentation")).clicked() {
             let _ = app.run("view.newWindow", json!({}));
         }
     });
@@ -140,7 +140,7 @@ pub(super) fn zoom_menu(app: &mut SlideApp, ui: &mut Ui) {
     });
     ui.data_mut(|d| d.insert_temp(id, (kind, picked.clone())));
     ui.separator();
-    if ui.add_enabled(!picked.is_empty(), egui::Button::new("Insert")).clicked() {
+    if ui.add_enabled(!picked.is_empty(), egui::Button::new(tl!("Insert"))).clicked() {
         let mut v = picked.clone();
         v.sort_unstable();
         let p = match kind {
diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs
--- a/crates/ui-egui/src/state.rs
+++ b/crates/ui-egui/src/state.rs
@@ -51,6 +51,10 @@ pub enum ViewMode {
 #[serde(default, rename_all = "camelCase")]
 pub struct UiState {
     pub brightness: theme::Brightness,
+    /// Interface language setting: [`i18n::AUTO`] follows the system, otherwise a language code
+    /// (`ja`, `en`). Menus, the ribbon, the status bar and the command labels are drawn in it;
+    /// command ids, the presentation and file names are not.
+    pub language: String,
     pub tab: String,
     pub ribbon_collapsed: bool,
     pub view: ViewMode,
@@ -97,6 +101,7 @@ impl Default for UiState {
     fn default() -> Self {
         UiState {
             brightness: theme::Brightness::Light,
+            language: i18n::AUTO.into(),
             tab: "Home".into(),
             ribbon_collapsed: false,
             view: ViewMode::Normal,
@@ -151,6 +156,8 @@ impl UiState {
         if !short(&self.format_tab) {
             self.format_tab = d.format_tab;
         }
+        // A hand-edited or newer `language` value falls back to following the system.
+        self.language = crate::i18n::normalize_pref(&self.language).unwrap_or(crate::i18n::AUTO).to_string();
         self.pane = self.pane.filter(short);
         self.recent.retain(|p| !p.is_empty() && p.len() <= 4096 && !p.contains('\0'));
         self.recent.dedup();
diff --git a/crates/ui-egui/src/status.rs b/crates/ui-egui/src/status.rs
--- a/crates/ui-egui/src/status.rs
+++ b/crates/ui-egui/src/status.rs
@@ -17,8 +17,11 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
         let Some(st) = app.session.active() else { return };
         let n = st.doc.slides.len();
         let left = match st.selection.target {
-            deckcraft_engine::Target::Slides => format!("Slide {} of {}", (st.selection.slide + 1).min(n.max(1)), n),
-            _ => "Slide Master".to_string(),
+            deckcraft_engine::Target::Slides => {
+                let current = (st.selection.slide + 1).min(n.max(1)).to_string();
+                crate::i18n::fmt(tl!("Slide {current} of {total}"), &[("current", &current), ("total", &n.to_string())])
+            }
+            _ => tl!("Slide Master").to_string(),
         };
         let p = ui.painter();
         // Left to right: slide counter, language, status message. Each starts at its usual column, or
@@ -29,14 +32,14 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
         let right_x = rect.max.x - 560.0;
         // The selection's proofing language; a click opens Review › Language.
         let (lang, no_proof) = crate::dialogs::selected_language(app);
-        let name = crate::squiggles::display_lang_name(ui.ctx(), &lang) + if no_proof { " (not checked)" } else { "" };
+        let name = crate::squiggles::display_lang_name(ui.ctx(), &lang) + if no_proof { tl!(" (not checked)") } else { "" };
         let lx = (rect.min.x + 120.0).max(sr.max.x + 16.0);
         let lclip = Rect::from_min_max(pos2(lx - 3.0, rect.min.y), pos2(right_x.max(lx - 3.0), rect.max.y));
         let lr = p.with_clip_rect(lclip).text(pos2(lx, rect.center().y), Align2::LEFT_CENTER, &name, theme::font(12.0), t.text_dim);
         let hit = lr.expand(3.0).intersect(lclip);
         if hit.is_positive() {
-            let resp = ui.interact(hit, ui.scope_id().with("status_lang"), Sense::click()).on_hover_text("Language");
-            resp.widget_info(|| named_with_value("Language", &name));
+            let resp = ui.interact(hit, ui.scope_id().with("status_lang"), Sense::click()).on_hover_text(tl!("Language"));
+            resp.widget_info(|| named_with_value(tl!("Language"), &name));
             if resp.clicked() {
                 app.dialog = Some(crate::dialogs::Dialog::new("language"));
             }
@@ -55,7 +58,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
                 .layout(egui::Layout::right_to_left(egui::Align::Center)),
         );
         right.spacing_mut().item_spacing.x = 4.0;
-        if small_icon(&mut right, Icon::FitSmall, "Fit slide to current window", false).clicked() {
+        if small_icon(&mut right, Icon::FitSmall, tl!("Fit slide to current window"), false).clicked() {
             app.ui.zoom = None;
         }
         let pct = (app.canvas_scale * 100.0).round();
@@ -64,7 +67,7 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
         if lbl.clicked() {
             let _ = app.run("view.zoomDialog", json!({}));
         }
-        if small_icon(&mut right, Icon::ZoomIn, "Zoom In", false).clicked() {
+        if small_icon(&mut right, Icon::ZoomIn, tl!("Zoom In"), false).clicked() {
             let _ = app.run("view.zoomIn", json!({}));
         }
         // Slider 10%–400% (log scale).
@@ -84,31 +87,31 @@ pub fn status_bar(app: &mut SlideApp, ui: &mut Ui) {
             z = zp / 100.0;
             app.ui.zoom = Some(z);
         }
-        if small_icon(&mut right, Icon::ZoomOut, "Zoom Out", false).clicked() {
+        if small_icon(&mut right, Icon::ZoomOut, tl!("Zoom Out"), false).clicked() {
             let _ = app.run("view.zoomOut", json!({}));
         }
         right.add_space(8.0);
         let view = app.ui.view;
-        if view_icon(&mut right, Icon::ViewShowSmall, "Slide Show", false).clicked() {
+        if view_icon(&mut right, Icon::ViewShowSmall, tl!("Slide Show"), false).clicked() {
             let i = app.session.active().map(|d| d.selection.slide).unwrap_or(0);
             app.start_show(i, false);
         }
-        if view_icon(&mut right, Icon::ViewReadingSmall, "Reading View", view == ViewMode::Reading).clicked() {
+        if view_icon(&mut right, Icon::ViewReadingSmall, tl!("Reading View"), view == ViewMode::Reading).clicked() {
             let _ = app.run("view.reading", json!({}));
         }
-        if view_icon(&mut right, Icon::ViewSorterSmall, "Slide Sorter", view == ViewMode::Sorter).clicked() {
+        if view_icon(&mut right, Icon::ViewSorterSmall, tl!("Slide Sorter"), view == ViewMode::Sorter).clicked() {
             app.ui.view = ViewMode::Sorter;
         }
-        if view_icon(&mut right, Icon::ViewNormalSmall, "Normal", view == ViewMode::Normal).clicked() {
+        if view_icon(&mut right, Icon::ViewNormalSmall, tl!("Normal"), view == ViewMode::Normal).clicked() {
             app.ui.view = ViewMode::Normal;
         }
         right.add_space(8.0);
         let c_on = app.ui.pane.as_deref() == Some("comments");
-        if labeled(&mut right, Icon::CommentsSmall, "Comments", c_on).clicked() {
+        if labeled(&mut right, Icon::CommentsSmall, tl!("Comments"), c_on).clicked() {
             let _ = app.run("view.pane", json!({"pane": "comments", "toggle": true}));
         }
         let n_on = app.ui.notes;
-        if labeled(&mut right, Icon::NotesSmall, "Notes", n_on).clicked() {
+        if labeled(&mut right, Icon::NotesSmall, tl!("Notes"), n_on).clicked() {
             app.ui.notes = !n_on;
         }
         let _ = Stroke::NONE;
@@ -215,7 +218,7 @@ pub fn notes_pane(app: &mut SlideApp, ui: &mut Ui) {
                 .auto_shrink([false, false])
                 .show(ui, |ui| {
                     crate::notes::editor(ui, &mut draft, &look, |e| {
-                        e.id(id).hint_text("Click to add notes").frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2)
+                        e.id(id).hint_text(tl!("Click to add notes")).frame(egui::Frame::NONE).desired_width(f32::INFINITY).desired_rows(2)
                     })
                 })
                 .inner;
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -49,6 +49,8 @@ fn big_button_width(text_w: f32, split: bool) -> f32 {
 /// response). Like Office's split buttons, the icon and the label text run the main command; only
 /// the chevron at the end of the label row opens the menu.
 pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: bool, selected: bool) -> (Response, Option<Response>) {
+    // The English label is the catalog key; a translation may break the two lines itself (`\n`).
+    let label = crate::i18n::t(label);
     let t = Tokens::get(ui.ctx());
     let font = theme::font(11.0);
     let lines: Vec<&str> = wrap_label(label);
@@ -133,6 +135,7 @@ pub fn small_button(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selecte
 /// Draw a small named button, optionally with a separately named options chevron, and return
 /// both click targets.
 pub fn small_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, selected: bool, split: bool) -> (Response, Option<Response>) {
+    let label = crate::i18n::t(label);
     small_button_named(ui, icon, label, if label.is_empty() { "Options" } else { label }, enabled, selected, split)
 }
 
@@ -244,6 +247,7 @@ fn split_popup_id(main: &Response) -> egui::Id {
 /// A menu choice ticked in the icon column (Align to Slide…), its text lined up with the
 /// [`small_button`] items around it.
 pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response {
+    let label = crate::i18n::t(label);
     let t = Tokens::get(ui.ctx());
     let font = theme::font(12.0);
     let tw = ui.fonts_mut(|f| f.layout_no_wrap(label.to_string(), font.clone(), t.text).size().x) + 5.0;
@@ -262,6 +266,7 @@ pub fn check_item(ui: &mut Ui, label: &str, enabled: bool, on: bool) -> Response
 
 /// A square icon toggle (B, I, U, alignment…).
 pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool) -> Response {
+    let tip = crate::i18n::t(tip);
     let t = Tokens::get(ui.ctx());
     let (rect, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), if enabled { Sense::click() } else { Sense::hover() });
     let painter = ui.painter_at(rect);
@@ -276,6 +281,7 @@ pub fn icon_toggle(ui: &mut Ui, icon: Icon, tip: &str, enabled: bool, on: bool)
 
 /// Icon with a colour bar under it and a dropdown arrow (Font Color, Highlight, Shape Fill…).
 pub fn color_split(ui: &mut Ui, icon: Icon, tip: &str, color: Color32, enabled: bool) -> (Response, Response) {
+    let tip = crate::i18n::t(tip);
     let t = Tokens::get(ui.ctx());
     let (rect, resp) = ui.allocate_exact_size(vec2(34.0, 22.0), Sense::hover());
     let main = ui.interact(Rect::from_min_size(rect.min, vec2(23.0, 22.0)), resp.id.with("m"), if enabled { Sense::click() } else { Sense::hover() });
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -22,7 +22,7 @@ revision history.
 | 2 | Data loss and crashes on common paths (#60, #40, #59) | Stability | B | 6–12 | this file |
 | 3 | PPTX round trip drops media options and rewind (#74, #77) | File format | B | 2–4 | [file-format-parity.md](file-format-parity.md) |
 | 4 | SVG pictures don't render (EMF/WMF do) | File format | B | 4–8 | [file-format-parity.md](file-format-parity.md) |
-| 5 | No localization: English only | Localization | B (infra + 5 languages) | 55–90 | [localization-parity.md](localization-parity.md) |
+| 5 | UI English, first Japanese catalog in review: menus, ribbon, status bar and Preferences translated; dialogs, tooltips and help still English | Localization | B (5 languages) | 45–80 | [localization-parity.md](localization-parity.md) |
 | 6 | No printing | Feature | B | 5–8 | [file-format-parity.md](file-format-parity.md) |
 | 7 | Fonts: only Regular, Bold and Italic faces (#69) | Feature | B | 3–5 | [ui-parity.md](ui-parity.md) |
 | 8 | Image paste fails on Windows and Linux (#53) | Feature | B | 2–4 | [ui-parity.md](ui-parity.md) |
@@ -335,9 +335,13 @@ Detail in [hardware-parity.md](hardware-parity.md).
 
 Detail in [localization-parity.md](localization-parity.md).
 
-- **English only, no string catalog** (#15, #30; Ukrainian PR #34 open). PowerPoint for Mac ships
-  26 languages. Infrastructure 8–14 h, then 3–16 h per language plus native review. Beta needs the
-  infrastructure and at least Simplified Chinese, Spanish, French, German and Japanese (~30–45 h).
+- **A Japanese catalog is the first, and most of the UI is still English** (#15, #30; Ukrainian
+  PR #34 open). The catalog system, system-language following and the Interface language setting
+  landed with the Japanese catalog (554 entries: menus, ribbon, status bar, command labels and
+  Preferences); dialog bodies, tooltips, help and the File-tab pages still show English. PowerPoint
+  for Mac ships 26 languages. A new language is its catalog file plus one row in `LANGUAGES`
+  (3–16 h plus native review). Beta needs at least Simplified Chinese, Spanish, French, German and
+  Japanese (~30–45 h), with Japanese partly there.
 - **Hangul in the interface needs an installed Korean font** (#101). craft-fonts has no Korean face
   (Noto CJK is excluded by the asset policy), so the web build draws Korean file names, notes and
   outline text as boxes; desktop borrows Malgun Gothic, Apple SD Gothic Neo or Noto Sans CJK.
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,12 +1,18 @@
 # Localization parity with PowerPoint
 
-> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-24 · **Change:** minor (opt-in native LXGW WenKai Lite fallback and interface CJK tests; local implementation only, managed validation pending; previous: minor, Japanese interface, first step: a per-language TSV catalog, system-language following and the Interface language setting; the menus, ribbon, status bar, command labels and the Preferences dialog are drawn in Japanese, other dialog bodies still English; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (one proofing language for the whole presentation; previous: minor, IME composition underlined in table cells; previous: minor, IME composition and CJK interface text, #101; previous: major, created) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 
-DeckCraft's interface is English only today: strings are literals in the UI and engine code, with
-no catalog and no language switch. Requests: #15 (follow the system language), #30 and PR #34
-(Ukrainian, open). Dimension score: **~8% ready** (estimated: English complete, slide-text script
-support partly there), **55–90 h** to parity plus native-speaker review.
+DeckCraft's interface is written in English, and a catalog per language
+(`crates/ui-egui/src/i18n/*.tsv`) translates the strings the UI draws: English strings are the
+keys, so a string without a translation shows in English and coverage grows one entry at a time.
+The `language` setting follows the operating system by default; Preferences ▸ Interface language
+picks English or 日本語 by hand. The Japanese catalog (554 entries) covers the menus, ribbon tabs
+and buttons, status bar, command labels, command palette and the Preferences dialog; most dialog
+bodies and the File-tab page content still show English. Requests: #15 (follow the system
+language), #30 and PR #34 (Ukrainian, open). Dimension score: **~8% ready** (estimated: English
+complete, slide-text script support partly there), **55–90 h** to parity plus native-speaker
+review.
 
 ## How the numbers are measured
 
@@ -33,21 +39,22 @@ support partly there), **55–90 h** to parity plus native-speaker review.
 | French | fr | 0 (0%) | no | full (Latin) | no | none | yes (fr, fr-CA) | 3–5 |
 | Portuguese | pt | 0 (0%) | no | full (Latin) | no | none | yes (pt-BR, pt-PT) | 3–5 |
 | Indonesian | id | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
-| Japanese | ja | 0 (0%) | no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | none | yes | 4–7 |
+| Japanese | ja | 554 of ~1,050 (53%) | menus, ribbon, status bar, Preferences and command labels yes; other dialog bodies, tooltips and help no | renders with system or craft-fonts Japanese faces; IME composition (#101); vertical text is rotated, not laid out vertically | no | partial (in review) | yes | 4–7 |
 | German | de | 0 (0%) | no | full (Latin) | no | none | yes | 3–5 |
 | Korean | ko | 0 (0%) | no | system Hangul fonts, in the interface too; IME composition (#101) | no | none | yes | 4–7 |
 | Vietnamese | vi | 0 (0%) | no | stacked diacritics through HarfRust | no | none | **no** (Windows only) | 3–5 |
 
-Other languages shipped: **0** (Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more
+Other languages shipped: **0** (Japanese, the first catalog, is in review with this change; Ukrainian is in review as PR #34). PowerPoint for Mac ships 16 more
 than the twelve above: Czech, Danish, Greek, Finnish, Hebrew, Hungarian, Italian, Dutch, Norwegian,
 Polish, Russian, Slovak, Swedish, Thai, Turkish and Traditional Chinese.
 
-Per-language hours assume the infrastructure exists: **8–14 h** once for a string catalog (copy a
-sibling app's approach, e.g. EffectCraft's menu and panel catalogs), a language setting that follows
-the system (#15), plural and number formatting, and an `xtask` check that every key has a
-translation. IME composition landed with #101 (slide text, table cells, notes and text fields;
-one undo step per composition, underlined in place); the hours that included it are kept until
-the next re-measure.
+Per-language hours assume the infrastructure exists. That infrastructure is now in DeckCraft: the
+sibling apps' TSV catalog format, system-language following, the Interface language setting and
+catalog tests (format, duplicates, placeholders, well-formed bundled files), so a new language is
+its catalog file plus one row in `LANGUAGES`. Remaining infrastructure work is plural and number
+formatting and an `xtask` check that every key has a translation. IME composition landed with #101
+(slide text, table cells, notes and text fields; one undo step per composition, underlined in
+place); the hours that included it are kept until the next re-measure.
 
 ## Proofing languages
 
@@ -62,6 +69,7 @@ of every text in the file and the default for new text (`format.languageAll`).
 | Date | Change | Summary |
 |---|---|---|
 | 2026-10-24 | minor | Local 0.37 candidate adds an opt-in native LXGW WenKai Lite glyph fallback and CJK interface-test coverage. This records local implementation only: managed tests, formatting, clippy, wasm-size and native GUI qualification remain pending. Browser font budget and uniform Chinese typography are unchanged. |
+| 2026-10-21 | major | Japanese interface, first step: the TSV catalog system ported from the sibling apps, system-language following, the Interface language setting (auto \| English \| 日本語) in Preferences, and 554 Japanese strings covering the menus, ribbon, status bar, command labels, command palette and the Preferences dialog; other dialog bodies still English |
 | 2026-10-21 | minor | Review › Language can set the proofing language of the whole presentation |
 | 2026-10-14 | minor | IME composition in a table cell is underlined in place; the candidate window sits on that cell's caret |
 | 2026-10-12 | minor | IME composition for Chinese, Japanese and Korean, and interface text in any installed script (#101) |
diff --git a/docs/roadmap.md b/docs/roadmap.md
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -1,6 +1,7 @@
 # DeckCraft roadmap: milestones and what's next
 
 > **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-22 · **Change:** trivial (beta item 9 drops Play From, now in; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
+> **Last reviewed:** 2026-10-14 · **Last updated:** 2026-10-21 · **Change:** minor (Japanese interface, first step: the string catalog exists, so the Ukrainian review item points at it; previous: minor, native chart payloads stored in bounded ZIP members; previous: major, community PR wave 2026-10-14: PPTX corpus, presenter and show controls, review navigation, starter decks, Grid Options and New Window, WordArt gallery and handles, pen pressure; beta items 9 and 10 narrowed; duplicated rows and headers folded; previous: minor, core-workflow alpha gate added) · **Target:** Microsoft PowerPoint for Mac 16.113 (Microsoft 365)
 
 Forward-looking: where DeckCraft is going and in what order. The one-page summary is
 [ROADMAP.md](../ROADMAP.md); every shortfall is in [gaps.md](gaps.md); numbers are in
@@ -34,7 +35,7 @@ points and lead the beta plan.
    this repo, media options and rewind on save (#74, #77), relationship-bearing SmartArt and OLE
    (#203), EMF+ records and SVG. 31–55 h.
 3. **Review the open PRs:** equations (#55), browser print (#66), Morph alignment (#67), macOS
-   open-with (#68), font lookup (#80), Ukrainian (#34, after the catalog exists). 4–8 h.
+   open-with (#68), font lookup (#80), Ukrainian (#34; the string catalog it needs now exists). 4–8 h.
 
 ## Plan to beta (~120–210 h; ~40–70 h wall clock with 3–4 agents)
 
@@ -116,6 +117,7 @@ plan above.
 | Date | Change | Summary |
 |---|---|---|
 | 2026-10-22 | trivial | Beta item 9: Animation Pane ▸ Play From landed, the item keeps effect options (3–6 h) |
+| 2026-10-21 | minor | Japanese interface, first step: the string catalog landed, so the Ukrainian review item points at it. Milestones, scores and hours unchanged |
 | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |
 | 2026-10-15 | trivial | Native save/reopen retains imported chart workbooks and related parts; headless command tests cover a PPTX → native → PPTX cycle and stale-workbook rejection. Milestone scope and scores unchanged |
 | 2026-10-15 | trivial | PPTX trigger click steps survive save, including by-paragraph builds. Interactive-deck gaps remain media actions, audio triggers and click-box geometry; milestone scope and feature scores unchanged |
