diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1086,6 +1086,17 @@ dependencies = [
  "thiserror 2.0.21",
 ]
 
+[[package]]
+name = "craft-quit"
+version = "0.1.0"
+source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0"
+dependencies = [
+ "dispatch2",
+ "egui",
+ "objc2 0.6.5",
+ "objc2-app-kit 0.3.2",
+]
+
 [[package]]
 name = "craft-ui"
 version = "0.1.0"
@@ -1822,6 +1833,7 @@ name = "effectcraft-ui-egui"
 version = "0.17.0"
 dependencies = [
  "chrono",
+ "craft-quit",
  "craft-ui",
  "craft-update",
  "effectcraft-color",
diff --git a/Cargo.toml b/Cargo.toml
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -14,6 +14,7 @@ repository = "https://github.com/storytold/effectcraft"
 craft-pdf = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", version = "=0.1.0", default-features = false, features = ["read"] }
 craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
 craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
+craft-quit = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" }
 # internal
 effectcraft-time = { path = "crates/time" }
 effectcraft-geom = { path = "crates/geom" }
diff --git a/apps/effectcraft/src/main.rs b/apps/effectcraft/src/main.rs
--- a/apps/effectcraft/src/main.rs
+++ b/apps/effectcraft/src/main.rs
@@ -365,14 +365,11 @@ fn main() -> eframe::Result {
             }))
         }),
     );
-    match &result {
-        Ok(()) => log::info!("the window closed"),
-        Err(e) => {
-            if on_gl {
-                launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
-            }
-            startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
+    if let Err(e) = &result {
+        if on_gl {
+            launch_guard::opengl_failed(effectcraft_host::config_dir().as_deref());
         }
+        startup_failed(e, !on_gl && cfg!(not(target_os = "macos")));
     }
     result
 }
@@ -484,6 +481,9 @@ impl eframe::App for Desktop {
     }
 
     fn on_exit(&mut self) {
+        // On macOS the app quits through `-[NSApplication terminate:]`, and the process ends
+        // before `eframe::run_native` returns (#341).
+        log::info!("the window is closing");
         self.launch.exited();
         self.window.save(self.app.session.config.as_deref());
         self.app.on_exit();
diff --git a/crates/effects/src/migrate.rs b/crates/effects/src/migrate.rs
--- a/crates/effects/src/migrate.rs
+++ b/crates/effects/src/migrate.rs
@@ -344,6 +344,23 @@ pub fn upgrade_instance(spec: &EffectSpec, g: &mut PropGroup, ids: &mut Ids, lay
     *g != before
 }
 
+/// The spec id of the parameter that an instance of `spec` saved at `id` (`a/b/param`) is now:
+/// `id` itself when the spec still has it, the new id of a renamed parameter
+/// ([`PARAM_ID_ALIASES`]), or the twirl-down path of a top-level parameter that moved into a
+/// group, as [`upgrade_instance`] finds them. Returns `None` when no parameter matches.
+pub fn current_param_id(spec: &EffectSpec, id: &str) -> Option<&'static str> {
+    if let Some(ps) = spec.params.iter().find(|ps| ps.id == id) {
+        return Some(ps.id);
+    }
+    if let Some((_, _, new)) = PARAM_ID_ALIASES.iter().find(|(e, old, _)| *e == spec.id && *old == id) {
+        return Some(new);
+    }
+    if id.contains('/') {
+        return None;
+    }
+    spec.params.iter().find(|ps| ps.id.rsplit('/').next() == Some(id)).map(|ps| ps.id)
+}
+
 /// Bring an effect instance saved before project schema 2 into effect space (#227).
 ///
 /// Layers without a source rectangle (shape, text) have comp-sized effect bounds centred on
@@ -559,6 +576,21 @@ mod tests {
         assert_eq!(prop_at(&mut g, "stabilization/smoothness").unwrap().value, Value::Scalar(12.0));
     }
 
+    /// A saved parameter id maps to the parameter `upgrade_instance` moves it to.
+    #[test]
+    fn current_param_id_follows_renames_and_twirl_down_moves() {
+        let stab = find(crate::warp_stab::ID).unwrap();
+        assert_eq!(current_param_id(stab, "stabilization/smoothness"), Some("stabilization/smoothness"));
+        assert_eq!(current_param_id(stab, "smoothness"), Some("stabilization/smoothness"));
+        let ctc = find("ec.color.changetocolor").unwrap();
+        assert_eq!(current_param_id(ctc, "tolerance"), Some("toleranceGroup/hue"));
+        // Radio Waves' old Velocity is the growth speed, not the new Velocity.
+        let waves = find("ec.generate.radiowaves").unwrap();
+        assert_eq!(current_param_id(waves, "velocity"), Some("waveMotion/expansion"));
+        assert_eq!(current_param_id(stab, "noSuchParam"), None);
+        assert_eq!(current_param_id(stab, "other/smoothness"), None);
+    }
+
     #[test]
     fn card_wipe_back_layer_popup_becomes_a_layer_parameter() {
         let spec = find("ec.transition.cardwipe").unwrap();
diff --git a/crates/engine/src/cmd/animation.rs b/crates/engine/src/cmd/animation.rs
--- a/crates/engine/src/cmd/animation.rs
+++ b/crates/engine/src/cmd/animation.rs
@@ -88,6 +88,21 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
     Ok(json!({"path": path, "properties": props.len(), "effects": effects.len()}))
 }
 
+/// Rewrite an effect property path in a preset (`effects/<effect id>[#n]/<param>`) to the current
+/// parameter id when it names a parameter that the effect has since renamed or moved into a
+/// twirl-down group. Any other path comes back unchanged.
+fn upgrade_effect_path(path: &str) -> String {
+    let Some((seg, tail)) = path.strip_prefix("effects/").and_then(|r| r.split_once('/')) else { return path.to_string() };
+    let effect = match seg.rsplit_once('#') {
+        Some((m, n)) if !m.is_empty() && n.parse::<usize>().is_ok() => m,
+        _ => seg,
+    };
+    match effectcraft_effects::find(effect).and_then(|spec| effectcraft_effects::migrate::current_param_id(spec, tail)) {
+        Some(id) if id != tail => format!("effects/{seg}/{id}"),
+        _ => path.to_string(),
+    }
+}
+
 fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
     let doc: Value = match (p.get("preset"), str_p(p, "path")) {
         (Some(v), _) => v.clone(),
@@ -117,7 +132,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
         .map(|a| {
             a.iter()
                 .filter_map(|v| {
-                    let path = v["path"].as_str()?.to_string();
+                    let path = upgrade_effect_path(v["path"].as_str()?);
                     let value: KV = serde_json::from_value(v["value"].clone()).ok()?;
                     let keys: Vec<Keyframe> = serde_json::from_value(v["keys"].clone()).unwrap_or_default();
                     let expr = serde_json::from_value(v["expression"].clone()).ok().flatten();
@@ -130,16 +145,29 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
         })
         .unwrap_or_default();
     let t = s.time();
+    // Effect bounds per layer size the point defaults of parameters added since the preset was saved.
+    let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
+    let sizes: BTreeMap<LayerId, [f64; 2]> =
+        ids.iter().filter_map(|lid| comp.layer(*lid).map(|l| (*lid, effectcraft_render::effect_bounds(&s.project, comp, l).0))).collect();
     let mut applied = 0;
     let mut skipped = vec![];
     s.edit("Apply Animation Preset", None, |proj, _| {
         let mut next = proj.next_id;
         for lid in &ids {
+            let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]);
             let l = layer_mut(proj, cid, *lid)?;
             let lt = l.layer_time(t);
             if let Some(fx) = l.props.sub_mut("effects") {
                 for g in &effects {
                     let mut g = g.clone();
+                    // Effects saved by an earlier version get the upgrade a project gets on open.
+                    let spec = match &g.kind {
+                        GroupKind::Effect { effect } => effectcraft_effects::find(effect),
+                        _ => None,
+                    };
+                    if let Some(spec) = spec {
+                        effectcraft_effects::migrate::upgrade_instance(spec, &mut g, &mut Ids(&mut next), size);
+                    }
                     g.reassign_uids(&mut next);
                     next += 1;
                     fx.children.push(Node::Group(g));
diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs
--- a/crates/engine/src/cmd/file_more.rs
+++ b/crates/engine/src/cmd/file_more.rs
@@ -1035,7 +1035,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Main...",
             ["File", "Interpret Footage"],
             Some("Cmd+Alt+G"),
-            "{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
+            "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
             has_footage_selection,
             interpret
         ),
@@ -1053,7 +1053,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Apply Interpretation",
             ["File", "Interpret Footage"],
             Some("Cmd+Alt+V"),
-            "{items?}",
+            "{items?|item?}",
             has_interpretation,
             apply_interpretation
         ),
@@ -1084,7 +1084,7 @@ pub fn specs() -> Vec<CommandSpec> {
             has_footage_selection,
             replace_with_solid
         ),
-        cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?}", has_footage_selection, reload),
+        cmd!("file.reloadFootage", "Reload Footage", ["File"], Some("Cmd+Alt+L"), "{items?|item?}", has_footage_selection, reload),
         cmd!("file.revealInFinder", "Reveal in Finder", ["File"], None, "{item?}", has_footage_selection, reveal_in_finder),
     ]
 }
diff --git a/crates/engine/src/cmd/proxy.rs b/crates/engine/src/cmd/proxy.rs
--- a/crates/engine/src/cmd/proxy.rs
+++ b/crates/engine/src/cmd/proxy.rs
@@ -229,7 +229,7 @@ pub fn specs() -> Vec<CommandSpec> {
             "Proxy...",
             ["File", "Interpret Footage"],
             None,
-            "{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}",
+            "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}",
             has_proxy,
             interpret_proxy
         ),
diff --git a/crates/engine/src/tests/effects.rs b/crates/engine/src/tests/effects.rs
--- a/crates/engine/src/tests/effects.rs
+++ b/crates/engine/src/tests/effects.rs
@@ -772,6 +772,37 @@ fn preset_round_trips_effect_properties_by_match_name() {
     }
 }
 
+/// An animation preset saved by an earlier version gets the upgrade a project gets on open: a
+/// Change to Color saved with its single Tolerance applies with the Tolerance group, that value
+/// as its Hue and the added parameters at their legacy defaults, and a lone property saved at the
+/// old id applies to the new one.
+#[test]
+fn applying_an_old_preset_upgrades_its_effects_and_property_paths() {
+    let (mut s, a, b) = comp_with_two_solids();
+    apply(&mut s, a, "Change to Color");
+    let mut old = layer(&s, a).effects().unwrap().groups().next().unwrap().clone();
+    old.children.retain(|c| c.match_id() != "toleranceGroup");
+    old.children.push(effectcraft_project::Property::new(0, "tolerance", "Tolerance", KV::Scalar(12.0)).into());
+    let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [], "effects": [old]});
+    let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [b]})).unwrap();
+    assert_eq!(r["applied"], 1, "{r}");
+    let l = layer(&s, b);
+    let g = l.effects().unwrap().groups().next().unwrap();
+    assert!(g.get("tolerance").is_none(), "the old Tolerance is still there");
+    let tol = g.sub("toleranceGroup").expect("no Tolerance group");
+    assert_eq!(tol.get("hue").unwrap().value, KV::Scalar(12.0));
+    assert_eq!(tol.get("lightness").unwrap().value, KV::Scalar(100.0));
+    assert_eq!(tol.get("saturation").unwrap().value, KV::Scalar(100.0));
+    let path = "effects/ec.color.changetocolor/tolerance";
+    let preset = json!({"effectcraftAnimationPreset": 1, "name": "old", "properties": [{"path": path, "value": KV::Scalar(30.0), "keys": []}], "effects": []});
+    let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [a]})).unwrap();
+    assert_eq!(r["applied"], 1, "{r}");
+    assert_eq!(r["skipped"], json!([]), "{r}");
+    let l = layer(&s, a);
+    let hue = l.effects().unwrap().groups().next().unwrap().sub("toleranceGroup").unwrap().get("hue").unwrap().value.clone();
+    assert_eq!(hue, KV::Scalar(30.0));
+}
+
 /// #2064: `prop.addKey` accepts the `effects/<match name>[#n]/<property>` path that
 /// `essential.list` reports.
 #[test]
