diff --git a/crates/ui-egui/src/grid.rs b/crates/ui-egui/src/grid.rs
--- a/crates/ui-egui/src/grid.rs
+++ b/crates/ui-egui/src/grid.rs
@@ -183,6 +183,13 @@ pub struct GridState {
     pub cells_rect: Option<Rect>,
     /// Column and row of the clicked header cell, and where to open the menu.
     pub filter_menu: Option<(u32, u32, Pos2)>,
+    /// The grid's input method context: its generation, and the pass that last allowed it (a new
+    /// generation starts when the grid takes the keyboard back).
+    pub ime_generation: u64,
+    pub ime_pass: Option<u64>,
+    /// The keys that started an edit by typing over the cell, for the in-cell editor once it has
+    /// the keyboard (see `keyboard::start_typing`).
+    pub typed: Vec<egui::Event>,
     pub context_menu: Option<Pos2>,
     /// The frame a right-click opened `context_menu` in: that click's own press doesn't close it.
     pub context_menu_frame: u64,
diff --git a/crates/ui-egui/src/grid/editor.rs b/crates/ui-egui/src/grid/editor.rs
--- a/crates/ui-egui/src/grid/editor.rs
+++ b/crates/ui-egui/src/grid/editor.rs
@@ -79,6 +79,14 @@ pub(super) fn in_cell_editor(app: &mut SheetApp, ui: &mut egui::Ui, geo: &Geo, s
     // The theme's caret is meant for the UI chrome and vanishes on some fills (and, in the dark
     // theme, on the white paper): pick black or white against the fill, like Excel.
     child.visuals_mut().text_cursor.stroke.color = ink_on(fill);
+    // The keys that started the edit, once the editor has the keyboard (`keyboard::start_typing`).
+    if !app.grid.typed.is_empty() && ui.ctx().memory(|m| m.has_focus(id)) {
+        let typed = std::mem::take(&mut app.grid.typed);
+        ui.ctx().input_mut(|i| {
+            let later = std::mem::replace(&mut i.events, typed);
+            i.events.extend(later);
+        });
+    }
     crate::formula_bar::editor_widget(app, &mut child, id, font, wrap, erect.width() - 4.0, erect.height() - 2.0);
     ui.ctx().data_mut(|d| d.insert_temp(egui::Id::unique("gridcraft.cell_editor.rect"), erect));
 }
diff --git a/crates/ui-egui/src/grid/keyboard.rs b/crates/ui-egui/src/grid/keyboard.rs
--- a/crates/ui-egui/src/grid/keyboard.rs
+++ b/crates/ui-egui/src/grid/keyboard.rs
@@ -32,6 +32,8 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
     ctx.memory_mut(|m| {
         m.set_focus_lock_filter(resp.id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })
     });
