diff --git a/crates/doc/src/collate.rs b/crates/doc/src/collate.rs
--- a/crates/doc/src/collate.rs
+++ b/crates/doc/src/collate.rs
@@ -5,7 +5,8 @@
 //! with `e` (`resume` < `résumé` < `rex`) unless the language makes it a letter of its own:
 //! Polish puts `ą` right after `a` and `ż` after `ź` after `z`, Swedish puts `å ä ö` after `z`,
 //! Czech puts `ch` after `h`, Spanish `ñ` after `n`, Hungarian `cs`, `gy`, `sz`… after their
-//! first letter. The per-language tables follow the published alphabets of those languages.
+//! first letter, Vietnamese `ă â` after `a`, `đ` after `d`, `ô ơ` after `o`, with the tones as
+//! accents. The per-language tables follow the published alphabets of those languages.
 //! Text without diacritics sorts exactly as a case-insensitive code-point compare.
 
 use std::cmp::Ordering;
@@ -171,6 +172,40 @@ const IS: &[Tailor] = &[
     t("æ", 'z', 2),
     t("ö", 'z', 3),
 ];
+const VI: &[Tailor] = &[t("đ", 'd', 1)];
+
+/// Vietnamese vowels (CLDR `vi`): the Latin letter each one sorts after, its rank there, and the
+/// letter followed by its toned forms in dictionary order of the tones: grave (huyền), hook above
+/// (hỏi), tilde (ngã), acute (sắc), dot below (nặng). The tone is an accent: `ma` < `mà` < `mả`
+/// < `mã` < `má` < `mạ`, and all of them sort before `mai`.
+const VI_VOWELS: &[(char, u16, &str)] = &[
+    ('a', 0, "aàảãáạ"),
+    ('a', 1, "ăằẳẵắặ"),
+    ('a', 2, "âầẩẫấậ"),
+    ('e', 0, "eèẻẽéẹ"),
+    ('e', 1, "êềểễếệ"),
+    ('i', 0, "iìỉĩíị"),
+    ('o', 0, "oòỏõóọ"),
+    ('o', 1, "ôồổỗốộ"),
+    ('o', 2, "ơờởỡớợ"),
+    ('u', 0, "uùủũúụ"),
+    ('u', 1, "ưừửữứự"),
+    ('y', 0, "yỳỷỹýỵ"),
+];
+
+/// Where languages other than Vietnamese put a toned or modified Vietnamese vowel among the accents
+/// of its Latin letter: after the [`DECOMP`] marks and the combining marks.
+const VI_FOLD: u16 = 160;
+
+/// A lowercase Vietnamese vowel: `(Latin letter, rank after it, the letter without its tone, tone)`
+/// with tone 0 for none and 1–5 in the order of [`VI_VOWELS`].
+fn vi_vowel(c: char) -> Option<(char, u16, &'static str, u16)> {
+    VI_VOWELS.iter().find_map(|(after, rank, forms)| {
+        let tone = forms.chars().position(|x| x == c)?;
+        let letter = forms.get(..forms.chars().next()?.len_utf8())?;
+        Some((*after, *rank, letter, u16::try_from(tone).ok()?))
+    })
+}
 
 /// The primary language subtag of a BCP 47 tag (`pl-PL` → `pl`), lowercased.
 pub fn language(lang: Option<&str>) -> String {
@@ -195,6 +230,7 @@ fn tailoring(lang: Option<&str>) -> &'static [Tailor] {
         "lv" => LV,
         "is" => IS,
         "sl" => LT,
+        "vi" => VI,
         _ => &[],
     }
 }