diff --git a/crates/engine/src/tests/proxy.rs b/crates/engine/src/tests/proxy.rs
--- a/crates/engine/src/tests/proxy.rs
+++ b/crates/engine/src/tests/proxy.rs
@@ -14,7 +14,8 @@ use crate::Session;
 use crate::render::{FootageSource, RenderOpts};
 
 /// Files by name: `red16.png` (16×16 red), `blue8.png` (8×8 blue), `lines.mov` (interlaced:
-/// even rows red, odd rows blue; 4×4, 10 fps), `matte_black.png` / `matte_white.png` (a
+/// even rows red, odd rows blue; 4×4, 10 fps), `flash.mov` (10×10, 10 fps: red, then blue from
+/// the second frame), `matte_black.png` / `matte_white.png` (a
 /// semi-transparent green premultiplied against black / white, read as straight), `half.png`
 /// (white at 25 % alpha), `wide.png` (10×10 white).
 struct Synthetic;
@@ -52,9 +53,14 @@ fn size_of(path: &str) -> (u32, u32) {
 }
 
 impl FootageSource for Synthetic {
-    fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {
+    fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
         let (w, h) = size_of(&f.path);
         let mut img = Image::new(w, h);
+        // `flash.mov` is red for its first frame and blue from its second (0.1 s) on.
+        if f.path.ends_with("flash.mov") {
+            let c = if t.seconds() >= 0.1 - 1e-9 { [0.0, 0.0, 1.0, 1.0] } else { [1.0, 0.0, 0.0, 1.0] };
+            return Some(Arc::new(Image::filled(w, h, c)));
+        }
         for y in 0..h {
             for x in 0..w {
                 // Decoders hand out premultiplied pixels (straight file colour × alpha).
@@ -171,6 +177,118 @@ fn comp_proxy_and_create_proxy() {
     assert!(blue(px(&s, outer, 0.0, o, 8, 8)));
 }
 
+/// Outer (4×4, 20 fps) holding a layer of Inner (4×4, 10 fps, a red solid): (inner, outer).
+fn nested(s: &mut Session) -> (ItemId, ItemId) {
+    s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 2})).unwrap();
+    let inner = s.active_comp_id().unwrap();
+    s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap();
+    s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();
+    let outer = s.active_comp_id().unwrap();
+    s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
+    (inner, outer)
+}
+
+/// Interpret Footage ▸ Proxy's Invert Alpha and Interpret As Linear Light change what a
+/// composition proxy draws, as they do for the same file used as footage.
+#[test]
+fn comp_proxy_draws_with_its_interpretation() {
+    let mut s = session();
+    let (inner, outer) = nested(&mut s);
+    let o = RenderOpts::default();
+    // Invert Alpha: the 25 % proxy covers 75 %.
+    s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/half.png"})).unwrap();
+    s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight", "invertAlpha": true})).unwrap();
+    let p = px(&s, outer, 0.0, o, 1, 1);
+    assert!((p[3] - 0.75).abs() < 0.01, "{p:?}");
+    // Interpret As Linear Light: the proxy draws as the file does as footage with that setting.
+    let f = import(&mut s, "/m/matte_black.png");
+    s.execute("file.interpretFootage", json!({"item": f.0, "linearLight": true})).unwrap();
+    s.execute("comp.new", json!({"name": "Ref", "width": 4, "height": 4, "frameRate": 20, "duration": 2})).unwrap();
+    let r = s.active_comp_id().unwrap();
+    s.execute("layer.addItem", json!({"item": f.0})).unwrap();
+    let want = px(&s, r, 0.0, o, 1, 1);
+    assert!(want[1] / want[3] > 0.7, "linear light re-encodes the 0.5 green: {want:?}");
+    s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/matte_black.png"})).unwrap();
+    s.execute("file.interpretProxy", json!({"item": inner.0, "linearLight": true})).unwrap();
+    let p = px(&s, outer, 0.0, o, 1, 1);
+    assert!((0..4).all(|i| (p[i] - want[i]).abs() < 1e-3), "{p:?} != {want:?}");
+}
+
+/// Interpret Footage ▸ Proxy ▸ Separate Fields: a composition proxy shows one field per frame
+/// at twice the rate, as footage does.
+#[test]
+fn comp_proxy_separates_fields() {
+    let mut s = session();
+    let (inner, outer) = nested(&mut s);
+    let o = RenderOpts::default();
+    s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/lines.mov"})).unwrap();
+    assert!(red(px(&s, outer, 0.05, o, 1, 0)) && blue(px(&s, outer, 0.05, o, 1, 1)), "progressive");
+    s.execute("file.interpretProxy", json!({"item": inner.0, "fields": "upper"})).unwrap();
+    // At 0.05 s the lower field (odd rows, blue) is shown, with the even rows interpolated.
+    for y in 0..3 {
+        assert!(blue(px(&s, outer, 0.05, o, 1, y)), "lower field at row {y}");
+    }
+}
+
+/// Frame Blending on a precomp layer blends the frames of the composition's proxy.
+#[test]
+fn comp_proxy_frames_blend() {
+    let mut s = session();
+    let (inner, outer) = nested(&mut s);
+    let o = RenderOpts::default();
+    s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/flash.mov"})).unwrap();
+    assert!(red(px(&s, outer, 0.05, o, 1, 1)));
+    s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();
+    s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();
+    // Halfway between the red first frame and the blue second one.
+    let p = px(&s, outer, 0.05, o, 1, 1);
+    assert!((p[0] - 0.5).abs() < 0.05 && (p[2] - 0.5).abs() < 0.05 && p[3] > 0.99, "{p:?}");
+    assert!(blue(px(&s, outer, 0.1, o, 1, 1)));
+}
+
+#[test]
+fn comp_proxy_frame_blend_cache_tracks_alpha_interpretation() {
+    struct Interpreted(std::sync::atomic::AtomicUsize);
+    impl FootageSource for Interpreted {
+        fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
+            self.0.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
+            let pixel = match f.alpha {
+                AlphaMode::Straight => [0.4, 0.2, 0.1, 0.5],
+                AlphaMode::Premultiplied => [f.premul_color[0] * 0.5, 0.1, 0.0, 0.5],
+                AlphaMode::Ignore => [0.8, 0.4, 0.2, 1.0],
+            };
+            Some(Arc::new(Image::filled(4, 4, pixel)))
+        }
+    }
+    for mode in ["frameMix", "pixelMotion"] {
+        let mut s = session();
+        let footage = Arc::new(Interpreted(std::sync::atomic::AtomicUsize::new(0)));
+        s.footage = footage.clone();
+        let (inner, outer) = nested(&mut s);
+        s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/alpha.mov"})).unwrap();
+        s.execute("file.interpretProxy", json!({"item": inner.0, "alpha": "straight"})).unwrap();
+        s.execute("layer.frameBlending", json!({"mode": mode})).unwrap();
+        s.execute("comp.setSwitch", json!({"switch": "frameBlending", "value": true})).unwrap();
+        let opts = RenderOpts::default();
+        let first = px(&s, outer, 0.05, opts, 1, 1);
+        let calls = footage.0.load(std::sync::atomic::Ordering::Relaxed);
+        assert_eq!(px(&s, outer, 0.05, opts, 1, 1), first);
+        assert_eq!(footage.0.load(std::sync::atomic::Ordering::Relaxed), calls);
+        for params in [
+            json!({"item": inner.0, "alpha": "ignore"}),
+            json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [0.0, 0.0, 0.0]}),
+            json!({"item": inner.0, "alpha": "premultiplied", "premulColor": [1.0, 0.0, 0.0]}),
+        ] {
+            s.execute("file.interpretProxy", params.clone()).unwrap();
+            let mut fresh = s.renderer(opts);
+            fresh.cache = None;
+            let want = fresh.comp_frame(outer, Tick::from_seconds_f64(0.05)).get(1, 1);
+            let actual = px(&s, outer, 0.05, opts, 1, 1);
+            assert_eq!(actual, want, "{mode} after {params}");
+        }
+    }
+}
+
 #[test]
 fn interpret_proxy_settings_apply_to_the_proxy() {
     let mut s = session();
@@ -187,6 +305,38 @@ fn interpret_proxy_settings_apply_to_the_proxy() {
     assert_eq!(f.alpha, AlphaMode::Straight, "the main footage keeps its interpretation");
 }
 
+/// Agents and the control channel run commands through `execute_checked`, which refuses keys a
+/// params doc does not list. The Interpret Footage commands list every key they read.
+#[test]
+fn interpret_commands_accept_their_keys_when_checked() {
+    let mut s = session();
+    let mov = import(&mut s, "/m/lines.mov");
+    let b = import(&mut s, "/m/red16.png");
+    s.execute("file.setProxy", json!({"item": mov.0, "path": "/m/lines.mov"})).unwrap();
+    s.execute("file.setProxy", json!({"item": b.0, "path": "/m/matte_black.png"})).unwrap();
+    s.execute_checked("file.interpretProxy", json!({"item": mov.0, "preserveRgb": true, "overrideStart": true, "startTimecode": 25})).unwrap();
+    let px = s.project.item(mov).unwrap().proxy.clone().unwrap().footage;
+    assert!(px.preserve_rgb);
+    assert_eq!(px.start_timecode, Some(25));
+    s.execute_checked("file.interpretProxy", json!({"item": mov.0, "overrideStart": false})).unwrap();
+    assert_eq!(s.project.item(mov).unwrap().proxy.as_ref().unwrap().footage.start_timecode, None);
+    s.execute_checked("file.interpretProxy", json!({"item": b.0, "guessAlpha": true})).unwrap();
+    assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.alpha, AlphaMode::Premultiplied);
+    s.execute_checked("file.interpretProxy", json!({"item": b.0, "premulColor": [1.0, 0.0, 0.0]})).unwrap();
+    assert_eq!(s.project.item(b).unwrap().proxy.as_ref().unwrap().footage.premul_color, [1.0, 0.0, 0.0]);
+    // `item` names one footage item, as `items` names several.
+    s.execute_checked("file.interpretFootage", json!({"item": b.0, "invertAlpha": true, "premulColor": [0.0, 1.0, 0.0]})).unwrap();
+    s.execute_checked("file.rememberInterpretation", json!({"item": b.0})).unwrap();
+    s.execute_checked("file.applyInterpretation", json!({"item": mov.0})).unwrap();
+    let f = match &s.project.item(mov).unwrap().kind {
+        ItemKind::Footage(f) => f.clone(),
+        _ => panic!("not footage"),
+    };
+    assert!(f.invert_alpha);
+    assert_eq!(f.premul_color, [0.0, 1.0, 0.0]);
+    assert_eq!(s.execute_checked("file.reloadFootage", json!({"item": mov.0})).unwrap()["reloaded"], 1);
+}
+
 /// Separate Fields: each field becomes a frame at twice the frame rate, the other field's lines
 /// interpolated; the dominant field comes first.
 #[test]
