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
@@ -754,6 +754,10 @@ impl LauncherUi {
         // Set the current language only after the font gate: a face installed by `set_fonts`
         // becomes active next pass, so drawing with it in the pass that installed it would panic.
         set_current(drawable_lang(&self.settings.language, self.cjk_font));
+        let rtl = crate::i18n::current().rtl();
+        if ctx.global_style().right_to_left != rtl {
+            ctx.all_styles_mut(|style| style.right_to_left = rtl);
+        }
         self.zoom_input(&ctx);
         if self.applied_theme != Some(self.settings.theme) {
             theme::apply(&ctx, self.settings.theme);
diff --git a/crates/ui-egui/src/card_parts.rs b/crates/ui-egui/src/card_parts.rs
--- a/crates/ui-egui/src/card_parts.rs
+++ b/crates/ui-egui/src/card_parts.rs
@@ -31,6 +31,9 @@ pub(crate) fn last_updated(e: &AppEntry) -> Option<String> {
 /// `text` in at most `rows` lines, ending in "…" when it is cut.
 pub(crate) fn blurb(ui: &mut Ui, text: &str, color: Color32, rows: usize) {
     let mut job = egui::text::LayoutJob::simple(text.to_string(), FontId::proportional(13.0), color, ui.available_width());
+    if crate::i18n::current().rtl() {
+        job.direction = egui::epaint::text::TextDirection::RightToLeft;
+    }
     job.wrap.max_rows = rows;
     job.wrap.break_anywhere = false;
     job.wrap.overflow_character = Some('…');
diff --git a/crates/ui-egui/src/cjk_font.rs b/crates/ui-egui/src/cjk_font.rs
--- a/crates/ui-egui/src/cjk_font.rs
+++ b/crates/ui-egui/src/cjk_font.rs
@@ -40,6 +40,7 @@ fn script_name(script: Script) -> &'static str {
     match script {
         Script::Chinese => "Chinese",
         Script::Japanese => "Japanese",
+        Script::Hebrew => "Hebrew",
     }
 }
 
@@ -48,6 +49,9 @@ fn script_families(script: Script) -> &'static str {
     match script {
         Script::Chinese => "PingFang SC, Hiragino Sans GB, Heiti SC, Microsoft YaHei, SimSun, Noto Sans CJK SC, WenQuanYi and MiSans",
         Script::Japanese => "Hiragino Sans, Yu Gothic, Meiryo, MS Gothic, Noto Sans CJK JP, Noto Sans JP, IPAGothic and TakaoGothic",
+        Script::Hebrew => {
+            "SF Hebrew, Arial Hebrew, Arial, Segoe UI, Noto Sans Hebrew, Heebo, Rubik, Assistant, David Libre, FreeSans and DejaVu Sans"
+        }
     }
 }
 
@@ -84,6 +88,7 @@ pub(crate) fn load(script: Script) -> Option<FontData> {
     None
 }
 
+/// Reads a font file up to [`MAX_FONT_BYTES`], returning `None` if missing, not a regular file, or oversized.
 fn read(path: &Path) -> Option<Vec<u8>> {
     let meta = std::fs::metadata(path).ok()?;
     if !meta.is_file() {
@@ -123,6 +128,7 @@ fn candidates(script: Script) -> Vec<Candidate> {
     match script {
         Script::Chinese => chinese_candidates(),
         Script::Japanese => japanese_candidates(),
+        Script::Hebrew => hebrew_candidates(),
     }
 }
 
@@ -305,6 +311,76 @@ fn japanese_candidates() -> Vec<Candidate> {
     out
 }
 
+/// The free OFL Hebrew faces a user may install by hand, by family preference.
+const HEBREW_USER_FILES: [(&str, &str); 6] = [
+    ("NotoSansHebrew-Regular.ttf", "Noto Sans Hebrew"),
+    ("Rubik-Regular.ttf", "Rubik"),
+    ("Heebo-Regular.ttf", "Heebo"),
+    ("Assistant-Regular.ttf", "Assistant"),
+    ("DavidLibre-Regular.ttf", "David Libre"),
+    ("Alef-Regular.ttf", "Alef"),
+];
+
+/// Where each system keeps a Hebrew font, best first.
+fn hebrew_candidates() -> Vec<Candidate> {
+    let mut out = Vec::new();
+    let mut add = |path: PathBuf, family: &'static str| out.push(Candidate { path, family });
+    #[cfg(target_os = "macos")]
+    {
+        add("/System/Library/Fonts/SFHebrew.ttf".into(), "SF Hebrew");
+        for path in mac_font_assets("SFHebrew.ttf") {
+            add(path, "SF Hebrew");
+        }
+        add("/System/Library/Fonts/ArialHB.ttc".into(), "Arial Hebrew");
+        add("/System/Library/Fonts/Supplemental/Arial Unicode.ttf".into(), "Arial Unicode MS");
+        add("/System/Library/Fonts/Supplemental/Arial.ttf".into(), "Arial");
+    }
+    #[cfg(target_os = "windows")]
+    {
+        let root = std::env::var_os("SystemRoot").map_or_else(|| PathBuf::from(r"C:\Windows"), PathBuf::from);
+        add(root.join("Fonts").join("segoeui.ttf"), "Segoe UI");
+        add(root.join("Fonts").join("arial.ttf"), "Arial");
+        add(root.join("Fonts").join("david.ttf"), "David");
+    }
+    #[cfg(all(unix, not(target_os = "macos")))]
+    {
+        const NOTO_DIRS: [&str; 8] = [
+            "/usr/share/fonts/truetype/noto",
+            "/usr/share/fonts/opentype/noto",
+            "/usr/share/fonts/noto",
+            "/usr/share/fonts/google-noto-vf",
+            "/usr/share/fonts/google-noto-sans-hebrew-vf-fonts",
+            "/usr/share/fonts/google-noto-sans-hebrew-fonts",
+            "/usr/local/share/fonts/noto",
+            "/usr/local/share/fonts",
+        ];
+        for file in ["NotoSansHebrew-Regular.ttf", "NotoSansHebrew-Regular.otf", "NotoSansHebrew[wdth,wght].ttf"] {
+            for dir in NOTO_DIRS {
+                add(Path::new(dir).join(file), "Noto Sans Hebrew");
+            }
+        }
+        const FALLBACK_DIRS: [&str; 6] = [
+            "/usr/share/fonts/truetype/freefont",
+            "/usr/share/fonts/gnu-free",
+            "/usr/share/fonts/truetype/dejavu",
+            "/usr/share/fonts/TTF",
+            "/usr/share/fonts/dejavu-sans-fonts",
+            "/usr/local/share/fonts",
+        ];
+        for (file, family) in [("FreeSans.ttf", "FreeSans"), ("DejaVuSans.ttf", "DejaVu Sans")] {
+            for dir in FALLBACK_DIRS {
+                add(Path::new(dir).join(file), family);
+            }
+        }
+    }
+    for (file, family) in HEBREW_USER_FILES {
+        for dir in user_font_dirs() {
+            add(dir.join(file), family);
+        }
+    }
+    out
+}
+
 /// `file` in macOS's downloaded font assets:
 /// `/System/Library/AssetsV2/com_apple_MobileAsset_Font*/*.asset/AssetData/<file>`.
 #[cfg(target_os = "macos")]
@@ -394,6 +470,18 @@ mod tests {
         assert!(first_misans > 0, "the system's own fonts come first");
     }
 
+    /// System Hebrew fonts come before user-installed OFL Hebrew faces, and every path is absolute.
+    #[test]
+    fn hebrew_candidates_include_system_and_user_fonts() {
+        let cands = hebrew_candidates();
+        let names: Vec<String> = cands.iter().filter_map(|c| c.path.file_name().map(|n| n.to_string_lossy().into_owned())).collect();
+        assert!(names.iter().any(|n| n == "NotoSansHebrew-Regular.ttf"), "{names:?}");
+        assert!(names.iter().any(|n| n == "Rubik-Regular.ttf"), "{names:?}");
+        assert!(cands.iter().all(|c| c.path.is_absolute() || c.path.starts_with("/")));
+        let first_rubik = cands.iter().position(|c| c.family == "Rubik").unwrap();
+        assert!(first_rubik > 0, "the system's own Hebrew fonts come first");
+    }
+
     #[test]
     fn face_index_finds_families_in_real_fonts() {
         // The bundled Latin faces stand in for system fonts the test machine may not have.
diff --git a/crates/ui-egui/src/i18n/lang.rs b/crates/ui-egui/src/i18n/lang.rs
--- a/crates/ui-egui/src/i18n/lang.rs
+++ b/crates/ui-egui/src/i18n/lang.rs
@@ -13,30 +13,33 @@ use super::detect::system_lang;
 pub enum Script {
     Chinese,
     Japanese,
+    Hebrew,
 }
 
 impl Script {
     /// Every script a language can be written in.
-    pub const ALL: [Script; 2] = [Script::Chinese, Script::Japanese];
+    pub const ALL: [Script; 3] = [Script::Chinese, Script::Japanese, Script::Hebrew];
 }
 
 /// One supported UI language.
 pub struct LangInfo {
-    /// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`).
+    /// Canonical BCP 47 code (`en`, `ru`, `zh-CN`, `de`, `fr`, `pt-BR`, `es`, `cs`, `pl`, `hu`, `ja`, `he`).
     /// Also the `interfaceLanguage` value.
     pub code: &'static str,
     /// The language's name in itself, shown in the Preferences dropdown.
     pub name: &'static str,
     /// The language's name in English, shown instead where its own script can't be drawn.
     pub english_name: &'static str,
-    /// The writing system this language is drawn in, or `None` if it needs no system CJK font.
+    /// The writing system this language is drawn in, or `None` if it needs no system font.
     pub script: Option<Script>,
+    /// Whether this language is written right-to-left (Hebrew, Arabic).
+    pub rtl: bool,
     /// Catalog file contents (empty for the built-in English).
     pub source: &'static str,
     /// Plural form index for a count (English, German, Spanish and Hungarian: 0 = one, 1 = other;
     /// Russian: 0 = one, 1 = few, 2 = many; Czech: 0 = one, 1 = few, 2 = other; Polish: 0 = one only
-    /// for 1, 1 = few, 2 = many; French: 0 = one for 0 and 1; a language whose nouns don't inflect
-    /// for number: always 0).
+    /// for 1, 1 = few, 2 = many; Hebrew: 0 = one, 1 = two, 2 = other; French: 0 = one for 0 and 1;
+    /// a language whose nouns don't inflect for number: always 0).
     pub plural: fn(u64) -> usize,
     /// Number of plural forms the catalog's `@plural` rows carry (checked by tests).
     pub plural_forms: usize,
@@ -93,13 +96,23 @@ pub(super) fn plural_polish(n: u64) -> usize {
     }
 }
 
+/// Hebrew integer counts (CLDR 44+): 1 is `one` (0), 2 is `two` (1), everything else is `other` (2).
+pub(super) fn plural_hebrew(n: u64) -> usize {
+    match n {
+        1 => 0,
+        2 => 1,
+        _ => 2,
+    }
+}
+
 /// The registry. English first: it is the fallback and the source language.
-pub static LANGUAGES: [LangInfo; 11] = [
+pub static LANGUAGES: [LangInfo; 12] = [
     LangInfo {
         code: "en",
         name: "English",
         english_name: "English",
         script: None,
+        rtl: false,
         source: "",
         plural: plural_one_other,
         plural_forms: 2,
@@ -111,6 +124,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
         name: "Русский",
         english_name: "Russian",
         script: None,
+        rtl: false,
         source: include_str!("ru.tsv"),
         plural: plural_east_slavic,
         plural_forms: 3,
@@ -122,6 +136,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
         name: "简体中文",
         english_name: "Simplified Chinese",
         script: Some(Script::Chinese),
+        rtl: false,
         source: include_str!("zh-CN.tsv"),
         plural: plural_none,
         plural_forms: 1,
@@ -133,6 +148,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
         name: "Deutsch",
         english_name: "German",
         script: None,
+        rtl: false,
         source: include_str!("de.tsv"),
         plural: plural_one_other,
         plural_forms: 2,
@@ -144,6 +160,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
         name: "Français",
         english_name: "French",
         script: None,
+        rtl: false,
         source: include_str!("fr.tsv"),
         plural: plural_zero_one,
         plural_forms: 2,
@@ -155,6 +172,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
         name: "Português (Brasil)",
         english_name: "Portuguese (Brazil)",
         script: None,
+        rtl: false,
         source: include_str!("pt-BR.tsv"),
         plural: plural_one_other,
         plural_forms: 2,
@@ -166,6 +184,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
         name: "Español",
         english_name: "Spanish",
         script: None,
+        rtl: false,
         source: include_str!("es.tsv"),
         plural: plural_one_other,
         plural_forms: 2,
@@ -177,6 +196,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
         name: "Čeština",
         english_name: "Czech",
         script: None,
+        rtl: false,
         source: include_str!("cs.tsv"),
         plural: plural_czech,
         plural_forms: 3,
@@ -188,6 +208,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
         name: "Polski",
         english_name: "Polish",
         script: None,
+        rtl: false,
         source: include_str!("pl.tsv"),
         plural: plural_polish,
         plural_forms: 3,
@@ -199,6 +220,7 @@ pub static LANGUAGES: [LangInfo; 11] = [
         name: "Magyar",
         english_name: "Hungarian",
         script: None,
+        rtl: false,
         source: include_str!("hu.tsv"),
         plural: plural_one_other,
         plural_forms: 2,
@@ -210,15 +232,29 @@ pub static LANGUAGES: [LangInfo; 11] = [
         name: "日本語",
         english_name: "Japanese",
         script: Some(Script::Japanese),
+        rtl: false,
         source: include_str!("ja.tsv"),
         plural: plural_none,
         plural_forms: 1,
         complete_menus: true,
         catalog: OnceLock::new(),
     },
+    LangInfo {
+        code: "he",
+        name: "עברית",
+        english_name: "Hebrew",
+        script: Some(Script::Hebrew),
+        rtl: true,
+        source: "",
+        plural: plural_hebrew,
+        plural_forms: 3,
+        complete_menus: false,
+        catalog: OnceLock::new(),
+    },
 ];
 
 impl LangInfo {
+    /// Lazily parses and returns this language's translation catalog.
     pub(super) fn catalog(&self) -> &Catalog {
         self.catalog.get_or_init(|| Catalog::parse(self.source))
     }
@@ -254,7 +290,9 @@ impl Lang {
     pub const PL: Lang = Lang(&LANGUAGES[8]);
     pub const HU: Lang = Lang(&LANGUAGES[9]);
     pub const JA: Lang = Lang(&LANGUAGES[10]);
+    pub const HE: Lang = Lang(&LANGUAGES[11]);
 
+    /// Canonical BCP 47 language code (`en`, `he`, `zh-CN`, …).
     pub fn code(self) -> &'static str {
         self.0.code
     }
@@ -286,6 +324,7 @@ impl Lang {
         std::iter::once(Lang::EN).chain(rest).collect()
     }
 
+    /// The language's autonym (native display name).
     pub fn name(self) -> &'static str {
         self.0.name
     }
@@ -300,11 +339,16 @@ impl Lang {
         self.0.script.is_some()
     }
 
-    /// The writing system this language is drawn in, if it needs a system CJK font.
+    /// The writing system this language is drawn in, if it needs a system font.
     pub fn script(self) -> Option<Script> {
         self.0.script
     }
 
+    /// Is this language written right-to-left?
+    pub fn rtl(self) -> bool {
+        self.0.rtl
+    }
+
     /// Does this language's catalog claim to cover every `tl!` literal?
     pub fn complete_menus(self) -> bool {
         self.0.complete_menus
@@ -315,6 +359,7 @@ impl Lang {
         LANGUAGES.iter().position(|l| l.code == self.0.code).unwrap_or(0)
     }
 
+    /// Returns the parsed translation catalog for this language.
     pub(super) fn catalog(self) -> &'static Catalog {
         self.0.catalog()
     }
@@ -337,7 +382,11 @@ pub(super) fn candidates(tag: &str) -> Vec<String> {
         return Vec::new();
     }
     let base = head.replace('_', "-").to_ascii_lowercase();
-    let parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).take(MAX_SUBTAGS).collect();
+    let mut parts: Vec<&str> = base.split('-').filter(|p| !p.is_empty()).take(MAX_SUBTAGS).collect();
+    // Normalize the deprecated ISO 639-1 code for Hebrew (`iw` -> `he`).
+    if let Some(first @ &mut "iw") = parts.first_mut() {
+        *first = "he";
+    }
     (1..=parts.len()).rev().filter_map(|n| parts.get(..n)).map(|p| p.join("-")).collect()
 }
 
diff --git a/crates/ui-egui/src/i18n/tests.rs b/crates/ui-egui/src/i18n/tests.rs
--- a/crates/ui-egui/src/i18n/tests.rs
+++ b/crates/ui-egui/src/i18n/tests.rs
@@ -4,7 +4,7 @@
 use std::collections::{BTreeSet, HashSet};
 
 use super::catalog::{Catalog, unescape};
-use super::lang::{name_sort_key, plural_czech, plural_east_slavic, plural_polish};
+use super::lang::{candidates, name_sort_key, plural_czech, plural_east_slavic, plural_hebrew, plural_polish};
 use super::{LANGUAGES, Lang, LangInfo, lang_from_tag, message, tr, trn};
 
 /// Each catalog's rows as (line number, context, source, translation), split exactly as the
@@ -225,6 +225,7 @@ fn czech_plural_rule_works() {
     assert_eq!(trn(Lang::CS, 5, "{n} app", "{n} apps"), "5 aplikací");
 }
 
+/// Polish plural forms and English fallback for untranslated plural keys.
 #[test]
 fn polish_plural_rule_works() {
     // Polish says "one" only for 1 itself: 21, 31 and 101 take "many", unlike Russian.
@@ -244,6 +245,24 @@ fn polish_plural_rule_works() {
     assert_eq!(trn(Lang::PL, 3, "{n} unknown item", "{n} unknown items"), "3 unknown items");
 }
 
+/// CLDR 44+ Hebrew plural indices (one/two/other), legacy `iw` locale tag normalization, and `Lang::HE` resolution.
+#[test]
+fn hebrew_plural_rule_and_legacy_tag_candidates_work() {
+    assert_eq!(plural_hebrew(1), 0);
+    assert_eq!(plural_hebrew(2), 1);
+    for n in [0, 3, 4, 10, 11, 20, 21, 100] {
+        assert_eq!(plural_hebrew(n), 2, "{n}");
+    }
+    assert_eq!(candidates("iw_IL.UTF-8"), vec!["he-il", "he"]);
+    assert_eq!(candidates("iw"), vec!["he"]);
+    for tag in ["he", "he-IL", "he_IL.UTF-8", "iw", "iw-IL", "iw_IL.UTF-8", "HE_il"] {
+        assert_eq!(lang_from_tag(tag), Some(Lang::HE), "{tag}");
+    }
+    assert_eq!(Lang::from_code("he"), Some(Lang::HE));
+    assert!(Lang::HE.rtl());
+}
+
+/// Untranslated plural keys fall back to the English singular/plural form by count.
 #[test]
 fn untranslated_plurals_fall_back_to_english_by_count() {
     // Russian's rule says "one" for 21, but English says "other".
diff --git a/crates/ui-egui/src/settings_dialog.rs b/crates/ui-egui/src/settings_dialog.rs
--- a/crates/ui-egui/src/settings_dialog.rs
+++ b/crates/ui-egui/src/settings_dialog.rs
@@ -17,21 +17,24 @@ use crate::widgets::{self, Cell, Kind};
 /// accessibility (0.37's kittest fails unnamed input widgets).
 fn row(ui: &mut Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut Ui, egui::Id)) {
     let t = Tokens::get(ui.ctx());
+    let rtl = crate::i18n::current().rtl();
     let font = egui::TextStyle::Body.resolve(ui.style());
     let label_width = ui.painter().layout_no_wrap(label.to_string(), font, t.ink).size().x;
     if label_width + control_width + ui.spacing().item_spacing.x > ui.available_width() {
         let label = ui.label(label);
         add(ui, label.id);
     } else {
-        ui.horizontal(|ui| {
+        let outer = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
+        let inner = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };
+        ui.allocate_ui_with_layout(vec2(ui.available_width(), theme::CONTROL_H), outer, |ui| {
             let label = ui.label(label);
-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+            ui.with_layout(inner, |ui| {
                 // A ComboBox grows to the width its layout offers, so leaving it loose made it
                 // start at the label's last glyph and paint over the words at a narrow dialog
-                // width. A fixed-width allocation at the right keeps it clear of the label and
-                // still leaves the dialog itself as narrow as before.
+                // width. A fixed-width allocation at the opposite edge keeps it clear of the label
+                // and still leaves the dialog itself as narrow as before.
                 let width = control_width.min(ui.available_width());
-                ui.allocate_ui_with_layout(vec2(width, theme::CONTROL_H), egui::Layout::left_to_right(egui::Align::Center), |ui| {
+                ui.allocate_ui_with_layout(vec2(width, theme::CONTROL_H), outer, |ui| {
                     add(ui, label.id);
                 });
             });
@@ -39,12 +42,14 @@ fn row(ui: &mut Ui, label: &str, control_width: f32, add: &mut dyn FnMut(&mut Ui
     }
 }
 
+/// Draws a secondary muted caption line inside a settings panel.
 fn note(ui: &mut Ui, text: &str) {
     let t = Tokens::get(ui.ctx());
     ui.label(RichText::new(text).size(12.0).color(t.muted));
 }
 
 impl LauncherUi {
+    /// Renders the Settings modal dialog and returns `true` when the user closes it.
     pub(crate) fn settings_dialog(&mut self, ui: &mut Ui, host: &mut dyn Host) -> bool {
         let t = Tokens::get(ui.ctx());
         let size = sheet::size(ui.ctx(), vec2(1000.0, 820.0));
@@ -86,13 +91,21 @@ impl LauncherUi {
             };
             if stack_actions {
                 quit(ui, host);
-                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+                let end = if crate::i18n::current().rtl() {
+                    egui::Layout::left_to_right(egui::Align::Center)
+                } else {
+                    egui::Layout::right_to_left(egui::Align::Center)
+                };
+                ui.allocate_ui_with_layout(vec2(ui.available_width(), theme::CONTROL_H), end, |ui| {
                     close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
                 });
             } else {
-                ui.horizontal(|ui| {
+                let rtl = crate::i18n::current().rtl();
+                let outer = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
+                let inner = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };
+                ui.allocate_ui_with_layout(vec2(ui.available_width(), theme::CONTROL_H), outer, |ui| {
                     quit(ui, host);
-                    ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+                    ui.with_layout(inner, |ui| {
                         close = widgets::button(ui, tl!("Done"), Kind::Primary, 90.0).clicked();
                     });
                 });
@@ -158,8 +171,14 @@ impl LauncherUi {
         let pref = crate::i18n::Lang::from_pref(&self.settings.language);
         if pref.needs_cjk_font() && !theme::cjk_font_available(pref) {
             // In English on purpose: the language asked for can't be drawn.
-            let text = "No font for this language was found on this system, so the interface stays in English. \
-                        Installing one (for example Noto Sans CJK) and restarting fixes it.";
+            let font_example = match pref.script() {
+                Some(crate::i18n::Script::Hebrew) => "Noto Sans Hebrew",
+                _ => "Noto Sans CJK",
+            };
+            let text = format!(
+                "No font for this language was found on this system, so the interface stays in English. \
+                 Installing one (for example {font_example}) and restarting fixes it."
+            );
             ui.add(egui::Label::new(RichText::new(text).size(12.0).color(t.warn)).wrap());
             ui.add_space(14.0);
         }
diff --git a/crates/ui-egui/src/sheet.rs b/crates/ui-egui/src/sheet.rs
--- a/crates/ui-egui/src/sheet.rs
+++ b/crates/ui-egui/src/sheet.rs
@@ -27,23 +27,28 @@ pub(crate) fn frame(t: &Tokens) -> Frame {
 pub(crate) fn hero(ui: &mut Ui, icon: Option<ImageSource<'static>>, title: &str, caption: &str, right: impl FnOnce(&mut Ui)) -> f32 {
     let t = Tokens::get(ui.ctx());
     let top = ui.cursor().top();
+    let rtl = crate::i18n::current().rtl();
+    let align = if rtl { egui::Align::Max } else { egui::Align::Min };
+    let row_layout = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
+    let end_layout = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };
     let shown =
         Frame::new().fill(tint(t.accent, if t.dark { 0.09 } else { 0.07 })).inner_margin(Margin::symmetric(theme::PAD as i8, 18)).show(ui, |ui| {
             ui.set_width(ui.available_width());
-            ui.horizontal(|ui| {
+            let row_h = if icon.is_some() { 44.0 } else { ui.spacing().interact_size.y };
+            ui.allocate_ui_with_layout(vec2(ui.available_width(), row_h), row_layout, |ui| {
                 if let Some(icon) = icon {
                     // Decorative: the heading beside it carries the title.
                     ui.add(egui::Image::new(icon).fit_to_exact_size(vec2(44.0, 44.0)));
                     ui.add_space(6.0);
                 }
-                ui.vertical(|ui| {
+                ui.with_layout(egui::Layout::top_down(align), |ui| {
                     ui.spacing_mut().item_spacing.y = 4.0;
                     ui.label(widgets::heading(title, theme::SHEET_TITLE, t.ink_strong));
                     if !caption.is_empty() {
                         widgets::hud(ui, caption, t.faint);
                     }
                 });
-                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), right);
+                ui.with_layout(end_layout, right);
             });
         });
     let r = shown.response.rect;
@@ -70,46 +75,81 @@ pub(crate) fn accent_scroll(ui: &mut Ui, t: &Tokens, bar: Color32) -> egui::Visu
 
 /// Two columns of equal width a `gap` apart when `wide`; otherwise `left` then `right`, stacked.
 pub(crate) fn columns(ui: &mut Ui, wide: bool, gap: f32, left: impl FnOnce(&mut Ui), right: impl FnOnce(&mut Ui)) {
+    let rtl = crate::i18n::current().rtl();
+    let align = if rtl { egui::Align::Max } else { egui::Align::Min };
     if !wide {
-        left(ui);
-        right(ui);
+        ui.with_layout(egui::Layout::top_down(align), |ui| {
+            left(ui);
+            right(ui);
+        });
         return;
     }
     let w = ((ui.available_width() - gap) / 2.0).floor();
     ui.horizontal_top(|ui| {
         ui.spacing_mut().item_spacing.x = gap;
-        ui.vertical(|ui| {
-            ui.set_width(w);
-            left(ui);
-        });
-        ui.vertical(|ui| {
-            ui.set_width(w);
-            right(ui);
-        });
+        if rtl {
+            ui.with_layout(egui::Layout::top_down(align), |ui| {
+                ui.set_width(w);
+                right(ui);
+            });
+            ui.with_layout(egui::Layout::top_down(align), |ui| {
+                ui.set_width(w);
+                left(ui);
+            });
+        } else {
+            ui.vertical(|ui| {
+                ui.set_width(w);
+                left(ui);
+            });
+            ui.vertical(|ui| {
+                ui.set_width(w);
+                right(ui);
+            });
+        }
     });
 }
 
 /// A titled box: a hairline frame with a small caps heading, for one group of settings.
 pub(crate) fn panel(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui)) {
     let t = Tokens::get(ui.ctx());
+    let rtl = crate::i18n::current().rtl();
     Frame::new().stroke(t.hairline()).inner_margin(theme::BOX_MARGIN).show(ui, |ui| {
         ui.set_width(ui.available_width());
-        widgets::hud(ui, title, t.faint);
-        ui.add_space(8.0);
-        body(ui);
+        if rtl {
+            ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| {
+                ui.set_width(ui.available_width());
+                widgets::hud(ui, title, t.faint);
+                ui.add_space(8.0);
+                body(ui);
+            });
+        } else {
+            widgets::hud(ui, title, t.faint);
+            ui.add_space(8.0);
+            body(ui);
+        }
     });
     ui.add_space(14.0);
 }
 
-/// [`panel`], but the body also receives the heading's id so its control can be named by that
+/// [`panel`], passing the heading's accessibility node id to `body` so a lone control can name the
 /// visible heading (egui 0.37's `Response::labelled_by`), without adding a second identical label.
 pub(crate) fn panel_with_heading(ui: &mut Ui, title: &str, body: impl FnOnce(&mut Ui, egui::Id)) {
     let t = Tokens::get(ui.ctx());
+    let rtl = crate::i18n::current().rtl();
     Frame::new().stroke(t.hairline()).inner_margin(theme::BOX_MARGIN).show(ui, |ui| {
         ui.set_width(ui.available_width());
-        let heading = widgets::hud(ui, title, t.faint);
-        ui.add_space(8.0);
-        body(ui, heading.id);
+        if rtl {
+            ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| {
+                ui.set_width(ui.available_width());
+                let heading = widgets::hud(ui, title, t.faint);
+                ui.add_space(8.0);
+                body(ui, heading.id);
+            });
+        } else {
+            let heading = widgets::hud(ui, title, t.faint);
+            ui.add_space(8.0);
+            body(ui, heading.id);
+        }
     });
     ui.add_space(14.0);
 }
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -212,6 +212,7 @@ pub fn apply(ctx: &egui::Context, choice: ThemeChoice) {
         ThemeChoice::Light => egui::ThemePreference::Light,
     });
     ctx.all_styles_mut(|style| {
+        style.right_to_left = crate::i18n::current().rtl();
         style.spacing.item_spacing = egui::vec2(8.0, 6.0);
         // Between a checkbox (or radio button) and its label.
         style.spacing.icon_spacing = 10.0;
diff --git a/crates/ui-egui/src/views.rs b/crates/ui-egui/src/views.rs
--- a/crates/ui-egui/src/views.rs
+++ b/crates/ui-egui/src/views.rs
@@ -67,34 +67,58 @@ impl LauncherUi {
     pub(crate) fn header(&mut self, ui: &mut Ui, bar: TitleBar) {
         let t = Tokens::get(ui.ctx());
         let h = HEADER_H;
+        let rtl = current().rtl();
         if bar != TitleBar::Native {
             widgets::title_bar_drag(ui);
         }
-        ui.horizontal(|ui| {
+        ui.allocate_ui_with_layout(vec2(ui.available_width(), h), egui::Layout::left_to_right(egui::Align::Center), |ui| {
             ui.spacing_mut().item_spacing.x = 0.0;
             if bar == TitleBar::MacOs {
                 ui.add_space(TRAFFIC_LIGHTS_W);
             }
-            // The ArtCraft wordmark exactly as supplied, in its own cell.
-            let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);
-            let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());
-            egui::Image::new(crate::icons::artcraft_logo(t.dark)).alt_text(tl!("ArtCraft")).paint_at(ui, Rect::from_center_size(cell.center(), logo));
-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
-                if bar == TitleBar::Custom {
-                    widgets::window_buttons(ui, h);
-                }
+            if rtl {
                 if widgets::nav_icon(ui, widgets::Glyph::Settings, tl!("Settings"), h, false).clicked() {
                     self.dialog = Some(Dialog::Settings);
                 }
-                // The controls reserve their actual widths before the secondary text gets space.
-                ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
+                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+                    if bar == TitleBar::Custom {
+                        widgets::window_buttons(ui, h);
+                    }
+                    let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);
+                    let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());
+                    egui::Image::new(crate::icons::artcraft_logo(t.dark))
+                        .alt_text(tl!("ArtCraft"))
+                        .paint_at(ui, Rect::from_center_size(cell.center(), logo));
                     if ui.available_width() >= 17.0 + widgets::hud_galley(ui, tl!("Craft App Suite"), t.muted).size().x {
                         widgets::rule(ui, h);
                         ui.add_space(16.0);
                         widgets::hud(ui, tl!("Craft App Suite"), t.muted);
                     }
                 });
-            });
+            } else {
+                // The ArtCraft wordmark exactly as supplied, in its own cell.
+                let logo = vec2(14.0 * crate::icons::ARTCRAFT_LOGO_ASPECT, 14.0);
+                let (cell, _) = ui.allocate_exact_size(vec2(logo.x + 40.0, h), Sense::hover());
+                egui::Image::new(crate::icons::artcraft_logo(t.dark))
+                    .alt_text(tl!("ArtCraft"))
+                    .paint_at(ui, Rect::from_center_size(cell.center(), logo));
+                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+                    if bar == TitleBar::Custom {
+                        widgets::window_buttons(ui, h);
+                    }
+                    if widgets::nav_icon(ui, widgets::Glyph::Settings, tl!("Settings"), h, false).clicked() {
+                        self.dialog = Some(Dialog::Settings);
+                    }
+                    // The controls reserve their actual widths before the secondary text gets space.
+                    ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
+                        if ui.available_width() >= 17.0 + widgets::hud_galley(ui, tl!("Craft App Suite"), t.muted).size().x {
+                            widgets::rule(ui, h);
+                            ui.add_space(16.0);
+                            widgets::hud(ui, tl!("Craft App Suite"), t.muted);
+                        }
+                    });
+                });
+            }
         });
     }
 
@@ -191,7 +215,10 @@ impl LauncherUi {
     /// whether the window is narrow enough that a second row follows.
     fn toolbar_row(&mut self, ui: &mut Ui, t: &Tokens, updates: usize) -> bool {
         let mut compact = false;
-        ui.horizontal(|ui| {
+        let rtl = current().rtl();
+        let outer = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
+        let inner = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };
+        ui.allocate_ui_with_layout(vec2(ui.available_width(), theme::CONTROL_H), outer, |ui| {
             self.refresh_button(ui);
             // Hiding an app is only undone from here, so the way back to the hidden ones stays
             // while there are any: a toggle, lit while the hidden apps are what the page shows.
@@ -206,8 +233,8 @@ impl LauncherUi {
                 }
             }
             compact = ui.available_width() < theme::TOOLBAR_COMPACT_BELOW;
-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
-                // Right to left: the view switch at the far right, then the search.
+            ui.with_layout(inner, |ui| {
+                // Opposite side: the view switch at the far edge, then the search.
                 crate::bar::view_toggle(ui, &mut self.settings.view, [tl!("Show apps as cards"), tl!("Show apps as a table")]);
                 ui.add_space(4.0);
                 ui.add(
@@ -215,7 +242,7 @@ impl LauncherUi {
                         .hint_text(tl!("Search apps"))
                         .desired_width(200.0)
                         .min_size(vec2(0.0, theme::CONTROL_H))
-                        .align(egui::Align2::LEFT_CENTER)
+                        .align(if rtl { egui::Align2::RIGHT_CENTER } else { egui::Align2::LEFT_CENTER })
                         .margin(vec2(10.0, 0.0)),
                 )
                 .accessible_name(tl!("Search apps"));
@@ -237,14 +264,17 @@ impl LauncherUi {
 
     /// The toolbar's second row on narrow windows: the sort picker, then update-all.
     fn toolbar_compact_row(&mut self, ui: &mut Ui, t: &Tokens, updates: usize) {
+        let rtl = current().rtl();
+        let outer = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
+        let inner = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };
         ui.add_space(6.0);
-        ui.horizontal(|ui| {
+        ui.allocate_ui_with_layout(vec2(ui.available_width(), theme::CONTROL_H), outer, |ui| {
             if self.settings.view == ViewMode::Grid {
                 // "Sort" before the picker, as in the full-width row.
                 widgets::hud(ui, tl!("Sort"), t.faint);
                 self.sort_picker(ui);
             }
-            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+            ui.with_layout(inner, |ui| {
                 if self.caps.manage_apps && updates > 0 {
                     self.update_all_button(ui, updates);
                 }
@@ -364,12 +394,15 @@ impl LauncherUi {
         }));
         let icon = 13.0;
         let spacing = ui.spacing().item_spacing.x;
-        // The icons on the right keep their size; the status on the left gets what is left and
+        let rtl = current().rtl();
+        let outer = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };
+        let inner = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
+        // The icons on the opposite edge keep their size; the status gets what is left and
         // is cut short with "…", never drawn under them.
-        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+        ui.allocate_ui_with_layout(vec2(ui.available_width(), ui.spacing().interact_size.y), outer, |ui| {
             widgets::icon_links(ui, icon, &links);
             ui.add_space(8.0);
-            ui.with_layout(egui::Layout::left_to_right(egui::Align::Center), |ui| {
+            ui.with_layout(inner, |ui| {
                 // While GitHub is rate limited, saying so matters more than when we last checked.
                 let avail = ui.available_width();
                 let text = |s: &str| RichText::new(s).size(12.0).color(t.warn);
@@ -404,17 +437,25 @@ impl LauncherUi {
         if !top.is_empty() && !rest.is_empty() {
             let t = Tokens::get(ui.ctx());
             ui.add_space(28.0);
-            widgets::hud(ui, tl!("Not installed"), t.faint);
+            if current().rtl() {
+                ui.with_layout(egui::Layout::top_down(egui::Align::Max), |ui| {
+                    widgets::hud(ui, tl!("Not installed"), t.faint);
+                });
+            } else {
+                widgets::hud(ui, tl!("Not installed"), t.faint);
+            }
             ui.add_space(12.0);
         }
         self.grid_block(ui, rest);
         ui.add_space(24.0);
     }
 
+    /// Lays out one block of app cards in a shared-hairline grid, ordering columns right-to-left when the UI locale is RTL.
     fn grid_block(&mut self, ui: &mut Ui, ids: &[&'static str]) {
         if ids.is_empty() {
             return;
         }
+        let rtl = current().rtl();
         let width = ui.available_width();
         // Neighbouring cards share their 1 px hairline, like the site's `gap-px` grid.
         let cols = ((width / CARD_MIN_W).floor() as usize).max(1);
@@ -424,7 +465,8 @@ impl LauncherUi {
         let (area, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
         for (i, id) in ids.iter().enumerate() {
             let (row, col) = (i / cols, i % cols);
-            let min = area.min + vec2(col as f32 * (card_w - 1.0), row as f32 * (CARD_H - 1.0));
+            let visual_col = if rtl { cols - 1 - col } else { col };
+            let min = area.min + vec2(visual_col as f32 * (card_w - 1.0), row as f32 * (CARD_H - 1.0));
             self.card(ui, id, Rect::from_min_size(min, vec2(card_w, CARD_H)));
         }
     }
@@ -467,20 +509,22 @@ impl LauncherUi {
         }
 
         // The top strip: the version in the app's colour, and its GitHub page.
+        let rtl = current().rtl();
+        let align = if rtl { egui::Align::Max } else { egui::Align::Min };
         let status = entry.status(self.platform, self.caps.manage_apps);
         let version = crate::card_parts::strip_version(&entry, &status);
         let strip = Rect::from_min_size(rect.min, vec2(rect.width(), STRIP_H));
         painter.hline(strip.x_range(), strip.bottom() - 0.5, t.hairline());
         {
-            let mut strip_ui = ui.new_child(
-                egui::UiBuilder::new().max_rect(strip.shrink2(vec2(CARD_PAD, 0.0))).layout(egui::Layout::left_to_right(egui::Align::Center)),
-            );
+            let strip_layout = if rtl { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
+            let link_layout = if rtl { egui::Layout::left_to_right(egui::Align::Center) } else { egui::Layout::right_to_left(egui::Align::Center) };
+            let mut strip_ui = ui.new_child(egui::UiBuilder::new().max_rect(strip.shrink2(vec2(CARD_PAD, 0.0))).layout(strip_layout));
             if !version.is_empty() {
                 let github_w = widgets::hud_galley(&strip_ui, &format!("{} ↗", tl!("GitHub")), t.muted).size().x;
                 let room = (strip_ui.available_width() - github_w - 16.0).max(0.0);
                 widgets::hud_elided(&mut strip_ui, &version, if in_colour { t.app_ink(app) } else { t.muted }, room);
             }
-            strip_ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+            strip_ui.with_layout(link_layout, |ui| {
                 ui.spacing_mut().item_spacing.x = 16.0;
                 widgets::hud_link(ui, tl!("GitHub"), &app.repo_url());
             });
@@ -488,7 +532,7 @@ impl LauncherUi {
 
         let inner = Rect::from_min_max(egui::pos2(rect.left() + CARD_PAD, strip.bottom() + 18.0), rect.max - vec2(CARD_PAD, CARD_PAD));
         // A detached child: the card's contents never move the next card along.
-        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(egui::Align::Min)));
+        let mut child = ui.new_child(egui::UiBuilder::new().max_rect(inner).layout(egui::Layout::top_down(align)));
         child.set_clip_rect(rect.intersect(ui.clip_rect()));
         let ui = &mut child;
         ui.horizontal(|ui| {
@@ -545,7 +589,7 @@ impl LauncherUi {
             ui.add_space(6.0);
             ui.add(egui::Label::new(RichText::new(line).size(12.5).color(t.faint)).selectable(false));
         }
-        ui.with_layout(egui::Layout::bottom_up(egui::Align::Min), |ui| {
+        ui.with_layout(egui::Layout::bottom_up(align), |ui| {
             ui.horizontal(|ui| self.actions(ui, &entry, &status, inner.width()));
         });
     }
@@ -1135,7 +1179,8 @@ pub(crate) fn launcher_download(r: &artcraft_launcher_core::Release, p: artcraft
 /// Each line is measured as an empty galley, which is rounded to the pixel like a label, unlike `Fonts::row_height`.
 fn centered_lines(ui: &mut Ui, fonts: &[&FontId], gap: f32, add_contents: impl FnOnce(&mut Ui)) {
     let height = lines_height(ui, fonts, gap);
-    ui.allocate_ui_with_layout(vec2(ui.available_width(), height), egui::Layout::top_down(egui::Align::Min), |ui| {
+    let align = if current().rtl() { egui::Align::Max } else { egui::Align::Min };
+    ui.allocate_ui_with_layout(vec2(ui.available_width(), height), egui::Layout::top_down(align), |ui| {
         ui.spacing_mut().item_spacing.y = gap;
         add_contents(ui);
     });
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
@@ -23,7 +23,11 @@ pub fn tone_color(t: &Tokens, tone: Tone) -> Color32 {
 /// `text` laid out upper case in `family`, `spacing` points between letters.
 pub fn caps(ui: &Ui, text: &str, size: f32, family: egui::FontFamily, spacing: f32, color: Color32) -> Arc<Galley> {
     let format = egui::TextFormat { font_id: egui::FontId::new(size, family), color, extra_letter_spacing: spacing, ..Default::default() };
-    ui.painter().layout_job(egui::text::LayoutJob::single_section(text.to_uppercase(), format))
+    let mut job = egui::text::LayoutJob::single_section(text.to_uppercase(), format);
+    if crate::i18n::current().rtl() {
+        job.direction = egui::epaint::text::TextDirection::RightToLeft;
+    }
+    ui.painter().layout_job(job)
 }
 
 /// A `.hud-label`: 11 px Geist Mono, upper case, widely spaced.
@@ -61,6 +65,9 @@ pub fn hud_elided(ui: &mut Ui, text: &str, color: Color32, max_width: f32) -> Re
         ..Default::default()
     };
     let mut job = egui::text::LayoutJob::single_section(text.to_uppercase(), format);
+    if crate::i18n::current().rtl() {
+        job.direction = egui::epaint::text::TextDirection::RightToLeft;
+    }
     job.wrap = egui::text::TextWrapping::truncate_at_width(max_width.min(ui.available_width()).max(0.0));
     let galley = ui.painter().layout_job(job);
     let elided = galley.elided;
@@ -659,7 +666,7 @@ pub fn wordmark(ui: &mut Ui, app: &artcraft_launcher_core::CraftApp, size: f32)
 pub fn wordmark_in(ui: &mut Ui, app: &artcraft_launcher_core::CraftApp, size: f32, in_colour: bool) -> Response {
     let t = Tokens::get(ui.ctx());
     let (prefix, craft) = app.name.strip_suffix("Craft").map_or((app.name, ""), |p| (p, "Craft"));
-    let mut job = egui::text::LayoutJob::default();
+    let mut job = egui::text::LayoutJob { direction: egui::epaint::text::TextDirection::LeftToRight, ..Default::default() };
     let format = |color| egui::TextFormat {
         font_id: egui::FontId::new(size, theme::display()),
         color,