@@ -332,6 +368,57 @@ const COMPOSE: &[(char, char, char)] = &[
     ('z', '\u{302}', 'ẑ'),
     ('z', '\u{307}', 'ż'),
     ('z', '\u{30c}', 'ž'),
+    // Vietnamese: the hook above, dot below and horn, then the tones of `ă â ê ô ơ ư`. Decomposed
+    // text puts a dot below before a circumflex or breve (`ậ` is `a` U+0323 U+0302), typed text
+    // often after it.
+    ('a', '\u{309}', 'ả'),
+    ('a', '\u{323}', 'ạ'),
+    ('e', '\u{309}', 'ẻ'),
+    ('e', '\u{323}', 'ẹ'),
+    ('i', '\u{309}', 'ỉ'),
+    ('i', '\u{323}', 'ị'),
+    ('o', '\u{309}', 'ỏ'),
+    ('o', '\u{31b}', 'ơ'),
+    ('o', '\u{323}', 'ọ'),
+    ('u', '\u{309}', 'ủ'),
+    ('u', '\u{31b}', 'ư'),
+    ('u', '\u{323}', 'ụ'),
+    ('y', '\u{309}', 'ỷ'),
+    ('y', '\u{323}', 'ỵ'),
+    ('ạ', '\u{302}', 'ậ'),
+    ('ạ', '\u{306}', 'ặ'),
+    ('ẹ', '\u{302}', 'ệ'),
+    ('ọ', '\u{302}', 'ộ'),
+    ('ă', '\u{300}', 'ằ'),
+    ('ă', '\u{309}', 'ẳ'),
+    ('ă', '\u{303}', 'ẵ'),
+    ('ă', '\u{301}', 'ắ'),
+    ('ă', '\u{323}', 'ặ'),
+    ('â', '\u{300}', 'ầ'),
+    ('â', '\u{309}', 'ẩ'),
+    ('â', '\u{303}', 'ẫ'),
+    ('â', '\u{301}', 'ấ'),
+    ('â', '\u{323}', 'ậ'),
+    ('ê', '\u{300}', 'ề'),
+    ('ê', '\u{309}', 'ể'),
+    ('ê', '\u{303}', 'ễ'),
+    ('ê', '\u{301}', 'ế'),
+    ('ê', '\u{323}', 'ệ'),
+    ('ô', '\u{300}', 'ồ'),
+    ('ô', '\u{309}', 'ổ'),
+    ('ô', '\u{303}', 'ỗ'),
+    ('ô', '\u{301}', 'ố'),
+    ('ô', '\u{323}', 'ộ'),
+    ('ơ', '\u{300}', 'ờ'),
+    ('ơ', '\u{309}', 'ở'),
+    ('ơ', '\u{303}', 'ỡ'),
+    ('ơ', '\u{301}', 'ớ'),
+    ('ơ', '\u{323}', 'ợ'),
+    ('ư', '\u{300}', 'ừ'),
+    ('ư', '\u{309}', 'ử'),
+    ('ư', '\u{303}', 'ữ'),
+    ('ư', '\u{301}', 'ứ'),
+    ('ư', '\u{323}', 'ự'),
     ('е', '\u{308}', 'ё'),
     ('и', '\u{306}', 'й'),
 ];
@@ -365,8 +452,9 @@ struct Elem<'a> {
     own: bool,
 }
 
