diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs
--- a/crates/engine/src/cmd/tools.rs
+++ b/crates/engine/src/cmd/tools.rs
@@ -767,6 +767,8 @@ pub fn specs() -> Vec<CommandSpec> {
         CommandSpec::new("review.language", "Language", "Review › Language", language).params(
             r#"{"lang"?: "en-US|en-GB|pl-PL|fr-FR|…" (BCP 47; default en-US), "noProof"?: bool, "detect"?: bool (detect language automatically, a preference), "default"?: bool (also the document's default and the saved language for future blank documents)}"#,
         ),
+        CommandSpec::new("review.defaultLanguage", "Review Language", "File › Options › Language", default_language)
+            .params(r#"{"value"?: "auto"|"off"|<tag> (a proofing language such as pt-BR), "apply"?: bool (also the open document's default language, true by default)}"#),
         CommandSpec::new("review.showMarkup", "Show Markup", "Review › Tracking", |s, v| {
             s.view.show_markup = p::bool(v, "value").unwrap_or(!s.view.show_markup);
             s.relayout();
@@ -865,6 +867,53 @@ fn language(s: &mut Session, v: &Value) -> CmdResult {
     Ok(result)
 }
 
+/// The proofing default shown by File › Options › Language (and used for text that names no
+/// language): the saved default, else the interface's editing language, else US English.
+fn proofing_default(s: &Session) -> String {
+    s.prefs.default_language.clone().or_else(|| s.editing_lang.clone()).unwrap_or_else(|| "en-US".to_string())
+}
+
+/// File › Options › Language › Review language: set the app's proofing default. `auto` follows
+/// the interface language, `off` turns the live check off, a tag is used as it is; `apply` (the
+/// default) also changes the open document's default language, which clears the live squiggles
+/// at once. Persisted with the other preferences in `ui.json`.
+fn default_language(s: &mut Session, v: &Value) -> CmdResult {
+    let Some(value) = p::str(v, "value").map(str::trim).filter(|v| !v.is_empty()) else {
+        let checking = s.view.proofing;
+        let value = if !checking { "off".to_string() } else { s.prefs.default_language.clone().unwrap_or_else(|| "auto".to_string()) };
+        return Ok(json!({"value": value, "resolved": proofing_default(s), "checking": checking}));
+    };
+    if value.eq_ignore_ascii_case("off") {
+        s.view.proofing = false;
+        s.prefs.check_spelling = false;
+        return Ok(json!({"value": "off", "resolved": proofing_default(s), "checking": false}));
+    }
+    let (value, resolved) = if value.eq_ignore_ascii_case("auto") {
+        // Automatic follows the interface language (a pt-br interface writes pt-BR).
+        s.prefs.default_language = None;
+        ("auto".to_string(), proofing_default(s))
+    } else {
+        let tag = wordcraft_proof::lang::normalize_tag(value)
+            .ok_or_else(|| CmdError::Params(format!("`value` must be auto, off or a language tag, not {value:?}")))?;
+        s.prefs.default_language = Some(tag.clone());
+        s.view.proofing = true;
+        s.prefs.check_spelling = true;
+        (tag.clone(), tag)
+    };
+    if p::bool(v, "apply").unwrap_or(true) {
+        // The open document's own default language: text that names no language now resolves in
+        // the chosen one, and the live check follows without reopening the file.
+        s.doc.styles.default_chr.lang = Some(resolved.clone());
+        if let Some(normal) = s.doc.styles.styles.iter_mut().find(|x| x.id == "Normal")
+            && normal.chr.lang.is_some()
+        {
+            normal.chr.lang = Some(resolved.clone());
+        }
+        s.relayout();
+    }
+    Ok(json!({"value": value, "resolved": resolved, "checking": s.view.proofing}))
+}
+
 fn macros(s: &mut Session, v: &Value) -> CmdResult {
     if let Some(d) = v.get("define") {
         let name = d.get("name").and_then(Value::as_str).unwrap_or("Macro").to_string();
@@ -2395,6 +2444,51 @@ mod tests {
         assert!(s.run("review.language", &json!({"lang": "<script>"})).is_err());
     }
 
+    /// File › Options › Language: the review language is a persisted app default; Automatic
+    /// follows the interface language, Off turns the live check off, and applying it changes the
+    /// open document's default so the squiggles follow at once.
+    #[test]
+    fn review_language_default_is_applied_and_persisted() {
+        let mut s = Session::new(Document::from_text("Texto"));
+        s.set_editing_language(Some("pt-BR"));
+        let r = s.run("review.defaultLanguage", &json!({"value": "auto"})).unwrap();
+        assert_eq!((r["value"].as_str(), r["resolved"].as_str()), (Some("auto"), Some("pt-BR")));
+        assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pt-BR"));
+        assert!(s.view.proofing && s.prefs.default_language.is_none());
+
+        let r = s.run("review.defaultLanguage", &json!({"value": "pt-br"})).unwrap();
+        assert_eq!(r["resolved"], "pt-BR");
+        assert_eq!(s.prefs.default_language.as_deref(), Some("pt-BR"));
+
+        let r = s.run("review.defaultLanguage", &json!({"value": "off"})).unwrap();
+        assert_eq!(r["value"], "off");
+        assert!(!s.view.proofing && !s.prefs.check_spelling);
+        assert_eq!(s.prefs.default_language.as_deref(), Some("pt-BR"), "Off keeps the language saved");
+        assert_eq!(s.run("review.defaultLanguage", &json!({})).unwrap()["value"], "off");
+
+        assert!(s.run("review.defaultLanguage", &json!({"value": "<script>"})).is_err());
+        let r = s.run("review.defaultLanguage", &json!({"value": "en-US"})).unwrap();
+        assert_eq!(r["resolved"], "en-US");
+        assert!(s.view.proofing && s.prefs.check_spelling, "choosing a language checks it again");
+        assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-US"));
+    }
+
+    /// A document that names no language adopts the app's proofing default when opened; a
+    /// document that names its own keeps it.
+    #[test]
+    fn documents_without_a_language_adopt_the_proofing_default() {
+        let mut s = Session::new(Document::from_text(""));
+        s.set_editing_language(Some("pt-BR"));
+        let mut plain = Document::from_text("Sem idioma");
+        plain.styles.default_chr.lang = None;
+        s.set_document(plain);
+        assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("pt-BR"));
+        let mut english = Document::from_text("With language");
+        english.styles.default_chr.lang = Some("en-US".into());
+        s.set_document(english);
+        assert_eq!(s.doc.styles.default_chr.lang.as_deref(), Some("en-US"), "its own language wins");
+    }
+
     /// The inspector's removals delete straight from the document, so they aren't tracked: on a
     /// document that tracks changes the text would go with no revision to show for it, and the
     /// revision trail would be broken behind the user's back. The removal is refused instead,
diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs
--- a/crates/engine/src/session.rs
+++ b/crates/engine/src/session.rs
@@ -1622,6 +1622,9 @@ impl Session {
         self.note_body_replaced();
         self.bib_style = doc_bib_style(&doc);
         self.doc = doc;
+        // A file that names no language uses the app's proofing default, not English.
+        let tag = self.prefs.default_language.clone().or_else(|| self.editing_lang.clone());
+        adopt_default_language(&mut self.doc, tag.as_deref());
         self.doc.ensure_nonempty();
         self.password = None;
         self.file_name = None;
@@ -2431,6 +2434,17 @@ impl Session {
 }
 
 /// A new blank document on `region`'s paper (see [`Session::blank_document`]).
+/// A document that names no default text language of its own adopts `tag`, so text without a
+/// language is checked (and shown in the status bar) in the app's language instead of English.
+/// A document that carries a language — from its own styles — is left as it is.
+pub(crate) fn adopt_default_language(doc: &mut Document, tag: Option<&str>) {
+    let Some(tag) = tag else { return };
+    let named = doc.styles.resolve_char(None, &wordcraft_doc::props::CharProps::default()).lang.is_some();
+    if !named {
+        doc.styles.default_chr.lang = Some(tag.to_string());
+    }
+}
+
 pub fn blank_document(region: &wordcraft_geom::Region) -> Document {
     let mut d = Document::new();
     let (_, w, h) = region.paper();
diff --git a/crates/engine/src/session/documents.rs b/crates/engine/src/session/documents.rs
--- a/crates/engine/src/session/documents.rs
+++ b/crates/engine/src/session/documents.rs
@@ -456,6 +456,10 @@ impl Session {
     }
 
     fn add(&mut self, mut doc: Document, untitled: u32) -> u64 {
+        // A document opened beside another (or a new one) adopts the app's proofing language when
+        // it names none of its own; see `adopt_default_language`.
+        let tag = self.prefs.default_language.clone().or_else(|| self.editing_lang.clone());
+        super::adopt_default_language(&mut doc, tag.as_deref());
         doc.ensure_nonempty();
         let mut view = self.view.clone();
         view.outline.collapsed.clear();
diff --git a/crates/fonts/src/fontdb.rs b/crates/fonts/src/fontdb.rs
--- a/crates/fonts/src/fontdb.rs
+++ b/crates/fonts/src/fontdb.rs
@@ -1757,13 +1757,17 @@ impl FontDb {
     /// loaded face covers a character. `None` when no single face covers them all, and the caller
     /// keeps the per-character fallback.
     ///
+    /// Default-ignorables (variation selectors, joiners) take no glyph of their own: they neither
+    /// choose a face nor disqualify one, so an emoji sequence can be given a colour emoji face
+    /// even though that face maps no variation selector.
+    ///
     /// Cached per (character set, excluded face, bold, italic) and dropped when the loaded faces
     /// change, like [`Self::fallback_for`], so relayout doesn't repeat the search.
     pub fn fallback_for_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
-        if chars.is_empty() {
+        let mut set: Vec<char> = chars.iter().copied().filter(|c| !crate::is_default_ignorable(*c)).collect();
+        if set.is_empty() {
             return None;
         }
-        let mut set: Vec<char> = chars.to_vec();
         set.sort_unstable();
         set.dedup();
         let key = (set, exclude, bold, italic);
@@ -1791,6 +1795,33 @@ impl FontDb {
         found
     }
 
+    /// A face drawing an emoji presentation sequence (`1️⃣`, `⚖️`, `👩‍💻`) as the one emoji it is:
+    /// the first colour emoji family that maps every character of the sequence the shaper draws
+    /// (the variation selectors and joiners are the shaper's own), else the run's fallback face.
+    /// `None` when no loaded face covers the characters.
+    ///
+    /// Not cached: the colour families are tried first, a handful of lookups, and the run cache
+    /// under them already keeps the search cheap.
+    pub fn color_emoji_face(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> Option<Arc<FontFace>> {
+        let need: Vec<char> = chars.iter().copied().filter(|c| !c.is_control() && !c.is_whitespace() && !crate::is_default_ignorable(*c)).collect();
+        if need.is_empty() {
+            return None;
+        }
+        let style = match (bold, italic) {
+            (true, true) => "Bold Italic",
+            (true, false) => "Bold",
+            (false, true) => "Italic",
+            (false, false) => "Regular",
+        };
+        for family in crate::COLOR_EMOJI {
+            let face = self.face(family, style);
+            if face.family.eq_ignore_ascii_case(family) && need.iter().all(|c| face.covers(*c)) {
+                return Some(face);
+            }
+        }
+        self.fallback_for_run(&need, exclude, bold, italic)
+    }
+
     /// The face for a run, and the number of loaded faces the search last chose from.
     fn search_run(&self, chars: &[char], exclude: u32, bold: bool, italic: bool) -> (Option<Arc<FontFace>>, usize) {
         #[cfg_attr(target_arch = "wasm32", allow(unused_mut))]
@@ -2156,6 +2187,31 @@ mod tests {
         assert!(db.run_fallbacks.read().unwrap().map.is_empty());
     }
 
+    #[test]
+    #[cfg(not(target_arch = "wasm32"))]
+    fn an_emoji_sequence_prefers_a_colour_emoji_face() {
+        let db = FontDb::global();
+        let colour = crate::COLOR_EMOJI.iter().find(|f| db.face(f, "Regular").family.eq_ignore_ascii_case(f)).copied();
+        let Some(colour) = colour else {
+            eprintln!("skipped: no colour emoji font installed");
+            return;
+        };
+        let primary = db.face(FALLBACK_FAMILY, "Regular");
+        // "1️⃣": the keycap is the digit and the enclosing mark; the variation selector takes no
+        // glyph of its own, so a face that maps the two characters draws it as one emoji.
+        let keycap: Vec<char> = "1\u{FE0F}\u{20E3}".chars().collect();
+        let face = db.color_emoji_face(&keycap, primary.id(), false, false).expect("a colour emoji face");
+        assert!(face.family.eq_ignore_ascii_case(colour), "{} is not {colour}", face.family);
+        assert!(face.covers('1') && face.covers('\u{20E3}'));
+        // The variation selector neither chooses a face nor disqualifies one.
+        assert_eq!(
+            db.fallback_for_run(&['1', '\u{FE0F}'], primary.id(), false, false).map(|f| f.id()),
+            db.fallback_for_run(&['1'], primary.id(), false, false).map(|f| f.id())
+        );
+        // A text-presentation cluster is not an emoji sequence and keeps the ordinary fallback.
+        assert!(!crate::emoji_sequence(&"\u{2764}\u{FE0E}".chars().collect::<Vec<_>>()));
+    }
+
     /// The issue's run (#1549): Thai letters and digits, Latin letters, digits and the baht sign.
     const THAI_RUN: &str = "[D] น้ำ ค่ำ ปั้น ฝั่ง ปู่ ๑๒๓๔๕๖๗๘๙๐ ฿1,250.50";
 
diff --git a/crates/fonts/src/lib.rs b/crates/fonts/src/lib.rs
--- a/crates/fonts/src/lib.rs
+++ b/crates/fonts/src/lib.rs
@@ -21,7 +21,7 @@ pub use fontdb::{
 pub use harfrust::Feature;
 use harfrust::{Direction, ShapeOptions, Tag, UnicodeBuffer};
 pub use kurbo::BezPath;
-pub use script::{fallback_rank, is_emoji, needs_layout_tables, script_fallbacks};
+pub use script::{COLOR_EMOJI, emoji_sequence, fallback_rank, is_default_ignorable, is_emoji, needs_layout_tables, script_fallbacks};
 use skrifa::MetadataProvider;
 use skrifa::instance::Size;
 pub use ui_fallback::{SystemUiFont, is_cjk, system_cjk_ui_font, system_cjk_ui_font_for, ui_needs_system_cjk, ui_uncovered_cjk};
diff --git a/crates/fonts/src/script.rs b/crates/fonts/src/script.rs
--- a/crates/fonts/src/script.rs
+++ b/crates/fonts/src/script.rs
@@ -94,7 +94,32 @@ pub fn script_fallbacks(c: char) -> &'static [&'static str] {
 
 /// The platforms' colour emoji fonts. Emoji prefer them over the monochrome symbol fonts that
 /// also cover many emoji (Windows' Segoe UI Symbol has ✅, so it drew in black and white).
-const COLOR_EMOJI: &[&str] = &["Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji"];
+pub const COLOR_EMOJI: &[&str] = &["Segoe UI Emoji", "Apple Color Emoji", "Noto Color Emoji"];
+
+/// Characters a shaper draws no glyph for of its own: variation selectors, the joiners, the tags
+/// and the other default-ignorables. A face that lacks one is not missing a glyph, so they never
+/// choose a face and never disqualify one ([`FontFace::covers`](crate::FontFace::covers)).
+pub fn is_default_ignorable(c: char) -> bool {
+    matches!(
+        u32::from(c),
+        0x00AD // soft hyphen
+            | 0x034F // combining grapheme joiner
+            | 0x061C // Arabic letter mark
+            | 0x115F..=0x1160 // Hangul fillers
+            | 0x17B4..=0x17B5 // Khmer inherent vowels
+            | 0x180B..=0x180F // Mongolian free variation selectors, vowel separator
+            | 0x200B..=0x200F // zero-width space, joiners, direction marks
+            | 0x202A..=0x202E // bidi embedding and override controls
+            | 0x2060..=0x206F // word joiner, invisible operators, bidi isolates
+            | 0x3164 // Hangul filler
+            | 0xFE00..=0xFE0F // variation selectors 1–16
+            | 0xFEFF // zero-width no-break space
+            | 0xFFA0 // halfwidth Hangul filler
+            | 0x1BCA0..=0x1BCA3 // shorthand format controls
+            | 0x1D173..=0x1D17A // musical format controls
+            | 0xE0000..=0xE0FFF // tags and variation selectors supplement
+    )
+}
 
 /// Characters drawn as emoji by default (Unicode `Emoji_Presentation`): the BMP ones listed in
 /// emoji-data.txt, and the pictograph blocks from U+1F000.
@@ -139,6 +164,38 @@ pub fn is_emoji(c: char) -> bool {
     )
 }
 
+/// Whether these characters, one grapheme cluster, are an emoji **presentation sequence**: the
+/// text asks for an emoji drawing of characters that are not emoji by themselves — a variation
+/// selector-16 (`⚖️`), a keycap (`1️⃣`), a ZWJ sequence, a flag or a skin-toned emoji. Such a
+/// cluster is shaped as one emoji in a colour emoji font, whose ligature draws the whole thing;
+/// the text font would draw the base and leave the keycap or the joiner without a glyph.
+///
+/// `false` for text presentation (`❤︎`, U+FE0E) and for a lone emoji, which
+/// [`is_emoji`] already sends to a colour emoji face character by character.
+pub fn emoji_sequence(chars: &[char]) -> bool {
+    let mut selectors = 0u8;
+    let mut emoji = false;
+    let mut regional = 0usize;
+    for c in chars.iter().copied() {
+        emoji |= is_emoji(c);
+        match u32::from(c) {
+            0xFE0F => selectors |= 1,             // VARIATION SELECTOR-16: emoji presentation
+            0x20E3 => selectors |= 2,             // COMBINING ENCLOSING KEYCAP
+            0x200D => selectors |= 4,             // ZERO WIDTH JOINER
+            0x1F3FB..=0x1F3FF => selectors |= 8,  // emoji modifier (skin tone)
+            0xE0020..=0xE007F => selectors |= 16, // tag characters (subdivision flags)
+            0x1F1E6..=0x1F1FF => regional += 1,   // regional indicators (flags)
+            _ => {}
+        }
+    }
+    selectors & 1 != 0
+        || selectors & 2 != 0
+        || (selectors & 4 != 0 && emoji)
+        || regional >= 2
+        || (selectors & 8 != 0 && emoji)
+        || (selectors & 16 != 0 && emoji)
+}
+
 /// How well `family` suits `c` as a fallback: 0 for the first preferred family, then the others
 /// in order; then families named after the script ("Noto Sans Tamil"); then everything else.
 pub fn fallback_rank(c: char, family: &str) -> usize {
@@ -195,4 +252,33 @@ mod tests {
             assert!(!is_emoji(c), "{c}");
         }
     }
+
+    #[test]
+    fn emoji_presentation_sequences_are_recognised() {
+        let seq = |s: &str| emoji_sequence(&s.chars().collect::<Vec<_>>());
+        // Keycaps (with and without the variation selector), emoji presentation of a text-default
+        // symbol, a ZWJ sequence, a flag, a skin tone and a tag sequence.
+        assert!(seq("1\u{FE0F}\u{20E3}"));
+        assert!(seq("1\u{20E3}"));
+        assert!(seq("\u{2696}\u{FE0F}"));
+        assert!(seq("\u{1F469}\u{200D}\u{1F4BB}"));
+        assert!(seq("\u{1F1E7}\u{1F1F7}"));
+        assert!(seq("\u{1F44D}\u{1F3FD}"));
+        assert!(seq("\u{1F3F4}\u{E0067}\u{E0062}\u{E0073}\u{E0063}\u{E0074}\u{E007F}"));
+        // Text presentation, plain text and a lone joiner are not sequences.
+        assert!(!seq("\u{2764}\u{FE0E}"));
+        assert!(!seq("1"));
+        assert!(!seq("Tema central"));
+        assert!(!seq("\u{1F4D8}"));
+        assert!(!seq("\u{200D}"));
+    }
+
+    #[test]
+    fn default_ignorables_never_choose_a_face() {
+        assert!(is_default_ignorable('\u{FE0F}') && is_default_ignorable('\u{FE0E}'));
+        assert!(is_default_ignorable('\u{200D}'));
+        // The enclosing keycap is drawn by the emoji's ligature: it does need a face.
+        assert!(!is_default_ignorable('\u{20E3}'));
+        assert!(!is_default_ignorable('1') && !is_default_ignorable('\u{1F4D8}'));
+    }
 }
diff --git a/crates/formats/src/odt.rs b/crates/formats/src/odt.rs
--- a/crates/formats/src/odt.rs
+++ b/crates/formats/src/odt.rs
@@ -2638,6 +2638,11 @@ pub fn import(bytes: &[u8]) -> Result<Document, String> {
     if let Some(t) = track {
         doc.settings.track_changes = t;
     }
+    // ODT's text language is not read yet (a known limit of this importer): don't let the built-in
+    // sheet's `en-US` look like the file's own language, or a Portuguese document would be
+    // proofed as English. With no language the app's proofing default applies (see
+    // `Session`/`adopt_default_language`).
+    doc.styles.default_chr.lang = None;
     Ok(doc)
 }
 
@@ -2746,6 +2751,16 @@ fn apply_layout(a: &Attrs, sec: &mut SectionProps) {
 mod tests {
     use super::*;
 
+    /// An ODT that names no language does not inherit the built-in sheet's en-US, so the app's
+    /// proofing default (pt-BR on a Portuguese interface) applies instead of English.
+    #[test]
+    fn imported_odt_has_no_default_language_of_its_own() {
+        let mut d = Document::new();
+        d.ensure_nonempty();
+        let back = super::import(&super::export(&d).unwrap()).unwrap();
+        assert_eq!(back.styles.default_chr.lang, None, "the file names no language");
+    }
+
     #[test]
     fn escaper_drops_noncharacters() {
         assert_eq!(x("a\u{FFFF}b\u{FFFE}c\u{1}d\te"), "abcd\te");
diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs
--- a/crates/layout/src/para.rs
+++ b/crates/layout/src/para.rs
@@ -626,11 +626,17 @@ struct SegKey {
     complex: bool,
     /// Odd bidi level: shaped right to left.
     rtl: bool,
+    /// A cluster drawn as one emoji in a colour emoji face (see [`emoji_sequence`]).
+    emoji: bool,
 }
 
 impl SegKey {
     fn same(&self, o: &SegKey) -> bool {
-        self.face.map(|f| f.id()) == o.face.map(|f| f.id()) && self.small == o.small && self.complex == o.complex && self.rtl == o.rtl
+        self.face.map(|f| f.id()) == o.face.map(|f| f.id())
+            && self.small == o.small
+            && self.complex == o.complex
+            && self.rtl == o.rtl
+            && self.emoji == o.emoji
     }
 }
 
@@ -981,40 +987,66 @@ impl<'a> Builder<'a> {
             }
         };
         // Split by font coverage (fallback faces), small caps case, script formatting and direction.
+        // Emoji presentation sequences ("1️⃣", "⚖️", "👩‍💻") are drawn as the one emoji they are:
+        // the whole cluster takes a colour emoji face covering it, where the text face would draw
+        // its base and leave the keycap (U+20E3) or the joined emoji without a glyph.
+        let clusters = wordcraft_doc::cluster::cluster_starts(text);
+        let mut cluster = 0usize;
+        let mut cluster_end = 0usize;
+        let mut cluster_face: Option<FaceRef> = None;
         let mut seg_start = 0;
         let mut cur: Option<SegKey> = None;
         let mut segs: Vec<(usize, usize, SegKey)> = Vec::new();
         for (i, c) in text.char_indices() {
             let rtl = use_levels && levels.get(lbase + i).is_some_and(|l| l % 2 == 1);
+            if i >= cluster_end {
+                while clusters.get(cluster + 1).is_some_and(|s| *s <= i) {
+                    cluster += 1;
+                }
+                let start = clusters.get(cluster).copied().unwrap_or(i);
+                cluster_end = clusters.get(cluster + 1).copied().unwrap_or(text.len());
+                let chars: Vec<char> = text.get(start..cluster_end).unwrap_or("").chars().collect();
+                cluster_face = wordcraft_fonts::emoji_sequence(&chars)
+                    .then(|| wordcraft_fonts::FontDb::global().color_emoji_face(&chars, primary.id(), rc.bold, rc.italic))
+                    .flatten()
+                    .map(|f| FaceRef::of(&f));
+            }
+            // An emoji cluster keeps its own face and its digits as typed: a numeral mode that
+            // rewrote them would break the keycap the shaper draws.
+            let emoji = cluster_face.filter(|_| i < cluster_end);
+            let prev_emoji = cur.is_some_and(|k| k.emoji);
             // A symbol-font character becomes its Unicode symbol (#776).
             let c = show(c);
             // Displayed form (Word › Numeral): one char in, one char out. Coverage and the
             // fallback face are picked for the displayed char, so Hindi digits land in an
             // Arabic-capable face and Western digits in a Latin one.
-            let dc = self.numeral_char(&context, i, c, rtl);
+            let dc = if emoji.is_some() { c } else { self.numeral_char(&context, i, c, rtl) };
             let mark = is_mark(c);
             let complex = match cur {
                 Some(k) if mark => k.complex,
                 _ => rc_cs.is_some() && rc.uses_complex(dc),
             };
             let prim = if complex { primary_cs.unwrap_or(primary) } else { primary };
-            let face = match cur {
-                Some(k) if mark => k.face,
-                _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,
-                // The run's one substitute face for a missing Thai font (#1549).
-                _ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,
-                _ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
-                // Stay in the fallback face the word started in while it covers the letters.
-                Some(SegKey { face: Some(f), .. }) if f.covers(dc) => Some(f),
-                // The run's unified East Asian face, when one face covers the whole run.
-                _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,
-                _ => {
-                    let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };
-                    wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))
-                }
+            let face = match emoji {
+                Some(f) => Some(f),
+                None => match cur {
+                    Some(k) if mark => k.face,
+                    _ if !complex && east_asian(c) && primary_ea.is_some_and(|f| f.covers(c)) => primary_ea,
+                    // The run's one substitute face for a missing Thai font (#1549).
+                    _ if !c.is_control() && c != SOFT_HYPHEN && run_face.is_some_and(|f| f.covers(dc)) => run_face,
+                    _ if prim.covers(dc) || c.is_whitespace() || c.is_control() || c == SOFT_HYPHEN => None,
+                    // Stay in the fallback face the word started in while it covers the letters.
+                    Some(SegKey { face: Some(f), .. }) if !prev_emoji && f.covers(dc) => Some(f),
+                    // The run's unified East Asian face, when one face covers the whole run.
+                    _ if !complex && ea_face.is_some() && crate::kinsoku::is_east_asian(c) => ea_face,
+                    _ => {
+                        let (bold, italic) = if complex { (rc.bold_cs, rc.italic_cs) } else { (rc.bold, rc.italic) };
+                        wordcraft_fonts::FontDb::global().fallback_styled(dc, prim.id(), bold, italic).map(|f| FaceRef::of(&f))
+                    }
+                },
             };
             let small = rc.small_caps && !rc.caps && c.is_lowercase();
-            let k = SegKey { face, small, complex, rtl };
+            let k = SegKey { face, small, complex, rtl, emoji: emoji.is_some() };
             match cur {
                 Some(p) if p.same(&k) => {}
                 Some(p) => {
@@ -1047,7 +1079,7 @@ impl<'a> Builder<'a> {
             // Displayed text: numeral substitution is 1 char → 1 char and a capital may be
             // several; `map_starts` holds where each document char's displayed form starts and
             // `orig_of` maps it back to the char's original byte offset (+ a `sub.len()` sentinel).
-            let substituted = expands || sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c);
+            let substituted = !key.emoji && (expands || sub.char_indices().any(|(i, c)| self.numeral_char(&context, a + i, c, key.rtl) != c));
             let mut mapped = String::new();
             let mut map_starts: Vec<usize> = Vec::new();
             let mut orig_of: Vec<usize> = Vec::new();
diff --git a/crates/render/src/emoji.rs b/crates/render/src/emoji.rs
new file mode 100644
--- /dev/null
+++ b/crates/render/src/emoji.rs
@@ -0,0 +1,192 @@
+//! Colour emoji for the interface: the same emoji fonts and colour-glyph path the page draws with
+//! ([`crate::color_glyph`]), rasterised into a small straight-alpha RGBA image that the UI can show
+//! as a texture. The Navigation pane uses it for the emoji in heading labels, which the interface
+//! fonts only have monochrome.
+
+use std::sync::Arc;
+
+use vello_cpu::kurbo::{Affine, Rect};
+use vello_cpu::{PixmapMut, Resources};
+
+use crate::color_glyph::{self, Drawable};
+use crate::context_pool::CONTEXTS;
+
+/// A colour emoji raster: straight (not premultiplied) RGBA, row-major, top-down.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct EmojiImage {
+    pub width: u32,
+    pub height: u32,
+    pub rgba: Vec<u8>,
+}
+
+/// The largest side an emoji raster may have, pixels: the UI asks for a line's height, not more.
+const MAX_SIDE: u32 = 512;
+
+/// `text` (an emoji or an emoji sequence such as `⚖️` or `👩‍💻`) drawn from a colour emoji font,
+/// about `px` pixels tall (the em square), cropped to its ink, in straight-alpha RGBA. `None` when
+/// the system has no colour emoji font or the glyphs carry no colour data, so the caller keeps the
+/// interface font's monochrome glyph.
+pub fn emoji_image(text: &str, px: u32) -> Option<EmojiImage> {
+    let px = px.clamp(4, MAX_SIDE) as f64;
+    let chars: Vec<char> = text.chars().collect();
+    if chars.iter().all(|c| c.is_whitespace() || c.is_control()) {
+        return None;
+    }
+    let face = wordcraft_fonts::FontDb::global().color_emoji_face(&chars, u32::MAX, false, false)?;
+    // A monochrome face (the run fallback when no colour emoji family is installed) would draw a
+    // flat silhouette: the interface font's own glyph is better than that.
+    if !wordcraft_fonts::COLOR_EMOJI.contains(&face.family.as_str()) {
+        return None;
+    }
+    let glyphs = wordcraft_fonts::shape(&face, text, &[], |c| c);
+    if glyphs.is_empty() {
+        return None;
+    }
+    let upem = face.upem;
+    if !(upem.is_finite() && upem > 0.0) {
+        return None;
+    }
+    let scale = px / upem;
+
+    // Drawables with their pen positions (font units, y down from the baseline), and the union of
+    // their boxes: the image is sized from that, then cropped to the ink.
+    let mut pen = 0.0f64;
+    let mut placed: Vec<(Arc<Drawable>, (f64, f64))> = Vec::new();
+    let mut bounds: Option<Rect> = None;
+    for g in &glyphs {
+        let origin = (pen + g.x_offset as f64, g.y_offset as f64);
+        if let Some(d) = color_glyph::drawable(&face, g.gid, px, true) {
+            let b = match &*d {
+                Drawable::Bitmap { rect, .. } => Rect::new(rect.x0 + origin.0, rect.y0 + origin.1, rect.x1 + origin.0, rect.y1 + origin.1),
+                // COLR glyphs have no bitmap box: take the em square, trimmed later by the ink.
+                Drawable::Layers(_) => Rect::new(origin.0 - 0.1 * upem, origin.1 - 0.95 * upem, origin.0 + 1.1 * upem, origin.1 + 0.1 * upem),
+            };
+            bounds = Some(match bounds {
+                Some(u) => u.union(b),
+                None => b,
+            });
+            placed.push((d, origin));
+        }
+        pen += g.x_advance as f64;
+    }
+    let b = bounds?;
+    // A wide sequence (a ZWJ family at 512 px) can need more than MAX_SIDE: shrink the drawing so
+    // its ink fits the image instead of being clipped at the right or bottom edge.
+    let raw_w = b.width() * scale + 2.0;
+    let raw_h = b.height() * scale + 2.0;
+    let shrink = (MAX_SIDE as f64 / raw_w).min(MAX_SIDE as f64 / raw_h).min(1.0);
+    let scale = scale * shrink;
+    let w = (b.width() * scale).ceil() + 2.0;
+    let h = (b.height() * scale).ceil() + 2.0;
+    if !(w.is_finite() && h.is_finite() && w >= 1.0 && h >= 1.0) {
+        return None;
+    }
+    let (w, h) = ((w as u32).clamp(1, MAX_SIDE), (h as u32).clamp(1, MAX_SIDE));
+
+    let mut ctx = CONTEXTS.take(w as u16, h as u16);
+    ctx.set_transform(Affine::IDENTITY);
+    // Font units (pen at the origin) → the image, with one pixel of margin.
+    let to_image = Affine::translate((1.0 - b.x0 * scale, 1.0 - b.y0 * scale)) * Affine::scale(scale);
+    for (d, origin) in &placed {
+        color_glyph::draw(&mut ctx, d, to_image * Affine::translate(*origin), upem, vello_cpu::peniko::Color::WHITE, 1.0);
+    }
+    ctx.flush();
+    let mut pixels = vec![0u8; w as usize * h as usize * 4];
+    let mut res = Resources::new();
+    if let Some(pm) = PixmapMut::new(w as u16, h as u16, &mut pixels) {
+        ctx.render(pm, &mut res);
+    }
+    CONTEXTS.give_back(ctx);
+
+    crop_straight(&pixels, w, h)
+}
+
+/// Crop premultiplied RGBA rows to the ink and un-premultiply them.
+fn crop_straight(pixels: &[u8], w: u32, h: u32) -> Option<EmojiImage> {
+    let (mut x0, mut y0, mut x1, mut y1) = (u32::MAX, u32::MAX, 0u32, 0u32);
+    for y in 0..h {
+        for x in 0..w {
+            if pixels[((y * w + x) * 4 + 3) as usize] != 0 {
+                x0 = x0.min(x);
+                y0 = y0.min(y);
+                x1 = x1.max(x);
+                y1 = y1.max(y);
+            }
+        }
+    }
+    if x0 == u32::MAX {
+        return None;
+    }
+    let (cw, ch) = (x1 - x0 + 1, y1 - y0 + 1);
+    let mut rgba = Vec::with_capacity((cw * ch * 4) as usize);
+    for y in y0..=y1 {
+        for x in x0..=x1 {
+            let i = ((y * w + x) * 4) as usize;
+            let a = pixels[i + 3] as u32;
+            let straight = |v: u8| if a == 0 || a == 255 { v } else { ((v as u32 * 255 + a / 2) / a).min(255) as u8 };
+            rgba.extend_from_slice(&[straight(pixels[i]), straight(pixels[i + 1]), straight(pixels[i + 2]), a as u8]);
+        }
+    }
+    Some(EmojiImage { width: cw, height: ch, rgba })
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn has_colour_emoji() -> bool {
+        wordcraft_fonts::FontDb::global().color_emoji_face(&['📘'], u32::MAX, false, false).is_some()
+    }
+
+    /// A colour emoji font, when the system has one, gives a non-empty image with actual colour in
+    /// it — what the Navigation pane paints over the interface font's monochrome glyph.
+    #[test]
+    fn a_colour_emoji_rasterises_in_colour() {
+        if !has_colour_emoji() {
+            return;
+        }
+        let img = emoji_image("📘", 32).expect("the blue book rasterises");
+        assert!(img.width > 4 && img.height > 4, "cropped to the ink: {img:?}");
+        assert_eq!(img.rgba.len(), (img.width * img.height * 4) as usize);
+        let colourful = img.rgba.chunks(4).any(|p| {
+            p[3] > 200 && {
+                let (hi, lo) = (*p[0..3].iter().max().unwrap(), *p[0..3].iter().min().unwrap());
+                hi - lo > 40
+            }
+        });
+        assert!(colourful, "the book is blue, not grey");
+    }
+
+    #[test]
+    fn several_markers_rasterise() {
+        if !has_colour_emoji() {
+            return;
+        }
+        for m in ["📘", "🔄", "⚖️", "📊", "📝"] {
+            assert!(emoji_image(m, 20).is_some(), "{m} must rasterise");
+        }
+    }
+
+    /// Junk input and text-only strings give no image: the caller keeps the monochrome glyph.
+    #[test]
+    fn text_and_blank_strings_have_no_image() {
+        assert!(emoji_image("   ", 16).is_none());
+        assert!(emoji_image("", 16).is_none());
+        if has_colour_emoji() {
+            assert!(emoji_image("conceito", 16).is_none(), "letters are not a colour emoji");
+        }
+    }
+
+    /// The size asked for is respected (the em square, about), and nonsense sizes are clamped
+    /// instead of asking vello for a huge pixmap.
+    #[test]
+    fn the_requested_size_is_respected() {
+        if !has_colour_emoji() {
+            return;
+        }
+        let small = emoji_image("📘", 8).expect("small");
+        let big = emoji_image("📘", 96).expect("big");
+        assert!(big.height > small.height, "bigger size, taller image: {small:?} {big:?}");
+        assert!(emoji_image("📘", 0).is_some(), "clamped, not zero");
+    }
+}
diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs
--- a/crates/render/src/lib.rs
+++ b/crates/render/src/lib.rs
@@ -17,6 +17,7 @@ use wordcraft_layout::display::{DisplayOptions, Draw, Stroke, page_layers};
 use wordcraft_metafile::{Item, Picture, PlacedItem, Seg};
 
 mod color_glyph;
+mod emoji;
 pub mod icon_lib;
 
 /// Worker threads for rasterising (0 on the web, where there are no threads).
@@ -575,6 +576,8 @@ pub struct RenderOptions {
     pub read_paper: Option<Rgb>,
 }
 
+pub use emoji::{EmojiImage, emoji_image};
+
 /// Default grey for a dark page's paper.
 pub const DARK_PAPER: u8 = 0x33;
 
diff --git a/crates/render/src/tests_color_glyphs.rs b/crates/render/src/tests_color_glyphs.rs
--- a/crates/render/src/tests_color_glyphs.rs
+++ b/crates/render/src/tests_color_glyphs.rs
@@ -98,3 +98,52 @@ fn emoji_page_with(opts: &RenderOptions, cp: wordcraft_doc::CharProps) -> Option
     assert_eq!(boxes.len(), 2, "both emoji use the colour emoji font");
     Some((img, boxes))
 }
+
+/// A keycap sequence (`1️⃣`) is drawn as the one colour emoji it is: the emoji font's ligature
+/// draws digit and enclosing keycap together, where the text font leaves a missing-glyph box
+/// after the digit. Skips when no colour emoji font is installed.
+#[cfg(not(target_arch = "wasm32"))]
+#[test]
+fn a_keycap_is_drawn_as_a_colour_emoji() {
+    let keycap = "1\u{FE0F}\u{20E3}";
+    let chars: Vec<char> = keycap.chars().collect();
+    if FontDb::global().color_emoji_face(&chars, u32::MAX, false, false).is_none() {
+        eprintln!("skipped: no colour emoji font installed");
+        return;
+    }
+    let mut d = Document::from_text("x");
+    d.body = vec![wordcraft_doc::para_block(wordcraft_doc::Paragraph::with_text(&format!("{keycap} Tema"), wordcraft_doc::CharProps::default()))];
+    let l = layout(&d, &mut LayoutCache::new(), &LayoutOptions::default());
+    let page = &l.pages[0];
+    let scale = 2.0;
+    let img = render_page(&d, page, scale, &RenderOptions::default());
+    let mut box_of = None;
+    for it in page_display(&d, page, &DisplayOptions::default()) {
+        if let Draw::Glyphs { face, size, glyphs, text, .. } = it
+            && COLOR_EMOJI.contains(&face.family.as_str())
+        {
+            // One glyph draws the whole keycap: its ligature, not the digit alone.
+            assert_eq!(glyphs.len(), 1, "keycap drawn with {} glyphs in {}", glyphs.len(), face.family);
+            assert_eq!(text, keycap, "the colour emoji draws the keycap text");
+            let (_, x, y) = glyphs[0];
+            let pad = 6.0;
+            box_of = Some((
+                ((x * scale - pad).max(0.0) as u32),
+                (((y - size) * scale - pad).max(0.0) as u32),
+                ((x + size) * scale + pad) as u32,
+                (y * scale + pad) as u32,
+            ));
+        }
+    }
+    let (x0, y0, x1, y1) = box_of.expect("the keycap is drawn from the colour emoji font");
+    // The keycap's badge is blue: its blue channel runs well above its red one, where the paper
+    // and the black text are grey.
+    let blue = (x0..x1)
+        .flat_map(|x| (y0..y1).map(move |y| (x, y)))
+        .filter(|(x, y)| {
+            let p = img.pixel(*x, *y);
+            i32::from(p[2]) - i32::from(p[0]) > 20
+        })
+        .count();
+    assert!(blue > 30, "only {blue} blue pixels in the keycap box {x0},{y0}..{x1},{y1}");
+}
diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs
--- a/crates/ui-egui/src/chrome.rs
+++ b/crates/ui-egui/src/chrome.rs
@@ -604,14 +604,20 @@ pub fn status_bar(app: &mut WordApp, ui: &mut Ui) {
                 let lang = crate::ribbon::caret_language(app);
                 let missing = crate::ribbon::caret_missing_dictionary(app);
                 let unreadable = crate::ribbon::caret_unreadable_dictionary(app);
-                let shown = if unreadable.is_some() {
+                let shown = if !app.session.view.proofing {
+                    tl!("Do not check spelling or grammar").to_string()
+                } else if unreadable.is_some() {
                     crate::i18n::fmt(tl!("{language} (dictionary unreadable)"), &[("language", &lang)])
                 } else if missing.is_some() {
                     crate::i18n::fmt(tl!("{language} (no dictionary)"), &[("language", &lang)])
                 } else {
                     lang.clone()
                 };
-                let tip = dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string());
+                let tip = if app.session.view.proofing {
+                    dictionary_problem(&lang, &unreadable, missing).unwrap_or_else(|| tl!("Language").to_string())
+                } else {
+                    tl!("Spelling and grammar are not checked.").to_string()
+                };
                 let resp = st(ui, &shown).on_hover_text(tip);
                 egui::Popup::menu(&resp).show(|ui| {
                     ui.set_min_width(200.0);
diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs
--- a/crates/ui-egui/src/options.rs
+++ b/crates/ui-egui/src/options.rs
@@ -472,11 +472,14 @@ fn save(app: &mut WordApp, ui: &mut Ui) {
 fn language(app: &mut WordApp, ui: &mut Ui) {
     section(ui, "Interface language");
     language_picker(app, ui);
-    // #414 (detect the document's language) goes here.
     ui.add_space(8.0);
     section(ui, "Proofing language");
+    review_language_picker(app, ui);
+    note(ui, "Automatic follows the interface language. It sets the spelling and grammar dictionary for your documents.");
     let lang = crate::ribbon::caret_language(app);
-    let text = if crate::ribbon::caret_no_proof(app) {
+    let text = if !app.session.view.proofing {
+        tl!("Spelling and grammar are not checked: Review language is Off.").to_string()
+    } else if crate::ribbon::caret_no_proof(app) {
         tl!("The text at the insertion point is marked Do not check spelling or grammar.").to_string()
     } else if !crate::ribbon::caret_language_proofed(app) {
         crate::i18n::fmt(tl!("{language}: WordCraft doesn't check spelling or grammar in this language yet."), &[("language", &lang)])
@@ -720,6 +723,51 @@ fn animations_picker(app: &mut WordApp, ui: &mut Ui) {
     }
 }
 
+/// Review language: the app's proofing default for text that names no language. The same shapes
+/// as the interface picker above (Dropdown + helper line); Automatic follows the interface, Off
+/// turns the live check off, and a language is used as it is. The choice is persisted in
+/// `ui.json` and applies to the open document's default language at once.
+fn review_language_picker(app: &mut WordApp, ui: &mut Ui) {
+    use crate::ribbon::{PROOFING_LANGUAGES, language_name};
+    let checking = app.session.view.proofing;
+    let saved = app.session.prefs.default_language.clone();
+    let resolved = saved.clone().or_else(|| app.session.editing_lang.clone()).unwrap_or_else(|| "en-US".to_string());
+    let resolved_name = tl!(&language_name(Some(&resolved))).to_string();
+    let automatic = crate::i18n::fmt(tl!("Automatic ({language})"), &[("language", &resolved_name)]);
+    let shown = if !checking {
+        tl!("Do not check spelling or grammar").to_string()
+    } else if saved.is_none() {
+        automatic.clone()
+    } else {
+        tl!(&language_name(Some(&resolved))).to_string()
+    };
+    let mut chosen: Option<&str> = None;
+    ui.horizontal(|ui| {
+        let label = ui.label(tl!("Review language:"));
+        let combo = egui::ComboBox::from_id_salt("review_language").selected_text(shown).width(260.0).show_ui(ui, |ui| {
+            if ui.selectable_label(checking && saved.is_none(), automatic.as_str()).clicked() {
+                chosen = Some("auto");
+            }
+            for (tag, _) in PROOFING_LANGUAGES.iter().filter(|(t, _)| ["pt-BR", "pt-PT", "en-US", "es-ES"].contains(t)) {
+                let name = tl!(&language_name(Some(tag))).to_string();
+                let on = checking && saved.as_deref().is_some_and(|s| s.eq_ignore_ascii_case(tag));
+                if ui.selectable_label(on, name).clicked() {
+                    chosen = Some(tag);
+                }
+            }
+            if ui.selectable_label(!checking, tl!("Do not check spelling or grammar")).clicked() {
+                chosen = Some("off");
+            }
+        });
+        combo.response.labelled_by(label.id);
+    });
+    if let Some(value) = chosen
+        && let Err(e) = app.run("review.defaultLanguage", json!({"value": value}))
+    {
+        app.status(e);
+    }
+}
+
 /// Interface language: follow the system (the default) or pick one (#8).
 fn language_picker(app: &mut WordApp, ui: &mut Ui) {
     use crate::i18n::{AUTO, Lang};
@@ -897,6 +945,43 @@ mod tests {
         h
     }
 
+    /// File › Options › Language: "Review language" is a dropdown (not static text). The choice
+    /// is persisted, applied to the open document at once, and the status bar reads it back.
+    #[test]
+    fn review_language_picker_persists_and_applies() {
+        use egui_kittest::kittest::Queryable;
+        let mut a = app();
+        a.run("ui.language", json!({"value": "en"})).unwrap();
+        a.run("file.options", json!({"pane": "language"})).unwrap();
+        let mut h = egui_kittest::Harness::builder().with_size(egui::vec2(1440.0, 900.0)).build_ui_state(
+            |ui, app: &mut crate::WordApp| {
+                let ctx = ui.ctx().clone();
+                app.logic(&ctx);
+                app.ui(ui);
+            },
+            a,
+        );
+        h.run_steps(4);
+        let combo_text =
+            |h: &egui_kittest::Harness<'static, crate::WordApp>| h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Review language:").value();
+        assert!(combo_text(&h).as_deref().is_some_and(|v| v.contains("Automatic")), "{:?}", combo_text(&h));
+        h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Review language:").click();
+        h.run_steps(3);
+        h.get_by_label("Portuguese (Brazil)").click();
+        h.run_steps(4);
+        assert_eq!(h.state().session.prefs.default_language.as_deref(), Some("pt-BR"));
+        assert_eq!(h.state().session.doc.styles.default_chr.lang.as_deref(), Some("pt-BR"), "applied to the document");
+        assert!(h.state().session.view.proofing && h.state().session.prefs.check_spelling);
+        assert_eq!(crate::ribbon::caret_language(h.state()), "Portuguese (Brazil)", "the status bar follows");
+        // Off turns the live check off and the dropdown (and the bar) say so.
+        h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Review language:").click();
+        h.run_steps(3);
+        h.get_by_label("Do not check spelling or grammar").click();
+        h.run_steps(4);
+        assert!(!h.state().session.view.proofing && !h.state().session.prefs.check_spelling);
+        assert_eq!(combo_text(&h).as_deref(), Some("Do not check spelling or grammar"));
+    }
+
     /// File › Options must not offer a way around the protected document's author check.
     #[test]
     fn user_name_field_disables_when_protection_starts() {
diff --git a/crates/ui-egui/src/panes.rs b/crates/ui-egui/src/panes.rs
--- a/crates/ui-egui/src/panes.rs
+++ b/crates/ui-egui/src/panes.rs
@@ -7,7 +7,7 @@
 use std::collections::BTreeMap;
 
 use craft_ui::panes::{Edge, PaneContent, PaneHost, PaneLabels, PaneRequest, PaneState, Place, SidePolicy};
-use egui::{Stroke, Ui, vec2};
+use egui::{Stroke, Ui, pos2, vec2};
 use serde_json::{Value, json};
 use wordcraft_doc::numbering::{Counters, LevelSuffix};
 use wordcraft_doc::section::NumFormat;
@@ -520,6 +520,9 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
             }
         }
         _ => {
+            // Modelo 2: the rows touch, so the vertical trunks have no gaps between them.
+            ui.scope(|ui| {
+            ui.spacing_mut().item_spacing.y = 0.0;
             let caret = app.session.sel.focus.clone();
             let items = nav_headings(&app.session.doc);
             let current = items.iter().rposition(|(path, ..)| *path <= caret.path);
@@ -531,25 +534,57 @@ fn nav(app: &mut WordApp, ui: &mut Ui) {
             let collapsed_flags: Vec<bool> = items.iter().enumerate().map(|(i, (p, _, _))| has_children[i] && app.ui.nav_collapsed.contains(p)).collect();
             let levels: Vec<u8> = items.iter().map(|(_, lv, _)| *lv).collect();
             let visible = visible_headings(&levels, &collapsed_flags);
-            for (i, (path, lv, txt)) in items.into_iter().enumerate() {
+            let chain = active_chain(&levels, &visible, current);
+            let current_index = current;
+            // The levels this heading nests under, for the tree's connector lines.
+            let mut ancestors: Vec<u8> = Vec::new();
+            for (i, (path, lv, txt)) in items.iter().enumerate() {
                 if !visible[i] {
                     continue;
                 }
+                while ancestors.last().is_some_and(|a| *a >= *lv) {
+                    ancestors.pop();
+                }
+                // The last child of its parent closes the guide line under it at the row's middle.
+                let last = items
+                    .iter()
+                    .enumerate()
+                    .skip(i + 1)
+                    .find(|(j, _)| visible[*j])
+                    .is_none_or(|(_, (_, next, _))| *next <= *lv);
                 let collapsed = collapsed_flags[i];
-                let (toggle, r) = nav_heading(ui, lv, &txt, current == Some(i), has_children[i].then_some(collapsed));
+                let row_id = ui.make_persistent_id(("nav_row", path));
+                // The trunks of the active branch: each ancestor level whose parent row is on the
+                // chain and which lies above (or at) the selected row.
+                let active_guides = branch_guide_levels(&ancestors, &chain, &levels, i, current_index);
+                let on_chain = chain.contains(&i);
+                let (toggle, r) = nav_heading(
+                    ui,
+                    row_id,
+                    *lv,
+                    txt,
+                    current == Some(i),
+                    has_children[i].then_some(collapsed),
+                    &ancestors,
+                    last,
+                    on_chain,
+                    &active_guides,
+                );
                 if toggle {
-                    if !app.ui.nav_collapsed.remove(&path) {
-                        app.ui.nav_collapsed.insert(path);
+                    if !app.ui.nav_collapsed.remove(path) {
+                        app.ui.nav_collapsed.insert(path.clone());
                     }
-                } else if r.clicked() {
-                    let _ = app.run("caret.set", json!({"pos": Pos { story: StoryRef::Body, path, off: 0 }}));
+                } else if r.clicked() || (r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter) || i.key_pressed(egui::Key::Space))) {
+                    let _ = app.run("caret.set", json!({"pos": Pos { story: StoryRef::Body, path: path.clone(), off: 0 }}));
                     // Align the chosen heading's page immediately against this pane, rather
                     // than the indeterminate minimum movement from scroll_to_rect.
                     app.canvas.nav_to_caret = true;
                     app.canvas.scroll_to_caret = false;
                     app.canvas.want_focus = true;
                 }
+                ancestors.push(*lv);
             }
+            });
         }
     });
 }
@@ -603,38 +638,301 @@ fn nav_headings(doc: &Document) -> Vec<(Path, u8, String)> {
     items
 }
 
-/// Constrain each heading to the available pane width so long document text cannot
-/// enlarge the panel. A heading too long for the width wraps onto further lines — a word
-/// wider than the pane is broken too — and the row grows with it, so the label reads whole
-/// instead of ending in an ellipsis (#3015).
-fn nav_heading(ui: &mut Ui, level: u8, text: &str, selected: bool, collapsed: Option<bool>) -> (bool, egui::Response) {
-    ui.horizontal(|ui| {
-        ui.add_space(level as f32 * 14.0);
-        // The triangle is drawn in code; headings without children keep an empty slot so text aligns.
-        let mut toggled = false;
-        let (rect, tri) = ui.allocate_exact_size(vec2(14.0, ui.spacing().interact_size.y), egui::Sense::click());
-        if let Some(c) = collapsed {
-            let ctr = rect.center();
-            let col = ui.visuals().text_color();
-            let pts = if c {
-                vec![ctr + vec2(-2.5, -4.0), ctr + vec2(-2.5, 4.0), ctr + vec2(3.0, 0.0)]
-            } else {
-                vec![ctr + vec2(-4.0, -2.5), ctr + vec2(4.0, -2.5), ctr + vec2(0.0, 3.0)]
-            };
-            ui.painter().add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
-            toggled = tri.clicked();
+/// Distance one heading level is indented under its parent, points.
+const NAV_INDENT: f32 = 14.0;
+
+/// Modelo 2 — Curva Orgânica Fluida: the tree's connector lines are 25% thicker than the old
+/// single pixel, the active branch is drawn heavier (with a glow) and the elbow into a row is a
+/// quarter circle of this radius; a parent whose children branch off gets a small node there.
+const NAV_GUIDE_WIDTH: f32 = 1.25;
+const NAV_ACTIVE_WIDTH: f32 = 1.5;
+const NAV_ELBOW_RADIUS: f32 = 6.0;
+const NAV_NODE_RADIUS: f32 = 2.25;
+/// The fork slot before the triangle: the articulation node sits in its middle, and the trunk to
+/// the children leaves from there (so the node is visible, as ● ▾ in the Modelo 2 diagram).
+const NAV_FORK_SLOT: f32 = 8.0;
+
+/// The row indices on the caret's branch, from the root down to the selected heading.
+fn active_chain(levels: &[u8], visible: &[bool], current: Option<usize>) -> Vec<usize> {
+    let mut chain = Vec::new();
+    let Some(mut i) = current else { return chain };
+    if !visible.get(i).copied().unwrap_or(false) {
+        return chain;
+    }
+    chain.push(i);
+    let mut level = levels[i];
+    loop {
+        let parent = (0..i).rev().find(|&j| visible.get(j).copied().unwrap_or(false) && levels[j] < level);
+        let Some(j) = parent else { break };
+        chain.push(j);
+        i = j;
+        level = levels[j];
+    }
+    chain.reverse();
+    chain
+}
+
+/// The guide levels of row `i` whose trunk belongs to the active branch: an ancestor level is lit
+/// when its parent row is on the chain and the row lies between that parent and the selected row,
+/// which carries the light through the siblings passed on the way down.
+fn branch_guide_levels(ancestors: &[u8], chain: &[usize], levels: &[u8], i: usize, current: Option<usize>) -> Vec<u8> {
+    let Some(current) = current else { return Vec::new() };
+    ancestors.iter().copied().filter(|a| chain.iter().any(|&k| levels.get(k) == Some(a) && k <= i) && i <= current).collect()
+}
+
+/// The colour of the keycap emoji the page draws (Noto Color Emoji's light steel blue): the pane
+/// reproduces the badge it makes, so the heading looks the same in both places.
+const KEYCAP_BADGE: egui::Color32 = egui::Color32::from_rgb(140, 175, 191);
+
+/// A heading that starts with a keycap (`1️⃣`, `#️⃣`): the digit and the text after the space
+/// that follows it. The interface fonts have no keycap ligature (they would draw the digit and
+/// drop the enclosing mark), so the pane draws the keycap as the badge the page shows.
+fn keycap_prefix(text: &str) -> Option<(char, &str)> {
+    let mut it = text.char_indices();
+    let (_, digit) = it.next()?;
+    if !(digit.is_ascii_digit() || digit == '#' || digit == '*') {
+        return None;
+    }
+    for (i, c) in it {
+        match c {
+            // The variation selector is optional (a keycap may be digit + enclosing keycap).
+            '\u{FE0E}' | '\u{FE0F}' => {}
+            '\u{20E3}' => {
+                let end = i + c.len_utf8();
+                let rest = text.get(end..).unwrap_or("");
+                return Some((digit, rest.strip_prefix(' ').unwrap_or(rest)));
+            }
+            _ => return None,
         }
-        let label = egui::RichText::new(text).font(if level == 0 { semibold(12.5) } else { regular(12.5) });
-        let width = ui.available_width();
-        let r = ui.add(
-            // The trailing grow atom takes the spare width, so the text starts flush left
-            // under its parent instead of centred, as in Word (#1199). A minimum height
-            // keeps short headings on one row; a wrapped one becomes as tall as it needs.
-            egui::Button::selectable(selected, (label, egui::Atom::grow())).wrap().min_size(vec2(width, ui.spacing().interact_size.y)),
-        );
-        (toggled, r)
-    })
-    .inner
+    }
+    None
+}
+
+/// Byte ranges of the emoji sequences in a heading's text, in order.
+fn emoji_clusters(text: &str) -> Vec<(usize, usize)> {
+    let starts = wordcraft_doc::cluster::cluster_starts(text);
+    let mut out = Vec::new();
+    for (i, &s) in starts.iter().enumerate() {
+        let e = starts.get(i + 1).copied().unwrap_or(text.len());
+        if let Some(c) = text.get(s..e) {
+            // Presentation sequences (`⚖️`, keycaps, ZWJ) and characters that are emoji by
+            // themselves (`📘`): the pane paints both from the colour emoji font.
+            let chars: Vec<char> = c.chars().collect();
+            if wordcraft_fonts::emoji_sequence(&chars) || chars.first().is_some_and(|&f| wordcraft_fonts::is_emoji(f)) {
+                out.push((s, e));
+            }
+        }
+    }
+    out
+}
+
+/// The interface's colour image for an emoji, kept between frames (rasterising it again every
+/// frame would burn the CPU for nothing: the pane redraws while typing).
+fn emoji_texture(ui: &Ui, text: &str, px: u32) -> Option<egui::TextureHandle> {
+    let id = egui::Id::unique(("nav_emoji", text, px));
+    if let Some(t) = ui.ctx().data(|d| d.get_temp::<egui::TextureHandle>(id)) {
+        return Some(t);
+    }
+    let img = wordcraft_render::emoji_image(text, px)?;
+    let color = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.rgba);
+    let tex = ui.ctx().load_texture(format!("nav-emoji-{text}"), color, egui::TextureOptions::LINEAR);
+    ui.ctx().data_mut(|d| d.insert_temp(id, tex.clone()));
+    Some(tex)
+}
+
+/// Paint the colour images of the emoji in `text` over the glyphs the pane's (transparent) text
+/// found for them. The interface fonts have emoji only in monochrome; the page draws them from the
+/// colour font, and the Navigator follows the page rather than showing grey shapes.
+fn paint_emoji(ui: &Ui, pos: egui::Pos2, galley: &egui::Galley, text: &str, em: f32) {
+    let ppp = ui.ctx().pixels_per_point();
+    let mut glyphs = galley.rows.iter().flat_map(|r| r.glyphs.iter());
+    for (s, e) in emoji_clusters(text) {
+        let Some(first) = text.get(s..e).and_then(|c| c.chars().next()) else { continue };
+        // One glyph per drawn character, in order: the cluster's first character finds its box.
+        let Some(g) = glyphs.find(|g| g.chr == first) else { continue };
+        let baseline = pos.y + g.pos.y;
+        // An emoji fills its em square, not the (taller) line box: size it like the label's text so
+        // it sits with the words instead of swelling past them.
+        let target_h = (em * 1.05).max(1.0);
+        let top = baseline - target_h * 0.86;
+        let Some(tex) = emoji_texture(ui, &text[s..e], (target_h * ppp).round().max(1.0) as u32) else { continue };
+        let size = tex.size_vec2();
+        let aspect = (size.x / size.y.max(1.0)).clamp(0.2, 5.0);
+        let rect = egui::Rect::from_min_size(egui::pos2(pos.x + g.pos.x, top), vec2(target_h * aspect, target_h));
+        ui.painter().image(tex.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE);
+    }
+}
+
+/// One heading in the pane: its level's indent, the expand/collapse triangle, the keycap badge
+/// when the heading starts with one, and the label — one line, ended with an ellipsis when it
+/// doesn't fit, as Writer's Navigator does (the whole heading shows on hover). `guides` are the
+/// levels of the headings this one nests under: connector lines run down their slots and across
+/// to this row, and `last` closes the line under its parent at the row's middle.
+///
+/// The row paints itself rather than using a Button: the label mixes text with colour emoji
+/// images, which a single widget's text cannot. `id` keeps its hover/selection and click stable.
+#[allow(clippy::too_many_arguments)]
+fn nav_heading(
+    ui: &mut Ui,
+    id: egui::Id,
+    level: u8,
+    text: &str,
+    selected: bool,
+    collapsed: Option<bool>,
+    guides: &[u8],
+    last: bool,
+    active: bool,
+    active_guides: &[u8],
+) -> (bool, egui::Response) {
+    let t = Tokens::get(ui.ctx());
+    let row_h = ui.spacing().interact_size.y;
+    let keycap = keycap_prefix(text);
+    let label_text = keycap.map_or(text, |(_, rest)| rest);
+    // How wide the label may be: the row less the indent, the triangle slot, the keycap badge
+    // and the gaps between them. Budgeted here rather than from the cursor part-way through the
+    // row, so the label can never make the row wider than the pane (which used to widen it).
+    let gap = ui.spacing().item_spacing.x;
+    let badge_w = keycap.map_or(0.0, |_| (row_h * 0.78).max(9.0) * 1.08 + 5.0);
+    let row_w = ui.available_width().max(0.0);
+    let label_w = (row_w - level as f32 * NAV_INDENT - NAV_FORK_SLOT - NAV_INDENT - badge_w - 3.0 * gap).max(24.0);
+
+    // The row goes in first so its hover/selection fill is painted behind the text.
+    let (row, _) = ui.allocate_exact_size(vec2(row_w, row_h), egui::Sense::hover());
+    let hovered = ui.ctx().read_response(id).is_some_and(|r| r.hovered());
+    if selected {
+        ui.painter().rect_filled(row, 4.0, ui.visuals().selection.bg_fill);
+    } else if hovered {
+        ui.painter().rect_filled(row, 4.0, ui.visuals().widgets.hovered.weak_bg_fill);
+    }
+
+    // The label: text, with the emoji laid out transparent so their colour images can sit on top.
+    let ink = egui::TextFormat { font_id: regular(12.5), color: ui.visuals().text_color(), ..Default::default() };
+    let clear = egui::TextFormat { font_id: regular(12.5), color: egui::Color32::TRANSPARENT, ..Default::default() };
+    let mut job = egui::text::LayoutJob::default();
+    let mut prev = 0;
+    for (s, e) in emoji_clusters(label_text) {
+        if prev < s {
+            job.append(&label_text[prev..s], 0.0, ink.clone());
+        }
+        job.append(&label_text[s..e], 0.0, clear.clone());
+        prev = e;
+    }
+    if prev < label_text.len() {
+        job.append(&label_text[prev..], 0.0, ink.clone());
+    }
+    job.wrap = egui::text::TextWrapping { max_width: label_w, max_rows: 1, break_anywhere: true, overflow_character: Some('\u{2026}') };
+    let galley = ui.painter().layout_job(job);
+
+    let mut toggled = false;
+    let label = ui
+        .scope_builder(egui::UiBuilder::new().max_rect(row).layout(egui::Layout::left_to_right(egui::Align::Center)), |ui| {
+            ui.add_space(level as f32 * NAV_INDENT);
+            // The articulation node's slot: the Modelo 2 diagram puts ●/○ before the triangle, and
+            // the trunk to the children leaves from its middle; rows without children keep it empty
+            // so every label aligns.
+            let (fork, _) = ui.allocate_exact_size(vec2(NAV_FORK_SLOT, row_h), egui::Sense::hover());
+            let _ = fork;
+            // The triangle is drawn in code; headings without children keep an empty slot so text aligns.
+            let (rect, tri) = ui.allocate_exact_size(vec2(NAV_INDENT, row_h), egui::Sense::click());
+            if let Some(c) = collapsed {
+                let ctr = rect.center();
+                let col = ui.visuals().text_color();
+                let pts = if c {
+                    vec![ctr + vec2(-2.5, -4.0), ctr + vec2(-2.5, 4.0), ctr + vec2(3.0, 0.0)]
+                } else {
+                    vec![ctr + vec2(-4.0, -2.5), ctr + vec2(4.0, -2.5), ctr + vec2(0.0, 3.0)]
+                };
+                ui.painter().add(egui::Shape::convex_polygon(pts, col, Stroke::NONE));
+                toggled = tri.clicked();
+            }
+            if let Some((digit, _)) = keycap {
+                // The keycap the page draws: a rounded square in the accent colour with the digit on
+                // it, at the row's text height.
+                let h = (row_h * 0.78).max(9.0);
+                let (slot, _) = ui.allocate_exact_size(vec2(h * 1.08, row_h), egui::Sense::hover());
+                let badge = egui::Rect::from_center_size(slot.center(), vec2(h * 1.08, h));
+                ui.painter().rect_filled(badge, 2.5, KEYCAP_BADGE);
+                ui.painter().text(badge.center(), egui::Align2::CENTER_CENTER, digit, semibold(h * 0.62), egui::Color32::WHITE);
+                ui.add_space(5.0);
+            }
+            let width = ui.available_width().max(0.0);
+            let (label_rect, resp) = ui.allocate_exact_size(vec2(width, row_h), egui::Sense::click());
+            let pos = label_rect.left_top() + vec2(0.0, ((row_h - galley.size().y) * 0.5).max(0.0));
+            ui.painter().galley(pos, galley.clone(), ui.visuals().text_color());
+            paint_emoji(ui, pos, &galley, label_text, 12.5);
+            // The whole heading stays the row's accessible name, and a click anywhere on the label
+            // jumps to it (the caller handles it).
+            resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, true, selected, text));
+            ui.interact(label_rect, id, egui::Sense::click())
+        })
+        .inner;
+
+    if ui.ctx().memory(|m| m.has_focus(id)) {
+        ui.painter().rect_stroke(row.shrink(1.0), 4.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside);
+    }
+    // The tree's connector lines, painted under the indent and the triangle only so they never
+    // run over the label. Modelo 2 — Curva Orgânica Fluida: one continuous trunk per ancestor
+    // level (the rows are laid out with no vertical spacing, so there are no cuts between them),
+    // a quarter-circle elbow into this row, and a small node where a parent's children branch.
+    let mid = row.center().y;
+    let left = row.left();
+    // The trunk of a level runs through the middle of that level's fork slot; a row's own connector
+    // ends there (at the node when it has children).
+    let fork_x = |a: u8| left + a as f32 * NAV_INDENT + NAV_FORK_SLOT * 0.5;
+    let end = fork_x(level);
+    let normal = Stroke::new(NAV_GUIDE_WIDTH, t.tree_line);
+    if let Some(innermost) = guides.last().copied() {
+        let painter = ui.painter();
+        let radius = NAV_ELBOW_RADIUS.min((row.height() * 0.5 - 1.0).max(2.0));
+        // Each trunk of the active branch is lit from the root down to the selected heading: a
+        // segment belongs to the branch when the selected row is inside that ancestor's subtree
+        // and the segment lies above it, which includes the siblings passed on the way.
+        let lit = |a: u8| active_guides.contains(&a);
+        let glow = Stroke::new(NAV_ACTIVE_WIDTH + 2.0, t.accent.gamma_multiply(0.35));
+        for &a in guides {
+            let bottom = if last && a == innermost { mid - radius } else { row.bottom() };
+            if lit(a) {
+                painter.vline(fork_x(a), row.top()..=bottom, glow);
+            }
+            painter.vline(fork_x(a), row.top()..=bottom, if lit(a) { Stroke::new(NAV_ACTIVE_WIDTH, t.accent) } else { normal });
+        }
+        // The organic elbow: the trunk turns into the row through a quarter circle, never a hard
+        // right angle.
+        let x = fork_x(innermost);
+        let elbow = if active { Stroke::new(NAV_ACTIVE_WIDTH, t.accent) } else { normal };
+        // A row without children has no node or triangle; the connector runs on to where its label
+        // (or keycap badge) starts, so the branch reads as joined instead of stopping short.
+        let target = if collapsed.is_some() { end } else { left + level as f32 * NAV_INDENT + NAV_FORK_SLOT + NAV_INDENT };
+        if active {
+            painter.hline(x + radius..=target, mid, glow);
+        }
+        if target > x {
+            let c = radius * 0.552_284_8;
+            painter.add(egui::epaint::CubicBezierShape::from_points_stroke(
+                [pos2(x, mid - radius), pos2(x, mid - radius + c), pos2(x + radius - c, mid), pos2(x + radius, mid)],
+                false,
+                egui::Color32::TRANSPARENT,
+                elbow,
+            ));
+            painter.hline(x + radius..=target, mid, elbow);
+        }
+    }
+    // The articulation node: filled while the group is open, hollow while it is collapsed. A stub
+    // reaches from it to the first child's row, so the branch has no cut at the parent's bottom half.
+    if let Some(collapsed) = collapsed {
+        let centre = pos2(end, mid);
+        let colour = if active { t.accent } else { t.tree_line };
+        if !collapsed {
+            ui.painter().vline(end, mid + NAV_NODE_RADIUS..=row.bottom(), Stroke::new(NAV_GUIDE_WIDTH, if active { t.accent } else { t.tree_line }));
+        }
+        if collapsed {
+            ui.painter().circle_filled(centre, NAV_NODE_RADIUS, t.panel);
+            ui.painter().circle_stroke(centre, NAV_NODE_RADIUS, Stroke::new(1.35, colour));
+        } else {
+            ui.painter().circle_filled(centre, NAV_NODE_RADIUS, colour);
+        }
+    }
+    (toggled, label.on_hover_text(text))
 }
 
 /// Items collected by Copy and Cut, newest first; clicking one pastes it at the caret.
@@ -1330,6 +1628,33 @@ mod tests {
         }
     }
 
+    /// Modelo 2: every ancestor level's trunk is lit from its parent down to the selected row,
+    /// including the siblings passed on the way (the CSS `.is-active-branch` path).
+    #[test]
+    fn branch_guides_light_the_way_from_the_root_to_the_selection() {
+        let levels = [0u8, 1, 1, 2, 2, 1];
+        let chain = super::active_chain(&levels, &[true; 6], Some(4));
+        assert_eq!(chain, vec![0, 2, 4]);
+        let g = |ancestors: &[u8], i: usize| super::branch_guide_levels(ancestors, &chain, &levels, i, Some(4));
+        assert_eq!(g(&[0], 1), vec![0], "a sibling above the branch lights the root trunk");
+        assert_eq!(g(&[0], 2), vec![0]);
+        assert_eq!(g(&[0, 1], 3), vec![0, 1]);
+        assert_eq!(g(&[0, 1], 4), vec![0, 1], "the selected row closes the path");
+    }
+
+    /// Modelo 2: the caret's branch is the chain from the root down to the selected heading, so
+    /// the trunks of every ancestor level are lit through the siblings passed on the way.
+    #[test]
+    fn active_chain_runs_from_the_root_to_the_selected_heading() {
+        let levels = [1u8, 2, 2, 3, 1];
+        let visible = [true; 5];
+        assert_eq!(super::active_chain(&levels, &visible, Some(3)), vec![0, 2, 3]);
+        assert_eq!(super::active_chain(&levels, &visible, Some(0)), vec![0]);
+        assert!(super::active_chain(&levels, &visible, None).is_empty());
+        let hidden = [true, false, false, false, true];
+        assert!(super::active_chain(&levels, &hidden, Some(1)).is_empty(), "a hidden heading lights nothing");
+    }
+
     /// Ctrl and F-key shortcuts run while F6 has put the keyboard on a pane's title bar; a pane's
     /// text field keeps Ctrl+H for itself.
     #[test]
@@ -1684,7 +2009,7 @@ mod navigation_tests {
     }
 
     #[test]
-    fn long_headings_wrap_without_stretching_navigation() {
+    fn long_headings_elide_without_stretching_navigation() {
         // A heading that runs well past the width, and a single word wider than the pane.
         let headings = ["An extremely long heading with words that must stay inside the navigation pane ".repeat(20), "x".repeat(400)];
         // Draw one heading in a pane of `width`: the pane rect, the heading row's rect, the label's
@@ -1696,10 +2021,11 @@ mod navigation_tests {
             let mut panel_rect = egui::Rect::NOTHING;
             let mut heading_rect = egui::Rect::NOTHING;
             let output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
-                let result = egui::Panel::left("navigation_test")
-                    .default_size(width)
-                    .resizable(true)
-                    .show(ui, |ui| egui::ScrollArea::vertical().show(ui, |ui| nav_heading(ui, level, text, true, None).1).inner);
+                let result = egui::Panel::left("navigation_test").default_size(width).resizable(true).show(ui, |ui| {
+                    egui::ScrollArea::vertical()
+                        .show(ui, |ui| nav_heading(ui, egui::Id::unique(("t", level)), level, text, true, None, &[], true, false, &[]).1)
+                        .inner
+                });
                 panel_rect = result.response.rect;
                 heading_rect = result.inner.rect;
             });
@@ -1720,11 +2046,14 @@ mod navigation_tests {
                 let (panel_rect, heading_rect, rows, elided) = render(width, level, text);
                 assert!(panel_rect.width() < width + 20.0, "long heading expanded the {width}px pane to {panel_rect:?}");
                 assert!(heading_rect.max.x <= panel_rect.max.x, "heading escaped pane: {heading_rect:?} / {panel_rect:?}");
-                // The heading wraps onto further lines instead of ending in an ellipsis (#3015).
-                assert!(rows > 1, "long heading must wrap onto further lines at {width}px, got {rows}");
-                assert!(!elided, "long heading must not end in an ellipsis at {width}px");
-                // ... and its row grows with the text, where a short heading keeps one row.
-                assert!(heading_rect.height() > one_line.height(), "a wrapped heading row is taller at {width}px: {heading_rect:?} vs {one_line:?}");
+                // Writer's Navigator keeps the row on one line and ends it in an ellipsis (the
+                // 0.10 look); the whole heading shows on hover.
+                assert_eq!(rows, 1, "long heading must stay on one line at {width}px, got {rows}");
+                assert!(elided, "long heading must end in an ellipsis at {width}px");
+                assert!(
+                    (heading_rect.height() - one_line.height()).abs() < 0.5,
+                    "an elided heading keeps one row's height at {width}px: {heading_rect:?} vs {one_line:?}"
+                );
             }
         }
     }
@@ -1738,7 +2067,9 @@ mod navigation_tests {
             let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(900.0, 600.0));
             let mut panel_left = 0.0;
             let output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), ..Default::default() }, |ui| {
-                let result = egui::Panel::left("navigation_test").default_size(260.0).show(ui, |ui| nav_heading(ui, level, "Kilns", false, None));
+                let result = egui::Panel::left("navigation_test")
+                    .default_size(260.0)
+                    .show(ui, |ui| nav_heading(ui, egui::Id::unique("t2"), level, "Kilns", false, None, &[], true, false, &[]));
                 panel_left = result.response.rect.min.x;
             });
             let x = output
@@ -1753,7 +2084,9 @@ mod navigation_tests {
             x - panel_left
         };
         let (top, second) = (text_left(0), text_left(1));
-        assert!(top < 40.0, "a top-level heading starts at the pane's left edge, not centred: {top}px in");
+        // The Modelo 2 fork slot (the articulation node) sits before the triangle, so the label
+        // starts a little further in than the old 22 px; the indent per level is unchanged.
+        assert!(top < 56.0, "a top-level heading starts at the pane's left edge, not centred: {top}px in");
         assert!((second - top - 14.0).abs() < 0.5, "a level-2 heading is indented 14px under its parent: {top} / {second}");
     }
 
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
@@ -800,13 +800,14 @@ fn mi_text(ui: &mut Ui, app: &mut WordApp, text: &str, id: &str, params: Value)
 }
 
 /// The proofing languages the Language menu offers: tag and English name (translated when drawn).
-pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 16] = [
+pub(crate) const PROOFING_LANGUAGES: [(&str, &str); 17] = [
     ("en-US", "English (United States)"),
     ("en-GB", "English (United Kingdom)"),
     ("en-AU", "English (Australia)"),
     ("en-CA", "English (Canada)"),
     ("pl-PL", "Polish"),
     ("pt-BR", "Portuguese (Brazil)"),
+    ("pt-PT", "Portuguese (Portugal)"),
     ("it-IT", "Italian"),
     ("hr-HR", "Croatian"),
     ("tr-TR", "Turkish"),
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
@@ -264,6 +264,9 @@ pub struct Tokens {
     /// Find › Reading Highlight.
     pub find_highlight: Color32,
     pub page_shadow: Color32,
+    /// The Navigation pane's tree connectors (the guides joining a heading to its parent): thin
+    /// lines that must read on the pane without competing with the headings themselves.
+    pub tree_line: Color32,
     /// Selection-frame handles (pictures, shapes, text boxes) and their shadow.
     pub handle: Color32,
     pub handle_shadow: Color32,
@@ -332,6 +335,7 @@ impl Tokens {
             selection: Color32::from_rgba_unmultiplied(0x3B, 0x5B, 0xDB, 0x48),
             find_highlight: Color32::from_rgba_unmultiplied(0xFF, 0xE0, 0x3D, 0x90),
             page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x22),
+            tree_line: Color32::from_rgb(0xBD, 0xBD, 0xC2),
             handle: Color32::WHITE,
             handle_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x28),
             guide: Color32::from_rgb(0xC0, 0x28, 0x8C),
@@ -399,6 +403,9 @@ impl Tokens {
             selection: Color32::from_rgba_unmultiplied(0x7A, 0x93, 0xF0, 0x55),
             find_highlight: Color32::from_rgba_unmultiplied(0xC8, 0xA8, 0x00, 0x70),
             page_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x60),
+            // Clearer than `border` on the pane (#262626): the tree's thin connectors were almost
+            // invisible in the dark theme.
+            tree_line: Color32::from_rgb(0x55, 0x55, 0x5E),
             handle: Color32::from_rgb(0xF2, 0xF2, 0xF2),
             handle_shadow: Color32::from_rgba_unmultiplied(0, 0, 0, 0x5A),
             guide: Color32::from_rgb(0xE0, 0x4C, 0xB0),
@@ -1053,6 +1060,9 @@ mod tests {
         let exempt = |c: char, file: &str| match file {
             "equation_tab.rs" => matches!(c, '≜' | '┬'),
             "ribbon.rs" => c == '➢',
+            // The Navigation pane paints the emoji in heading labels from the colour emoji font;
+            // its variation selectors and keycap marks are painted by that image too.
+            "panes.rs" => wordcraft_fonts::is_emoji(c) || wordcraft_fonts::is_default_ignorable(c),
             _ => false,
         };
         symbols.retain(|(c, file)| !exempt(*c, file));