diff --git a/crates/gpu/src/shaders/advanced3d.wgsl b/crates/gpu/src/shaders/advanced3d.wgsl
--- a/crates/gpu/src/shaders/advanced3d.wgsl
+++ b/crates/gpu/src/shaders/advanced3d.wgsl
@@ -194,6 +194,25 @@ fn tap_hash(x: i32, y: i32) -> u32 {
     return h;
 }
 
+fn tap_jitter_at(x: i32, y: i32) -> vec2<f32> {
+    let h = tap_hash(x, y);
+    return vec2<f32>(f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5, f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5);
+}
+
+// shade::tap_jitter: the hash at the four corners of the receiver's 1/8-texel lattice
+// cell (cx, cy), blended bilinearly by the receiver's position w in the cell.
+fn tap_jitter(cx: i32, cy: i32, w: vec2<f32>, i: i32, j: i32) -> vec2<f32> {
+    let x = cx + i * 7919;
+    let y = cy + j * 7919;
+    let c00 = tap_jitter_at(x, y);
+    let c10 = tap_jitter_at(x + 1, y);
+    let c01 = tap_jitter_at(x, y + 1);
+    let c11 = tap_jitter_at(x + 1, y + 1);
+    let a = c00 + (c10 - c00) * w.x;
+    let b = c01 + (c11 - c01) * w.x;
+    return a + (b - a) * w.y;
+}
+
 const PCF_MAX_TAPS: i32 = 10;
 const PLANE_SLOPE_MAX: f32 = 64.0;
 