-/// The next collation element of `rest` (lowercase), and its length in bytes.
-fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> {
+/// The next collation element of `rest` (lowercase), and its length in bytes. `vi` sorts
+/// Vietnamese vowels as Vietnamese does.
+fn next_elem<'a>(rest: &'a str, rules: &[Tailor], vi: bool) -> Option<(Elem<'a>, usize)> {
     if let Some(r) = rules.iter().filter(|r| rest.starts_with(r.seq)).max_by_key(|r| r.seq.len()) {
         let text = rest.get(..r.seq.len())?;
         return Some((Elem { primary: vec![weight(r.after, r.rank)], sec: r.sec, text, own: true }, r.seq.len()));
@@ -374,6 +462,13 @@ fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> {
     let c = rest.chars().next()?;
     let n = c.len_utf8();
     let text = rest.get(..n)?;
+    let vowel = vi_vowel(c);
+    if vi {
+        // The text is the letter without its tone: `ấm` files under `Â` in an index.
+        if let Some((after, rank, letter, tone)) = vowel {
+            return Some((Elem { primary: vec![weight(after, u32::from(rank))], sec: tone, text: letter, own: true }, n));
+        }
+    }
     if let Some((_, base, mark)) = DECOMP.iter().find(|(x, _, _)| *x == c) {
         return Some((Elem { primary: base.chars().map(|b| weight(b, 0)).collect(), sec: *mark, text, own: false }, n));
     }
@@ -381,6 +476,10 @@ fn next_elem<'a>(rest: &'a str, rules: &[Tailor]) -> Option<(Elem<'a>, usize)> {
     if ('\u{300}'..='\u{36f}').contains(&c) {
         return Some((Elem { primary: Vec::new(), sec: 32 + (c as u32 - 0x300) as u16, text, own: false }, n));
     }
+    // Other languages sort a Vietnamese vowel with its Latin letter, as an accented form of it.
+    if let Some((after, rank, _, tone)) = vowel.filter(|(_, rank, _, tone)| *rank > 0 || *tone > 0) {
+        return Some((Elem { primary: vec![weight(after, 0)], sec: VI_FOLD + rank * 8 + tone, text, own: false }, n));
+    }
     Some((Elem { primary: vec![weight(c, 0)], sec: 0, text, own: false }, n))
 }
 
@@ -398,10 +497,11 @@ pub struct SortKey {
 pub fn sort_key(s: &str, lang: Option<&str>) -> SortKey {
     let text = lower_composed(s);
     let rules = tailoring(lang);
+    let vi = language(lang) == "vi";
     let mut key = SortKey::default();
     let mut i = 0;
     while let Some(rest) = text.get(i..).filter(|r| !r.is_empty()) {
-        let Some((e, n)) = next_elem(rest, rules) else { break };
+        let Some((e, n)) = next_elem(rest, rules, vi) else { break };
         i += n.max(1);
         if e.primary.is_empty() {
             if let Some(last) = key.secondary.last_mut() {
@@ -424,14 +524,20 @@ pub fn collate(a: &str, b: &str, lang: Option<&str>) -> Ordering {
 }
 
 /// The index heading letter `s` files under in language `lang`: `Ł` for `Łódź` in Polish, `L`
-/// in English; `Ch` for `chata` in Czech; `A` for `Äpfel` in German, `Ä` in Swedish.
+/// in English; `Ch` for `chata` in Czech; `A` for `Äpfel` in German, `Ä` in Swedish; `Â` for
+/// `ấm` in Vietnamese, `A` in English.
 pub fn heading_letter(s: &str, lang: Option<&str>) -> Option<String> {
     let low = lower_composed(s.trim_start());
-    let (e, _) = next_elem(&low, tailoring(lang))?;
+    let (e, _) = next_elem(&low, tailoring(lang), language(lang) == "vi")?;
     let shown = if e.own || e.sec == 0 {
         e.text.to_string()
     } else {
-        DECOMP.iter().find(|(x, _, _)| e.text.starts_with(*x)).map(|(_, b, _)| b.chars().take(1).collect()).unwrap_or_else(|| e.text.to_string())
+        DECOMP
+            .iter()
+            .find(|(x, _, _)| e.text.starts_with(*x))
+            .map(|(_, b, _)| b.chars().take(1).collect())
+            .or_else(|| e.text.chars().next().and_then(vi_vowel).map(|(after, ..)| after.to_string()))
+            .unwrap_or_else(|| e.text.to_string())
     };
     let mut cs = shown.chars();
     let first = cs.next()?;
@@ -522,8 +628,56 @@ mod tests {
         assert_eq!(collate("ABC", "abc", None), Ordering::Equal);
     }
 
+    #[test]
+    fn vietnamese_alphabet_and_tones() {
+        // #4317: Vietnamese letters sorted after `z`.
+        assert_eq!(sorted(&["An", "Dung", "Khoa", "Kim", "Ăn", "Đang", "Ẩn"], "vi-VN"), ["An", "Ăn", "Ẩn", "Dung", "Đang", "Khoa", "Kim"]);
+        let alphabet = [
+            "a", "ă", "â", "b", "c", "d", "đ", "e", "ê", "g", "h", "i", "k", "l", "m", "n", "o", "ô", "ơ", "p", "q", "r", "s", "t", "u", "ư", "v",
+            "x", "y",
+        ];
+        let mut shuffled = alphabet;
+        shuffled.reverse();
+        assert_eq!(sorted(&shuffled, "vi"), alphabet);
+        // Tones are accents, in dictionary order: none, grave, hook, tilde, acute, dot below.
+        assert_eq!(sorted(&["mạ", "má", "mã", "mả", "mà", "ma"], "vi"), ["ma", "mà", "mả", "mã", "má", "mạ"]);
+        assert_eq!(sorted(&["mát", "màn", "mạ"], "vi"), ["mạ", "màn", "mát"]);
+        assert_eq!(sorted(&["ba", "ấm", "âm", "ăn", "an"], "vi"), ["an", "ăn", "âm", "ấm", "ba"]);
+        assert_eq!(sorted(&["ước", "ung", "ở", "ôm", "ổn", "ong"], "vi"), ["ong", "ôm", "ổn", "ở", "ung", "ước"]);
+        assert_eq!(collate("ĐẶNG", "đặng", Some("vi")), Ordering::Equal);
+    }
+
+    #[test]
+    fn vietnamese_decomposed_letters_match_their_composed_forms() {
+        // A toneless letter + combining tone (Windows "Unicode tổ hợp" input), and full NFD, where
+        // a dot below comes before the circumflex and after the horn.
+        for (composed, decomposed) in [
+            ("ấm", "â\u{301}m"),
+            ("ấm", "a\u{302}\u{301}m"),
+            ("Ậu", "A\u{323}\u{302}u"),
+            ("ợ", "o\u{31b}\u{323}"),
+            ("ửa", "u\u{31b}\u{309}a"),
+            ("mả", "ma\u{309}"),
+        ] {
+            assert_eq!(collate(composed, decomposed, Some("vi-VN")), Ordering::Equal, "{composed}");
+            assert_eq!(heading_letter(composed, Some("vi")), heading_letter(decomposed, Some("vi")), "{composed}");
+        }
+    }
+
+    #[test]
+    fn other_languages_sort_vietnamese_vowels_with_their_latin_letter() {
+        assert_eq!(sorted(&["zebra", "Ẩn", "bánh", "Ước", "vợ"], "en-US"), ["Ẩn", "bánh", "Ước", "vợ", "zebra"]);
+        assert_eq!(sorted(&["mo", "mợ", "mỏ", "mơ"], "en"), ["mo", "mỏ", "mơ", "mợ"]);
+        assert_eq!(heading_letter("Ẩn", Some("en")).as_deref(), Some("A"));
+        assert_eq!(heading_letter("ước", None).as_deref(), Some("U"));
+    }
+
     #[test]
     fn heading_letters() {
+        assert_eq!(heading_letter("ấm", Some("vi")).as_deref(), Some("Â"));
+        assert_eq!(heading_letter("Ánh", Some("vi")).as_deref(), Some("A"));
+        assert_eq!(heading_letter("đường", Some("vi")).as_deref(), Some("Đ"));
+        assert_eq!(heading_letter("ợ", Some("vi")).as_deref(), Some("Ơ"));
         assert_eq!(heading_letter("Łódź", Some("pl-PL")).as_deref(), Some("Ł"));
         assert_eq!(heading_letter("Łódź", Some("en-US")).as_deref(), Some("L"));
         assert_eq!(heading_letter("chata", Some("cs")).as_deref(), Some("Ch"));
@@ -534,8 +688,10 @@ mod tests {
 
     #[test]
     fn hostile_input_never_panics() {
-        for s in ["", "\u{301}", "a\u{301}\u{301}\u{301}", "dzs", "\u{10FFFF}", "ẞ", "İstanbul"] {
-            for l in [None, Some("hu"), Some(""), Some("-"), Some("tr-TR")] {
+        for s in
+            ["", "\u{301}", "a\u{301}\u{301}\u{301}", "dzs", "\u{10FFFF}", "ẞ", "İstanbul", "\u{323}\u{302}", "ậ\u{301}\u{323}\u{309}", "đ\u{31b}"]
+        {
+            for l in [None, Some("hu"), Some(""), Some("-"), Some("tr-TR"), Some("vi")] {
                 let _ = sort_key(s, l);
                 let _ = heading_letter(s, l);
             }
diff --git a/docs/localization-parity.md b/docs/localization-parity.md
--- a/docs/localization-parity.md
+++ b/docs/localization-parity.md
@@ -1,6 +1,6 @@
 # Localization parity with Microsoft Word
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Vietnamese sorting: `ă â đ ê ô ơ ư` as letters of their own and the five tones in dictionary order, #4317; previously minor: the egui 0.37 migration preserves the equation “Delete Around Caret in Equation” translation in all 27 catalogs through the shared catalog pin; previously minor: Spanish proofing: es-ES, es-MX, es-419 and es-UY from the installed Hunspell dictionaries, Uruguayan `vos` forms, opening `¿`/`¡` checks, #3360, #3553; previously minor: the interface catalogs moved to storytold/craft-i18n and are looked up through the `craft-i18n` crate, embedded at build time from the commit in `craft-i18n.rev`; previously trivial: interface-language tests: a missing translation never fails a test, `cargo xtask i18n-report` in `cargo xtask ci` prints the strings each language lacks, and lookup and template tests use inline fixture catalogs or the pseudo-language; previously trivial: interface audit: missing catalog entries are reported per language and don't fail the audit; previously minor: disabled-control reasons in ribbon hints translated in every catalog, #2678; previously trivial: Slovenian interface catalog: every key of every other catalog, 2,614 entries; previously minor: Romanian interface catalog; previously trivial: the File › Home greeting is translated in every catalog; previously trivial: Japanese row: ruby (Phonetic Guide) is supported and HTML export/import keeps it, #1681; previously trivial: Russian sorting keeps ё with е and recognises decomposed ё/й; previously minor: English dialects: en-GB, en-CA, en-AU and the other British-spelling Englishes proof their own spellings and flag the other dialect's, #1281, #1045; previously minor: how to install the Hunspell dictionaries on Windows, macOS and Linux: `docs/spelling-dictionaries.md`; previously minor: Read Aloud speaks each sentence in its own language, #1843; previously minor: Indonesian proofing: id-ID spelling from the installed Hunspell dictionary, dictionary-driven suggestions, generic checks with Indonesian messages, #498; previously trivial: print-area strings translated in every catalog; previously trivial: Italian: the interface audit checks the Italian catalog, `Privacy Settings` wording; previously trivial: Italian grammar: `po'`, `qual è`, `È`, `un`/`un'` rules; more elisions and truncations in spelling; previously trivial: Czech interface catalog completed, 2,447 entries; previously trivial: interface audit runs in CI again: size labels translated, 20 strings in every catalog, Read Aloud error race fixed, #1128; previously trivial: Review › Language reaches every language again — Set Proofing Language… and Set As Default, #860; previously minor: Italian proofing: it-IT spelling from the installed Hunspell dictionary, accent-first suggestions, conservative grammar with Italian messages; previously minor: Portuguese proofing; previously trivial: Turkish interface catalog completed, 2,325 entries, #774; previously minor: Hungarian interface added; previously trivial: sorting and table numbers follow the text's language, #836, #837; previously minor: Polish interface; Polish proofing — spelling, grammar, hyphenation — and a proofing language per run; previously minor: Turkish interface added; previously major: first version; catalogs measured from `crates/ui-egui/src/i18n`, Word's languages from its bundle listing) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
 
 Interface languages, script support and proofing per language. How translation works (adding a
 language, the embedded catalogs): `crates/ui-egui/src/i18n/mod.rs`. The catalogs, their format and
@@ -165,7 +165,7 @@ Authorities. Letters with diacritics sort with their base letter (`é` with `e`)
 language makes them letters of their own: Polish `ą ć ę ł ń ó ś ź ż`, Czech/Slovak `č ch ř š ž`
 (and Slovak `ä ô`), Hungarian `cs dz dzs gy ly ny sz ty zs ö ü`, Swedish/Finnish `å ä ö` and
 Danish/Norwegian `æ ø å` after `z`, Spanish `ñ`, Turkish, Croatian/Bosnian/Serbian Latin,
-Romanian, Estonian, Latvian, Lithuanian, Slovenian and Icelandic (`crates/doc/src/collate.rs`,
+Romanian, Estonian, Latvian, Lithuanian, Slovenian, Icelandic and Vietnamese (`crates/doc/src/collate.rs`,
 a hand-written per-language alphabet table; full CLDR collation is not used). Table sort and
 table formulas read numbers with the language's decimal and group separators (`1 250,50` in
 Polish, `1.250,50` in German, `1,250.50` in English) and write formula results with its decimal
@@ -176,6 +176,15 @@ formatting. Interface lists (styles, fonts) still sort by code point.
 
 Russian (`ru`) sorting keeps ё with е (a secondary diaeresis difference), before ж. Composed and decomposed ё and й have the same keys and index headings; the stored text is unchanged. Tests cover Russian locale aliases, case, representative words and these two canonical pairs. This is a narrow Cyrillic correction, not full Unicode normalization or CLDR collation. Ordering reference: [Unicode CLDR Russian collation chart](https://unicode.org/cldr/charts/42/collation/ru.html).
 
+Vietnamese (`vi`) sorting follows its alphabet, `a ă â b c d đ e ê g h i k l m n o ô ơ p q r s t u ư v x y`
+(#4317): `Ăn` and `Ẩn` follow `An`, `Đang` follows `Dung`. Tones are accents, compared only after
+the letters, in dictionary order: none, grave (huyền), hook above (hỏi), tilde (ngã), acute (sắc),
+dot below (nặng), so `ma < mà < mả < mã < má < mạ < mai`. Index headings drop the tone (`ấm` files
+under `Â`). Composed and decomposed text (a toneless letter + combining tone, as Windows' "Unicode
+tổ hợp" input writes, or full NFD) have the same keys. Other languages sort Vietnamese vowels
+with their Latin letter (`Ẩn` under `A` before `B`) instead of after `z`. Ordering reference:
+[Unicode CLDR Vietnamese collation chart](https://unicode.org/cldr/charts/42/collation/vi.html).
+
 ## Region conventions (#838)
 
 As in Word, a new blank document takes its paper from the system **region**, not the interface
@@ -223,6 +232,7 @@ WordCraft keeps 2,54 cm).
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-26 | minor | Vietnamese sorting (#4317): `ă â đ ê ô ơ ư` are letters of their own, tones compare in dictionary order after the letters, index headings drop the tone, decomposed text sorts like composed; other languages file Vietnamese vowels with their Latin letter instead of after `z` |
 | 2026-10-24 | minor | Preserved “Delete Around Caret in Equation” in all 27 shared WordCraft catalogs; the native/release pin and Flatpak source advance together. |
 | 2026-10-23 | minor | Interface catalogs moved to storytold/craft-i18n (`locales/<locale>/wordcraft.tsv`), looked up through craft-libs' `craft-i18n` crate and embedded from the pinned commit (`craft-i18n.rev`); strings other Crafting Apps already translate in the shared catalog now show translated; the "Open" paragraph-spacing preset no longer shows the file verb |
 | 2026-10-23 | trivial | Lookup, context, template, style-name and command-search tests use inline fixture catalogs or the pseudo-language; tests that asserted one language's catalog wording removed |