+    app.grid.typed.clear();
+    allow_ime(app, ctx, resp, geo, sh);
     let events = ctx.input(|i| i.events.clone());
     // A shortcut key that also types a character (Shift+Space sends Text(" ")) must not start an edit.
     let shortcut_pressed = events.iter().any(|e| {
@@ -42,7 +44,8 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
     // Alt shortcuts type their character too on Windows and Linux (Alt+= sends Text("=")): once
     // a shortcut ran, that text must not start an edit over what it just did.
     let mut shortcut_ran = false;
-    for ev in events {
+    let mut events = events.into_iter();
+    while let Some(ev) = events.next() {
         match ev {
             egui::Event::Copy => {
                 app.copy_to_clipboard(ctx, "edit.copy");
@@ -59,12 +62,11 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
             // PRIMARY middle-click paste belongs to egui's TextEdit; the grid has no caret to
             // insert text at, so a middle click on a cell is deliberately not a paste.
             egui::Event::MiddleClickPaste { .. } => {}
-            egui::Event::Text(_) if shortcut_pressed || shortcut_ran => {}
-            egui::Event::Text(text) if text.chars().all(|c| !c.is_control()) && !text.is_empty() => {
-                app.begin_edit(Some(text), false);
-                if let Some(ed) = app.editor.as_mut() {
-                    ed.caret = ed.text.chars().count();
-                }
+            egui::Event::Text(_) | egui::Event::Ime(_) if shortcut_pressed || shortcut_ran => {}
+            egui::Event::Text(ref text) | egui::Event::Ime(egui::ImeEvent::Commit(ref text) | egui::ImeEvent::Preedit { ref text, .. })
+                if text.chars().all(|c| !c.is_control()) && !text.is_empty() =>
+            {
+                start_typing(app, ctx, std::iter::once(ev).chain(events).collect());
                 return;
             }
             egui::Event::Key { key, physical_key, pressed: true, modifiers: m, .. } => {
@@ -153,3 +155,50 @@ pub(super) fn keyboard(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Res
         }
     }
 }
+
+/// Lets the input method compose while the grid has the keyboard. Otherwise the first key of an
+/// entry arrives as a lone jamo (or kana) that the next keys can no longer combine with, and the
+/// in-cell editor, which allows the input method, only gets the keys after it. Not on a touch
+/// screen, where an input method context brings up the on-screen keyboard.
+fn allow_ime(app: &mut SheetApp, ctx: &egui::Context, resp: &egui::Response, geo: &Geo, sh: &Sheet) {
+    if ctx.input(|i| i.has_touch_screen()) {
+        return;
+    }
+    let Some(active) = app.session.active().map(|d| d.selection.active) else { return };
+    let cell = match sh.merge_at(active) {
+        Some(m) => geo.range_rect(sh, m),
+        None => geo.cell_rect(sh, active),
+    };
+    let caret = Rect::from_min_size(cell.min + vec2(2.0, 0.0), vec2(1.0, cell.height()));
+    // A new context each time the grid takes the keyboard back, as a text field does on focus.
+    let pass = ctx.cumulative_pass_nr();
+    if app.grid.ime_pass.is_none_or(|last| last.saturating_add(1) < pass) {
+        app.grid.ime_generation = app.grid.ime_generation.wrapping_add(1).max(1);
+    }
+    app.grid.ime_pass = Some(pass);
+    let to_global = ctx.layer_transform_to_global(resp.layer_id).unwrap_or_default();
+    let ime = egui::output::IMEOutput::new(
+        resp.id,
+        app.grid.ime_generation,
+        egui::IMEPurpose::Normal,
+        to_global * geo.mirror_rect(cell),
+        to_global * geo.mirror_rect(caret),
+    );
+    ctx.output_mut(|o| o.ime = Some(ime));
+}
+
+/// Starts editing the cell with the keys typed over it. The in-cell editor gets those keys itself,
+/// once it has the keyboard: egui interrupts the input method when the focus moves, which would
+/// cancel the composition just started (the next key starts a new one, so a Korean syllable comes
+/// apart into its jamo). So the keys are held back from this pass, which is discarded, and the
+/// repeat pass, where the editor has the focus and the input method context is its own, replays
+/// them: the interrupt was asked for the grid's context and doesn't reach the platform.
+fn start_typing(app: &mut SheetApp, ctx: &egui::Context, typed: Vec<egui::Event>) {
+    app.begin_edit(Some(String::new()), false);
+    if app.editor.is_none() {
+        return;
+    }
+    ctx.input_mut(|i| i.events.retain(|e| !typed.contains(e)));
+    app.grid.typed = typed;
+    ctx.request_discard("the in-cell editor takes the keys that started the edit");
+}
diff --git a/crates/ui-egui/tests/it/ime_typing.rs b/crates/ui-egui/tests/it/ime_typing.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/tests/it/ime_typing.rs
@@ -0,0 +1,103 @@
+//! An entry typed over a selected cell through an input method keeps what it composes whole:
+//! the grid lets the input method compose before an edit starts, and the composition carries
+//! into the in-cell editor without being cancelled. Korean 2-Set typing lost or split its first
+//! syllable. The events below have the shape macOS sends for Korean (a syllable is committed
+//! together with the start of the next one); the composed text is Vietnamese Telex, which the
+//! harness's bundled fonts can draw.
+use egui::{Event, ImeEvent, Key};
+use gridcraft_engine::Session;
+use gridcraft_engine::core::CellRef;
+
+use crate::common::{Harness, app, builder, harness_with};
+
+fn harness() -> Harness {
+    let mut session = Session::new();
+    session.new_workbook();
+    harness_with(builder().with_size(egui::vec2(1200.0, 800.0)), app(session), 4)
+}
+
+fn preedit(text: &str) -> Event {
+    Event::Ime(ImeEvent::Preedit { text: text.into(), active_range_chars: None })
+}
+
+fn commit(text: &str) -> Event {
+    Event::Ime(ImeEvent::Commit(text.into()))
+}
+
+fn a1(h: &Harness) -> Option<String> {
+    let d = h.state().session.active().unwrap();
+    d.wb.active().unwrap().cell(CellRef::new(0, 0)).map(|c| c.input_text())
+}
+
+fn draft(h: &Harness) -> Option<&str> {
+    h.state().editor.as_ref().map(|ed| ed.text.as_str())
+}
+
+/// One key per frame; the composition in progress must survive every one of them.
+fn type_keys(h: &mut Harness, keys: Vec<Vec<Event>>) {
+    for events in keys {
+        h.input_mut().events.extend(events);
+        h.run_steps(1);
+        let ime = h.output().platform_output.ime.as_ref();
+        assert!(ime.is_some(), "the input method stays allowed while the entry is typed");
+        assert!(!ime.is_some_and(|ime| ime.should_interrupt_composition), "the composition in progress is never cancelled");
+    }
+}
+
+#[test]
+fn the_selected_cell_lets_the_input_method_compose() {
+    let h = harness();
+    let ime = h.output().platform_output.ime.as_ref().expect("the grid allows the input method");
+    assert!(ime.rect.is_positive(), "the candidate window opens at the active cell");
+}
+
+#[test]
+fn a_composition_typed_over_a_cell_stays_whole() {
+    let mut h = harness();
+    // a a e e: â is committed when e starts the next letter.
+    type_keys(&mut h, vec![vec![preedit("a")], vec![preedit("â")], vec![preedit(""), commit("â"), preedit("e")], vec![preedit("ê")]]);
+    assert_eq!(draft(&h), Some("âê"), "the draft shows the letter being composed");
+    // Return ends the composition (the input method keeps that key); the next one enters the cell.
+    h.input_mut().events.extend([preedit(""), commit("ê")]);
+    h.run_steps(2);
+    h.key_press(Key::Enter);
+    h.run_steps(2);
+    assert_eq!(a1(&h).as_deref(), Some("âê"));
+}
+
+#[test]
+fn an_input_method_that_replaces_the_letter_just_typed_finds_it() {
+    let mut h = harness();
+    // macOS input methods may type the first letter plainly, then replace it with the composed one.
+    type_keys(
+        &mut h,
+        vec![vec![Event::Text("o".into())], vec![Event::Ime(ImeEvent::DeleteSurroundingBytes { before_bytes: 1, after_bytes: 0 }), commit("ô")]],
+    );
+    assert_eq!(draft(&h), Some("ô"));
+}
+
+#[test]
+fn typing_after_the_composition_goes_on_in_the_cell_editor() {
+    let mut h = harness();
+    type_keys(&mut h, vec![vec![preedit("o")], vec![preedit("ô")]]);
+    // Space ends the composition; the in-cell editor then has the keyboard for the rest.
+    h.input_mut().events.extend([preedit(""), commit("ô"), Event::Text(" ".into())]);
+    h.run_steps(2);
+    for c in ["1", "2"] {
+        h.input_mut().events.push(Event::Text(c.into()));
+        h.run_steps(2);
+    }
+    h.key_press(Key::Enter);
+    h.run_steps(2);
+    assert_eq!(a1(&h).as_deref(), Some("ô 12"));
+}
+
+#[test]
+fn deleting_the_whole_composition_leaves_an_empty_draft() {
+    let mut h = harness();
+    type_keys(&mut h, vec![vec![preedit("a")]]);
+    // Backspace inside the composition: the input method clears it.
+    h.input_mut().events.push(preedit(""));
+    h.run_steps(2);
+    assert_eq!(draft(&h), Some(""));
+}
diff --git a/crates/ui-egui/tests/it/main.rs b/crates/ui-egui/tests/it/main.rs
--- a/crates/ui-egui/tests/it/main.rs
+++ b/crates/ui-egui/tests/it/main.rs
@@ -49,6 +49,7 @@ mod header_autofit;
 mod header_column_drag;
 mod host_chrome;
 mod hyperlink_ctrl_click;
+mod ime_typing;
 mod in_cell_editor_size;
 mod in_cell_editor_wrap;
 mod insert_copied_cells;
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
 
-> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-23 · **Change:** minor (missing translations are listed by `cargo xtask i18n-report` and never fail a check) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Korean typed over a selected cell keeps its first syllable whole) · **Target:** Microsoft Excel (Microsoft 365), Excel for Mac 16.113.4
 
 Per-language status against Excel, which ships its UI in dozens of languages (Windows language
 packs cover 100+), with localized function names, argument separators, number and date formats,
@@ -54,7 +54,10 @@ right-to-left sheets and full complex-script support. Part of
   (no bidi), and the sheet tabs, horizontal scrollbar and printing/PDF are not mirrored yet.
   Text boxes and shapes keep their paragraphs' right-to-left direction (`a:pPr rtl`) through XLSX
   save, though GridCraft still draws that text left to right. IME input goes
-  through egui/winit and is unverified for Chinese, Japanese and Korean.
+  through egui/winit. Korean typed over a selected cell on macOS composes from the first key
+  (the grid allows the input method, and the in-cell editor takes the keys without cancelling
+  the composition); this is verified only with the separate storytold/winit#15 fix applied.
+  Chinese and Japanese, and other platforms, are unverified.
 - **Names in any script.** Defined names (Define Name, Create from Selection) are limited to
   255 characters, counted as characters rather than UTF-8 bytes, so a 255-letter Arabic name is
   accepted as in Excel. Sheet names already counted characters (31).
@@ -81,7 +84,7 @@ separators, number/date formats, machine translation with agent review, and scri
 | Japanese | ja | 268 (~12%) | no | CJK system-font fallback; the theme's East Asian typeface (`<a:ea>`) survives open + save (rendering does not use it yet); IME unverified; no furigana display | no | menus only | 8–12 h |
 | German | de | 266 (~12%) | no | Latin: yes | no | menus only | 4–8 h |
 | Italian | it | 971 (~44%) | dialogs, tooltips, pivot pane | Latin: yes | no | menus, File view, dialogs, pivot pane | 3–5 h |
-| Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME unverified | no | menus only | 6–10 h |
+| Korean | ko | 266 (~12%) | no | Hangul via the CJK fallback; IME typing over a cell verified on macOS only with the storytold/winit#15 fix applied | no | menus only | 6–10 h |
 | Vietnamese | vi | 0 (0%) | no | Latin with stacked diacritics: unverified | no | none | 6–10 h |
 | Turkish | tr | 382 (~17%) | no | Latin: yes | yes | menus only | 4–8 h |
 
@@ -110,6 +113,7 @@ language (none so far) and of localized function names.
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-27 | minor | Korean typed over a selected cell no longer splits its first syllable into jamo: the grid allows the input method while it has the keyboard, and the keys that start an edit are replayed to the in-cell editor in a repeat pass, so moving the focus doesn't cancel the composition (`crates/ui-egui/tests/it/ime_typing.rs`). Checked on macOS with Apple 2-Set Korean; a winit fix for a jamo lost after a commit is a separate storytold/winit PR |
 | 2026-10-23 | minor | Missing translations are reported, never a failure: `cargo xtask i18n-report` prints, per language, the strings other catalogs translate and it lacks (plus Spanish function help rows keyed by English the function catalog no longer has) and always passes; `cargo xtask ci` runs it first. Coverage and per-language parity tests and tests of one language's exact wording are gone; UI tests take the expected text from the catalog. Duplicate-key, placeholder, context-prefix and line-shape checks stay |
 | 2026-10-21 | minor | Romanian interface (`ro`, Română): 970 labels covering every key of the other catalogs (`crates/ui-egui/src/i18n/ro.rs`), `ro-*` system locales, the View-tab switcher and `app.language.set`; context keys show no prefix (File › Options test). English formulas and formats; AI-assisted draft pending native-speaker review |
 | 2026-10-20 | minor | Japanese: furigana (`rPh` phonetic runs and `<phoneticPr>` in `xl/sharedStrings.xml`) is no longer deleted by an open + save; it is kept on the workbook and written back (a `<phoneticPr>` without runs is kept too, while duplicate texts with different readings keep only the first, reported on open). Display of the readings is still missing (`crates/xlsx/src/tests/phonetic_tests.rs`) |