@@ -244,7 +263,8 @@ fn shadow_factor(m: u32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 {
     // shadows off their casters), each texel compared against the receiver plane's
     // depth at its centre so wide kernels never self-shadow a lit surface, an N×N grid
     // jittered by an integer hash (bit-identical across backends) of the sub-texel
-    // cell and tap index, and bilinear compares of the four nearest texel centres.
+    // lattice and tap index, blended across the receiver's lattice cell, and bilinear
+    // compares of the four nearest texel centers.
     // extra.y is the wanted penumbra in output pixels; dividing by the receiver's
     // texel footprint keeps it in output pixels at any map resolution, and the reach
     // is at least two texels so hard edges ramp over texel steps instead of showing them.
@@ -268,16 +288,17 @@ fn shadow_factor(m: u32, p: vec3<f32>, ng: vec3<f32>, n_l: f32) -> f32 {
     let n = clamp(i32(ceil(reach)) + 1, 5, PCF_MAX_TAPS);
     let step = reach / f32(n - 1);
     let half = f32(n - 1) * 0.5;
-    let cx = i32(floor(u * 8.0));
-    let cy = i32(floor(v * 8.0));
+    let gu = u * 8.0;
+    let gv = v * 8.0;
+    let cx = i32(floor(gu));
+    let cy = i32(floor(gv));
+    let wc = vec2<f32>(gu - floor(gu), gv - floor(gv));
     var lit = 0.0;
     for (var j = 0; j < n; j = j + 1) {
         for (var i = 0; i < n; i = i + 1) {
-            let h = tap_hash(cx + i * 7919, cy + j * 7919);
-            let jx = f32(h & 0xFFFFu) * (1.0 / 65536.0) - 0.5;
-            let jy = f32((h >> 16u) & 0xFFFFu) * (1.0 / 65536.0) - 0.5;
-            let su = u + ((f32(i) - half) + jx) * step;
-            let sv = v + ((f32(j) - half) + jy) * step;
+            let jit = tap_jitter(cx, cy, wc, i, j);
+            let su = u + ((f32(i) - half) + jit.x) * step;
+            let sv = v + ((f32(j) - half) + jit.y) * step;
             // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the
             // four is compared against the receiver plane at that centre.
             let tu = su - 0.5;
diff --git a/crates/render/src/disk_cache.rs b/crates/render/src/disk_cache.rs
--- a/crates/render/src/disk_cache.rs
+++ b/crates/render/src/disk_cache.rs
@@ -772,7 +772,7 @@ fn decode_layer(p: &[u8]) -> Option<Buf> {
 // ---------------------------------------------------------------- content keys
 
 /// Bump when rendering changes in a way that makes old disk entries wrong.
-const RENDER_VERSION: &str = "effectcraft-render-2";
+const RENDER_VERSION: &str = "effectcraft-render-3";
 
 fn hash_debug(h: &mut Hash128, v: &impl std::fmt::Debug) {
     h.write(format!("{v:?}").as_bytes());
@@ -841,9 +841,18 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf
 
 /// [`comp_content_key`] with [`VirtualFileId`] for paths `std::fs::metadata` cannot describe.
 pub fn comp_content_key_with(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId, virtual_id: Option<VirtualFileId<'_>>) -> u128 {
+    comp_content_key_for_renderer(project, comp, virtual_id, RENDER_VERSION)
+}
+
+fn comp_content_key_for_renderer(
+    project: &effectcraft_project::Project,
+    comp: effectcraft_project::ItemId,
+    virtual_id: Option<VirtualFileId<'_>>,
+    renderer: &str,
+) -> u128 {
     use effectcraft_project::{ItemKind, LayerSource};
     let mut h = Hash128::default();
-    h.write(RENDER_VERSION.as_bytes());
+    h.write(renderer.as_bytes());
     hash_debug(&mut h, &project.settings);
     let mut seen = std::collections::BTreeSet::new();
     let mut stack = vec![comp];
@@ -1001,6 +1010,28 @@ mod tests {
         assert!(dc.get_frame(42).is_none());
     }
 
+    #[test]
+    fn previous_renderer_frames_are_not_reused() {
+        use effectcraft_project::{Comp, ItemKind, Project};
+        use effectcraft_time::{FrameRate, Tick};
+
+        let mut project = Project::default();
+        let comp = Comp::new(2, 2, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
+        let id = project.add_item("Cached composition", effectcraft_color::Label::None, None, ItemKind::Comp(comp.into()));
+        let key = frame_key(comp_content_key(&project, id), 0, 1000, 0);
+        let previous_key = frame_key(comp_content_key_for_renderer(&project, id, None, "effectcraft-render-2"), 0, 1000, 0);
+        let dir = tmpdir("previous-renderer");
+        {
+            let cache = DiskCache::open(&dir, 1 << 30).unwrap();
+            cache.put_frame_now(previous_key, 2, 2, &[9; 16]);
+        }
+        let cache = DiskCache::open(&dir, 1 << 30).unwrap();
+        assert_eq!(cache.get_frame(previous_key).unwrap().rgba, vec![9; 16]);
+        assert!(cache.get_frame(key).is_none());
+        cache.put_frame_now(key, 2, 2, &[17; 16]);
+        assert_eq!(cache.get_frame(key).unwrap().rgba, vec![17; 16]);
+    }
+
     #[test]
     fn persists_across_restarts_and_clears() {
         let dir = tmpdir("persist");
diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs
--- a/crates/render/src/pipeline.rs
+++ b/crates/render/src/pipeline.rs
@@ -595,8 +595,10 @@ impl<'a> Renderer<'a> {
                 if self.project.comp_contains(*item, ctx.comp_id) {
                     return None;
                 }
-                // A composition proxy (a rendered still or movie) stands in for the comp.
-                // Nothing before the nested comp starts or after it ends.
+                // A composition proxy (a rendered still or movie) stands in for the comp. It is
+                // decoded like footage: its Separate Fields, Invert Alpha and Interpret As Linear
+                // Light settings and the layer's Frame Blending apply. Nothing before the nested
+                // comp starts or after it ends.
                 let lt = ctx.nested_time(layer);
                 if !self.project.comp(*item).is_some_and(|nc| nc.covers(lt)) {
                     return None;
@@ -604,7 +606,7 @@ impl<'a> Renderer<'a> {
                 if let Some(pf) = self.proxy_for(*item)
                     && let Some(nc) = self.project.comp(*item)
                 {
-                    let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?;
+                    let img = self.footage_frame(ctx, layer, *item, pf, lt, None)?;
                     return Some(self.footage_buf(&img, pf, nc.width, nc.height));
                 }
                 // Essential Properties overrides render the nested comp with this instance's
@@ -738,6 +740,10 @@ impl<'a> Renderer<'a> {
                     use std::hash::{Hash, Hasher};
                     let mut h = std::collections::hash_map::DefaultHasher::new();
                     (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h);
+                    (f.alpha as u8).hash(&mut h);
+                    if f.alpha == effectcraft_project::AlphaMode::Premultiplied {
+                        f.premul_color.map(f32::to_bits).hash(&mut h);
+                    }
                     h.finish()
                 });
                 if let (Some(c), Some(k)) = (self.cache, key)
diff --git a/crates/render/src/three_d/adv/shade.rs b/crates/render/src/three_d/adv/shade.rs
--- a/crates/render/src/three_d/adv/shade.rs
+++ b/crates/render/src/three_d/adv/shade.rs
@@ -217,6 +217,25 @@ fn tap_hash(x: i32, y: i32) -> u32 {
     h
 }
 
+/// The jitter of tap (`i`, `j`), each axis in [−0.5, 0.5): [`tap_hash`] values at the four
+/// corners of the receiver's 1/8-texel lattice cell (`cx`, `cy`), blended bilinearly by the
+/// receiver's position in the cell (`wx`, `wy`). The jitter is continuous in the receiver's
+/// map position, so a position that differs between backends in its last bits moves the
+/// taps by an amount proportional to that difference and does not switch them to another
+/// cell's jitter.
+fn tap_jitter(cx: i32, cy: i32, wx: f32, wy: f32, i: i32, j: i32) -> (f32, f32) {
+    let corner = |dx: i32, dy: i32| -> (f32, f32) {
+        let h = tap_hash(cx.wrapping_add(dx).wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(dy).wrapping_add(j.wrapping_mul(7919)));
+        (((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5, (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5)
+    };
+    let (c00, c10, c01, c11) = (corner(0, 0), corner(1, 0), corner(0, 1), corner(1, 1));
+    let ax = c00.0 + (c10.0 - c00.0) * wx;
+    let bx = c01.0 + (c11.0 - c01.0) * wx;
+    let ay = c00.1 + (c10.1 - c00.1) * wx;
+    let by = c01.1 + (c11.1 - c01.1) * wx;
+    (ax + (bx - ax) * wy, ay + (by - ay) * wy)
+}
+
 /// Upper bound of the PCF grid's taps per axis. The grid grows with the reach so taps
 /// stay about one texel apart for moderate penumbrae; the cap bounds the per-fragment
 /// cost of very wide ones (bilinear taps still overlap and the jitter dithers the rest).
@@ -232,8 +251,9 @@ const PLANE_SLOPE_MAX: f32 = 64.0;
 ///
 /// PCF over an N×N tap grid (5 ≤ N ≤ `PCF_MAX_TAPS`) spread over a reach derived
 /// from `radius` (the wanted penumbra in output pixels) divided by the receiver's
-/// texel footprint, each tap jittered by an integer hash of the receiver's
-/// sub-texel cell and the tap index, and each tap comparing against the four
+/// texel footprint, each tap jittered by [`tap_jitter`] (an integer hash of the
+/// receiver's sub-texel lattice and the tap index, blended across the receiver's
+/// lattice cell), and each tap comparing against the four
 /// nearest texel centres with bilinear weights. The jitter detaches the tap grid
 /// from the texel axes (axis-aligned binary taps are what stair-step a straight
 /// shadow edge), and keying it below texel size keeps the dither finer than an
@@ -301,13 +321,13 @@ pub fn shadow_factor(m: &ShadowMap, texels: &[f32], p: [f32; 3], ng: [f32; 3], n
     let n = (reach.ceil() as i32 + 1).clamp(5, PCF_MAX_TAPS);
     let step = reach / (n - 1) as f32;
     let half = (n - 1) as f32 * 0.5;
-    let (cx, cy) = ((u * 8.0).floor() as i32, (v * 8.0).floor() as i32);
+    let (gu, gv) = (u * 8.0, v * 8.0);
+    let (cx, cy) = (gu.floor() as i32, gv.floor() as i32);
+    let (wx, wy) = (gu - gu.floor(), gv - gv.floor());
     let mut lit = 0.0;
     for j in 0..n {
         for i in 0..n {
-            let h = tap_hash(cx.wrapping_add(i.wrapping_mul(7919)), cy.wrapping_add(j.wrapping_mul(7919)));
-            let jx = ((h & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5;
-            let jy = (((h >> 16) & 0xFFFF) as f32) * (1.0 / 65536.0) - 0.5;
+            let (jx, jy) = tap_jitter(cx, cy, wx, wy, i, j);
             let (su, sv) = (u + ((i as f32 - half) + jx) * step, v + ((j as f32 - half) + jy) * step);
             // Texel (x, y) holds the depth at its centre (x + 0.5, y + 0.5); each of the
             // four is compared against the receiver plane at that centre.
diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs
--- a/crates/render/src/three_d/adv/tests.rs
+++ b/crates/render/src/three_d/adv/tests.rs
@@ -887,6 +887,26 @@ fn penumbra_dithers_along_a_straight_edge() {
     assert!(distinct(&within) >= 4, "coverage inside one texel takes {} distinct values", distinct(&within));
 }
 
+#[test]
+fn penumbra_jitter_is_continuous_across_its_lattice() {
+    // Receiver pairs 1/5000 texel either side of a line of the 1/8-texel jitter lattice,
+    // across u and across v, inside the penumbra. The GPU places a receiver within a few
+    // ulps of the CPU, so coverage that jumps at a lattice line differs between the two
+    // backends there; both sides of each line must agree within one 8-bit level.
+    let (m, texels) = wall_map(64, 1.0, 6.0);
+    let f = |p: [f32; 2]| shade::shadow_factor(&m, &texels, [p[0], p[1], 13.5], FACING, 1.0);
+    let mut worst = 0.0f32;
+    for k in 0..16 {
+        let line = k as f32 * 0.125;
+        for (a, b) in [([-16.0 + line - 2e-4, 0.3], [-16.0 + line + 2e-4, 0.3]), ([-16.3, line - 2e-4], [-16.3, line + 2e-4])] {
+            let (fa, fb) = (f(a), f(b));
+            assert!(fa > 0.05 && fa < 0.95, "{a:?} lies in the penumbra: {fa}");
+            worst = worst.max((fa - fb).abs());
+        }
+    }
+    assert!(worst < 1.0 / 255.0, "coverage jumps by {worst} across a jitter lattice line");
+}
+
 #[test]
 fn penumbra_width_is_independent_of_map_resolution() {
     // One wall (edge at world x = −16) in a 64-texel map at 1 texel per world unit and
diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml
--- a/crates/ui-egui/Cargo.toml
+++ b/crates/ui-egui/Cargo.toml
@@ -11,6 +11,7 @@ workspace = true
 
 [dependencies]
 craft-ui = { workspace = true }
+craft-quit = { workspace = true, features = ["egui"] }
 effectcraft-time = { workspace = true }
 effectcraft-geom = { workspace = true }
 effectcraft-color = { workspace = true }
diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -131,6 +131,9 @@ impl EffectcraftApp {
         self.apply_gpu_failure(ctx);
         if self.gpu_failure_applied == Some(true) {
             self.close_after_device_loss(ctx);
+            // On macOS the app quits through `-[NSApplication terminate:]` instead of closing the
+            // window while AppKit's run loop is running (#341).
+            craft_quit::on_close_request(ctx);
             return;
         }
         // Focus comes from the fresh viewport copy; a full pass's `InputState::focused` is only a
@@ -198,7 +201,9 @@ impl EffectcraftApp {
             self.command_inbox = Some(rx);
         }
         // A close request (the window's button, Quit, `app.quit`) must be answered while hidden.
-        panels::unsaved::on_close_requested(self, ctx);
+        // On macOS a close that nothing held open quits through `-[NSApplication terminate:]`
+        // (#341).
+        panels::unsaved::close_or_quit(self, ctx);
         let title = panels::unsaved::window_title(self);
         if title != self.window_title {
             ctx.send_viewport_cmd(egui::ViewportCommand::Title(title.clone()));
diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs
--- a/crates/ui-egui/src/panels/text_panels.rs
+++ b/crates/ui-egui/src/panels/text_panels.rs
@@ -717,10 +717,11 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
             let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == selected, ""));
             resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), index == selected, &r.display));
             let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height()));
+            let name = drawn_font_name(ui.ctx(), r);
             ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text(
                 label_rect.left_center(),
                 Align2::LEFT_CENTER,
-                &r.display,
+                name,
                 Tokens::ui(12.0),
                 t.text,
             );
@@ -754,7 +755,7 @@ pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui:
             if resp.clicked() {
                 chosen = Some(r.family.clone());
             }
-            resp.on_hover_text(&r.display);
+            resp.on_hover_text(name);
         }
         chosen
     };
@@ -900,6 +901,13 @@ fn align_glyph(p: &egui::Painter, r: Rect, op: &str, distribute: bool, t: &Token
     }
 }
 
+/// The name a font menu row draws: the font's own-language name when the interface fonts have a
+/// glyph for each of its characters, else its English family name. A Simplified Chinese name
+/// such as 微软雅黑 can have characters that the Han face of an English interface lacks.
+fn drawn_font_name<'a>(ctx: &egui::Context, row: &'a effectcraft_engine::FontRow) -> &'a str {
+    if ctx.fonts_mut(|f| f.has_glyphs(&Tokens::ui(12.0), &row.display)) { &row.display } else { &row.family }
+}
+
 /// Where a font's "Sample" outlines (`lines`, baseline at y = 0) go in `room`: `(scale, origin)`,
 /// a point `p` landing at `origin + p * scale`. At their natural size on `baseline` when they fit;
 /// fonts with large or odd metrics are scaled down (never up) and moved inside, so no preview
@@ -958,4 +966,17 @@ mod tests {
         assert!(fit_preview(&[], room, baseline).is_none());
         assert!(fit_preview(&[vec![[f32::NAN, 1.0]]], room, baseline).is_none());
     }
+
+    /// A font menu row draws the font's own-language name when the interface fonts have its
+    /// glyphs, and the English family name when they do not.
+    #[test]
+    fn font_menu_rows_draw_a_name_the_interface_fonts_have() {
+        let ctx = egui::Context::default();
+        crate::theme::install(&ctx, &Tokens::for_kind(Default::default()), "en");
+        ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
+        let row = |display: &str| effectcraft_engine::FontRow { family: "Menu Name Test".into(), display: display.into(), recent: false, variable: false };
+        // U+F1234 is in a private use plane that no interface font maps.
+        assert_eq!(drawn_font_name(&ctx, &row("Menu \u{F1234}")), "Menu Name Test");
+        assert_eq!(drawn_font_name(&ctx, &row("Nom du menu")), "Nom du menu");
+    }
 }
diff --git a/crates/ui-egui/src/panels/unsaved.rs b/crates/ui-egui/src/panels/unsaved.rs
--- a/crates/ui-egui/src/panels/unsaved.rs
+++ b/crates/ui-egui/src/panels/unsaved.rs
@@ -58,6 +58,15 @@ pub fn on_close_requested(app: &mut EffectcraftApp, ctx: &egui::Context) {
     }
 }
 
+/// [`on_close_requested`], then on macOS a close that nothing held open quits through
+/// `-[NSApplication terminate:]` instead: closing the window while AppKit's run loop is running
+/// crashes on a Mac with a Touch Bar (#341). `on_exit` still runs. Returns whether that quit was
+/// scheduled; other platforms close the window as before.
+pub fn close_or_quit(app: &mut EffectcraftApp, ctx: &egui::Context) -> bool {
+    on_close_requested(app, ctx);
+    craft_quit::on_close_request(ctx)
+}
+
 /// The project's display name: its file name, or `Untitled Project.ecproj`.
 pub fn project_name(app: &EffectcraftApp) -> String {
     app.session
@@ -207,6 +216,23 @@ mod tests {
         out.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose))
     }
 
+    /// One frame of the dialogs with the window's close button pressed, through [`close_or_quit`]
+    /// as the app runs it; returns whether the close was canceled and whether the platform quit
+    /// was scheduled.
+    fn close_frame(app: &mut EffectcraftApp, ctx: &egui::Context) -> (bool, bool) {
+        let mut raw = egui::RawInput::default();
+        raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
+        let mut quits = false;
+        let mut out = ctx.run_ui(raw, |ui| {
+            app.auto.begin_frame();
+            quits = close_or_quit(app, ui.ctx());
+            crate::panels::dialogs::show(app, ui.ctx());
+        });
+        out.textures_delta.clear();
+        let canceled = out.viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose));
+        (canceled, quits)
+    }
+
     fn modified(app: &mut EffectcraftApp) {
         app.session.execute("comp.new", json!({"name": "Main", "width": 64, "height": 64})).unwrap();
         assert!(app.session.is_dirty());
@@ -340,4 +366,62 @@ mod tests {
         assert!(!app.session.is_dirty());
         assert!(!frame(&mut app, &ctx, true));
     }
+
+    /// Closing the window while AppKit's run loop is running crashes on a Mac with a Touch Bar
+    /// (#341). On macOS the close is canceled and the app quits through
+    /// `-[NSApplication terminate:]`. Other platforms close the window.
+    #[test]
+    fn closing_a_saved_project_quits_through_the_platform_on_macos() {
+        let (mut app, ctx) = app();
+        let macos = cfg!(target_os = "macos");
+        assert_eq!(close_frame(&mut app, &ctx), (macos, macos));
+        assert!(app.dialog.is_none(), "nothing to ask");
+    }
+
+    #[test]
+    fn closing_a_modified_project_asks_before_the_platform_quit() {
+        let (mut app, ctx) = app();
+        modified(&mut app);
+        assert_eq!(close_frame(&mut app, &ctx), (true, false), "the prompt holds the window and nothing quits");
+        assert_eq!(app.dialog, Some(Dialog::UnsavedChanges));
+        answer_with(&mut app, &ctx, Answer::Cancel);
+        assert!(app.session.is_dirty() && !app.dialog_state.unsaved.quitting);
+        assert_eq!(close_frame(&mut app, &ctx), (true, false), "asked again after Cancel");
+        answer_with(&mut app, &ctx, Answer::Discard);
+        let macos = cfg!(target_os = "macos");
+        assert_eq!(close_frame(&mut app, &ctx), (macos, macos), "Don't Save quits");
+    }
+
+    /// While the window is minimized or hidden, eframe runs `App::logic` without `App::ui` and
+    /// still handles a close: `logic` asks about a modified project.
+    #[test]
+    fn a_close_while_only_logic_runs_asks_about_a_modified_project() {
+        let (mut app, ctx) = app();
+        modified(&mut app);
+        let mut raw = egui::RawInput::default();
+        raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
+        let mut frame = eframe::Frame::_new_kittest();
+        let out = ctx.run_logic(&raw, |ctx| eframe::App::logic(&mut app, ctx, &mut frame));
+        let canceled = out.viewport_commands.get(&egui::ViewportId::ROOT).is_some_and(|c| c.contains(&egui::ViewportCommand::CancelClose));
+        assert!(canceled, "the close is canceled");
+        assert_eq!(app.dialog, Some(Dialog::UnsavedChanges));
+    }
+
+    /// A file dialog answered in the frame that closes the window runs its command before the
+    /// close decision, so a change it makes is asked about.
+    #[test]
+    fn a_dialog_answer_runs_before_the_close_decision() {
+        let (mut app, ctx) = app();
+        assert!(!app.session.is_dirty());
+        let answer = crate::DialogAnswer { command: "comp.new".into(), key: "name", params: json!({"width": 64, "height": 64}), paths: vec!["Main".into()] };
+        app.dialog_answers.0.send(answer).unwrap();
+        let mut raw = egui::RawInput::default();
+        raw.viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
+        let mut frame = eframe::Frame::_new_kittest();
+        let out = ctx.run_logic(&raw, |ctx| eframe::App::logic(&mut app, ctx, &mut frame));
+        let canceled = out.viewport_commands.get(&egui::ViewportId::ROOT).is_some_and(|c| c.contains(&egui::ViewportCommand::CancelClose));
+        assert!(app.session.is_dirty(), "the answer ran");
+        assert!(canceled, "the close is canceled");
+        assert_eq!(app.dialog, Some(Dialog::UnsavedChanges));
+    }
 }
diff --git a/crates/ui-egui/src/workspace_state/legacy.rs b/crates/ui-egui/src/workspace_state/legacy.rs
--- a/crates/ui-egui/src/workspace_state/legacy.rs
+++ b/crates/ui-egui/src/workspace_state/legacy.rs
@@ -25,6 +25,9 @@ use super::{Snapshot, VERSION};
 use crate::dock::{Layout, PanelKind, Workspace};
 use crate::state::UiState;
 
+#[cfg(test)]
+mod tests;
+
 #[derive(Deserialize)]
 enum OldSplitSize {
     Ratio(f32),
@@ -166,7 +169,7 @@ fn node(old: OldNode, depth: usize) -> Result<Node<PanelKind>, String> {
             first: Box::new(node(*a, depth.saturating_add(1))?),
             second: Box::new(node(*b, depth.saturating_add(1))?),
         },
-        OldNode::Stack { entries } => column(entries)?,
+        OldNode::Stack { entries } => column(entries, depth)?,
     })
 }
 
@@ -183,7 +186,7 @@ struct Frame {
 }
 
 /// A column of stacked frames as tab groups split top to bottom (see the module docs).
-fn column(entries: Vec<OldStackEntry>) -> Result<Node<PanelKind>, String> {
+fn column(entries: Vec<OldStackEntry>, depth: usize) -> Result<Node<PanelKind>, String> {
     let mut frames: Vec<Frame> = Vec::new();
     // Tabs of closed frames above the first open one.
     let mut waiting: Vec<PanelKind> = Vec::new();
@@ -199,6 +202,9 @@ fn column(entries: Vec<OldStackEntry>) -> Result<Node<PanelKind>, String> {
         }
         let active = entry.active.min(panels.len().saturating_sub(1));
         if entry.open {
+            if depth.saturating_add(frames.len()) > craft_ui::docking::MAX_DEPTH {
+                return Err("workspace dock tree exceeds supported limits".into());
+            }
             let height = entry.height.filter(|h| h.is_finite() && *h > 0.0).map(|h| h.min(MAX_FRAME));
             frames.push(Frame { panels, active, height });
         } else if let Some(above) = frames.last_mut() {
diff --git a/crates/ui-egui/src/workspace_state/legacy/tests.rs b/crates/ui-egui/src/workspace_state/legacy/tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/workspace_state/legacy/tests.rs
@@ -0,0 +1,35 @@
+use super::*;
+
+fn stack(count: usize) -> OldNode {
+    OldNode::Stack {
+        entries: (0..count).map(|_| OldStackEntry { panel: Some(PanelKind::Info), panels: Vec::new(), active: 0, open: true, height: None }).collect(),
+    }
+}
+
+#[test]
+fn stacked_frames_respect_the_remaining_tree_depth() {
+    let limit = craft_ui::docking::MAX_DEPTH;
+    assert!(node(stack(limit + 1), 0).is_ok());
+    assert!(node(stack(limit + 2), 0).is_err());
+    assert!(node(stack(1), limit).is_ok());
+    assert!(node(stack(2), limit).is_err());
+    assert!(node(stack(limit), 2).is_err());
+}
+
+#[test]
+fn oversized_legacy_stack_load_does_not_abort() {
+    const CHILD: &str = "EFFECTCRAFT_TEST_LEGACY_STACK_CHILD";
+    if std::env::var_os(CHILD).is_some() {
+        let entry = serde_json::json!({"panel": "Info", "open": true});
+        let value = serde_json::json!({"workspaces": {"Oversized": {"Stack": {"entries": vec![entry; 20_000]}}}});
+        assert!(serde_json::to_vec(&value).unwrap().len() < super::super::MAX_BYTES);
+        assert!(migrate(value, &UiState::default()).is_err());
+        return;
+    }
+    let output = std::process::Command::new(std::env::current_exe().unwrap())
+        .args(["--exact", "workspace_state::legacy::tests::oversized_legacy_stack_load_does_not_abort", "--nocapture"])
+        .env(CHILD, "1")
+        .output()
+        .unwrap();
+    assert!(output.status.success(), "{}\n{}", String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr));
+}
diff --git a/crates/ui-egui/tests/it/ui_font_search.rs b/crates/ui-egui/tests/it/ui_font_search.rs
--- a/crates/ui-egui/tests/it/ui_font_search.rs
+++ b/crates/ui-egui/tests/it/ui_font_search.rs
@@ -106,6 +106,52 @@ fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() {
     assert_eq!(doc.style_at(8).font, "Inter");
 }
 
+/// A font file of just `name`: the family and Regular in English, and the family again in
+/// Simplified Chinese (language 0x804).
+fn font_with_native_name(family: &str, native: &str) -> Vec<u8> {
+    let u16s = |v: &[u16]| -> Vec<u8> { v.iter().flat_map(|x| x.to_be_bytes()).collect() };
+    let records = [(0x409, 1, family), (0x409, 2, "Regular"), (0x804, 1, native)];
+    let strings: Vec<Vec<u8>> = records.iter().map(|(_, _, s)| s.encode_utf16().flat_map(|u| u.to_be_bytes()).collect()).collect();
+    let mut name = u16s(&[0, records.len() as u16, 6 + 12 * records.len() as u16]);
+    let mut off = 0;
+    for ((lang, id, _), st) in records.iter().zip(&strings) {
+        name.extend(u16s(&[3, 1, *lang, *id, st.len() as u16, off]));
+        off += st.len() as u16;
+    }
+    strings.iter().for_each(|st| name.extend(st));
+    let mut out = u16s(&[1, 0, 1, 0, 0, 0]);
+    out.extend(b"name");
+    out.extend([0u8; 4]);
+    out.extend(28u32.to_be_bytes());
+    out.extend((name.len() as u32).to_be_bytes());
+    out.extend(name);
+    out
+}
+
+/// A family whose own-language name has a character no interface font has is listed under its
+/// English name: the harness fails on a missing glyph, and the app would draw boxes. Picking the
+/// row still applies the family.
+#[test]
+fn font_menu_lists_a_name_the_interface_fonts_lack_in_english() {
+    const FAMILY: &str = "Native Name Menu Test";
+    // U+F1234 is in a private use plane that no interface font maps.
+    const NATIVE: &str = "中文\u{F1234}字体";
+    assert_eq!(effectcraft_engine::text::fonts::add_font_data(font_with_native_name(FAMILY, NATIVE)).len(), 1);
+    assert_eq!(effectcraft_engine::text::fonts::native_families().get(FAMILY).map(String::as_str), Some(NATIVE));
+    let mut s = Session::default();
+    s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
+    let lid = s.execute("layer.newText", json!({"text": "Native", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
+    let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
+    h.state_mut().show_panel(PanelKind::Character);
+    h.run_steps(3);
+    click(&mut h, "character.font");
+    type_text(&mut h, "native name menu");
+    let shown: Vec<String> = h.state().auto.query("character.font.option.").iter().map(|e| e.id.clone()).collect();
+    assert_eq!(shown, vec![format!("character.font.option.{FAMILY}")]);
+    click(&mut h, &format!("character.font.option.{FAMILY}"));
+    assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, FAMILY);
+}
+
 /// A font file of just `name` (the family, Regular) and an `fvar` table with one axis pinned at
 /// 400: a variable font as far as the font menu's filter is concerned.
 fn pinned_variable_font(family: &str) -> Vec<u8> {
diff --git a/crates/ui-egui/tests/it/ui_gpu.rs b/crates/ui-egui/tests/it/ui_gpu.rs
--- a/crates/ui-egui/tests/it/ui_gpu.rs
+++ b/crates/ui-egui/tests/it/ui_gpu.rs
@@ -115,5 +115,12 @@ fn a_lost_device_draws_nothing_autosaves_and_closes() {
     let saved = h.state().session.autosave.last_path.clone().expect("unsaved changes auto-saved");
     assert!(std::path::Path::new(&saved).is_file(), "{saved}");
     assert_eq!(*told.borrow(), vec![Some(Ok(saved))], "the user is told once, with the recovery copy");
+    // The close arrives: on macOS the app quits through `-[NSApplication terminate:]` instead of
+    // closing the window while AppKit's run loop is running (#341).
+    h.input_mut().viewports.entry(egui::ViewportId::ROOT).or_default().events.push(egui::ViewportEvent::Close);
+    h.step();
+    let kept = h.output().viewport_output.get(&egui::ViewportId::ROOT).is_some_and(|v| v.commands.contains(&egui::ViewportCommand::CancelClose));
+    assert_eq!(kept, cfg!(target_os = "macos"), "the platform quit closes the window on macOS");
+    assert_eq!(told.borrow().len(), 1);
     let _ = std::fs::remove_dir_all(&dir);
 }
