diff --git a/Cargo.lock b/Cargo.lock
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1174,6 +1174,7 @@ dependencies = [
 name = "effectcraft"
 version = "0.16.0"
 dependencies = [
+ "android-activity",
  "cpal",
  "effectcraft-engine",
  "effectcraft-host",
diff --git a/apps/effectcraft/Cargo.toml b/apps/effectcraft/Cargo.toml
--- a/apps/effectcraft/Cargo.toml
+++ b/apps/effectcraft/Cargo.toml
@@ -6,6 +6,10 @@ edition.workspace = true
 license.workspace = true
 default-run = "effectcraft"
 
+[lib]
+# The desktop binary and Android's entry shim (apps/effectcraft-android) share this library.
+crate-type = ["rlib"]
+
 [lints]
 workspace = true
 
@@ -15,17 +19,29 @@ effectcraft-host = { workspace = true }
 effectcraft-ui-egui = { workspace = true }
 egui = { workspace = true }
 # `links`: links (Help menu, Discord, Settings) open in the system browser.
-eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit", "links", "wayland", "x11"] }
+eframe = { workspace = true, features = ["default_fonts", "wgpu", "links"] }
 serde = { workspace = true }
 serde_json = { workspace = true }
 # The window's monitors, size and position (src/window.rs), and macOS event-loop options (no app
 # activation when driven by an agent). The same winit eframe runs on.
 winit = { version = "0.30", default-features = false }
-rfd = { workspace = true }
 pollster = { workspace = true }
 cpal = { workspace = true }
 log = { workspace = true }
 
+[target.'cfg(target_os = "android")'.dependencies]
+# The NativeActivity handle eframe's event loop needs (`NativeOptions.android_app`); the
+# same 0.6 instance winit and eframe use, so the handle passes across crates as one type.
+android-activity = { version = "0.6", default-features = false, features = ["native-activity"] }
+[target.'cfg(not(target_os = "android"))'.dependencies]
+# File and message dialogs. rfd has no Android backend (it doesn't compile there), so
+# Android builds leave the file hooks empty and the commands fail with
+# "no file dialog available".
+rfd = { workspace = true }
+# Screen-reader support and the desktop windowing backends. eframe 0.36 only offers
+# `accesskit` with `android-game-activity`, and Wayland/X11 don't exist on Android.
+eframe = { workspace = true, features = ["accesskit", "wayland", "x11"] }
+
 [target.'cfg(target_os = "linux")'.dependencies]
 # Settings ▸ Appearance ▸ Sync with System: the XDG desktop portal's colour scheme. Already in
 # Cargo.lock (accesskit, through eframe), so it adds no new crates.
diff --git a/apps/effectcraft/src/android_storage.rs b/apps/effectcraft/src/android_storage.rs
new file mode 100644
--- /dev/null
+++ b/apps/effectcraft/src/android_storage.rs
@@ -0,0 +1,62 @@
+//! Android's app-private storage: `Projects/`, `Exports/` and `config/` below the
+//! `AndroidApp::internal_data_path` directory.
+//!
+//! Android has no system file picker in v1 (rfd has no backend there), so saves, render outputs
+//! and settings default to private storage: no permissions, no Storage Access Framework
+//! round-trip, though invisible to the Files app (the in-app browser can additionally reach
+//! shared storage once "All files access" is granted). Pure path logic, unit-tested on the host;
+//! the handle comes from `run_android` (`apps/effectcraft/src/lib.rs`).
+
+use std::path::{Path, PathBuf};
+
+/// The storage folders below the app-private base directory.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct Layout {
+    /// Saved `.ecproj`/`.ecprojx` projects (the Open Project prompt lists these).
+    pub projects: PathBuf,
+    /// Render output, frame stills and other saved files.
+    pub exports: PathBuf,
+    /// Settings, auto-save and crash-recovery state (`session.config`).
+    pub config: PathBuf,
+}
+
+/// Split the app-private `base` into its folders.
+pub fn layout(base: &Path) -> Layout {
+    Layout { projects: base.join("Projects"), exports: base.join("Exports"), config: base.join("config") }
+}
+
+/// Create the folders (`Err` names the first failure; the app then keeps that feature off
+/// rather than crashing).
+pub fn ensure(layout: &Layout) -> Result<(), String> {
+    for dir in [&layout.projects, &layout.exports, &layout.config] {
+        std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.to_string_lossy()))?;
+    }
+    Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn temp_base(tag: &str) -> PathBuf {
+        std::env::temp_dir().join(format!("ec-android-storage-{tag}-{}", std::process::id()))
+    }
+
+    #[test]
+    fn layout_splits_the_base_into_projects_exports_and_config() {
+        let base = Path::new("/data/data/ai.storyteller.effectcraft/files");
+        assert_eq!(layout(base), Layout { projects: base.join("Projects"), exports: base.join("Exports"), config: base.join("config") });
+    }
+
+    #[test]
+    fn ensure_creates_every_folder() {
+        let base = temp_base("ok");
+        let _ = std::fs::remove_dir_all(&base);
+        let l = layout(&base);
+        ensure(&l).unwrap();
+        assert!(l.projects.is_dir() && l.exports.is_dir() && l.config.is_dir());
+        // Re-running over existing folders is fine.
+        ensure(&l).unwrap();
+        let _ = std::fs::remove_dir_all(&base);
+    }
+}
diff --git a/apps/effectcraft/src/audio_out.rs b/apps/effectcraft/src/audio_out.rs
--- a/apps/effectcraft/src/audio_out.rs
+++ b/apps/effectcraft/src/audio_out.rs
@@ -7,7 +7,7 @@
 //! channels: silence), converting to the device's sample format.
 
 use std::sync::Arc;
-use std::sync::atomic::{AtomicU64, Ordering};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
 use std::sync::mpsc;
 use std::thread::JoinHandle;
 use std::time::Duration;
@@ -22,6 +22,24 @@ fn preferred_buffer_frames(rate: u32) -> u32 {
     (rate / 100).clamp(256, 2048)
 }
 
+/// Frames between "handed over" and "audible": the device's output latency plus one callback's
+/// buffer. `dev_lat` is `playback − callback` from the stream timestamps, trusted only while it
+/// can be a real output latency: cpal's AAudio backend (Android) reports `playback` on
+/// CLOCK_MONOTONIC (absolute, since boot) but `callback` since the stream was created, so their
+/// difference is the device's uptime. An uptime-sized latency holds the preview's audio clock
+/// behind the frames the device consumed — the picture freezes on the first frame while the
+/// sound plays on. A value no device outputs is dropped (the buffer alone still counts).
+fn sync_latency_frames(dev_lat: f64, rate: f64, buffer_frames: usize) -> u64 {
+    const MAX_LATENCY_SECS: f64 = 1.0;
+    static WARNED: AtomicBool = AtomicBool::new(false);
+    let plausible = dev_lat.is_finite() && (0.0..=MAX_LATENCY_SECS).contains(&dev_lat);
+    if !plausible && !WARNED.swap(true, Ordering::Relaxed) {
+        log::warn!("audio output: implausible device latency {dev_lat}s; syncing A/V to this buffer alone");
+    }
+    let secs = if plausible { dev_lat } else { 0.0 };
+    (secs * rate) as u64 + buffer_frames as u64
+}
+
 /// Wait until the feeder has mixed a block, or until preview is stopped. Starting the stream
 /// before that fills the device with silence, so the first real samples are heard late (#554).
 fn wait_to_play(feed: &AudioFeed, stop: &mpsc::Receiver<()>) -> bool {
@@ -115,7 +133,7 @@ fn run<T: SizedSample + FromSample<f32>>(
             // Frames between "handed over" and "audible": device latency plus this buffer.
             let ts = info.timestamp();
             let dev_lat = ts.playback.duration_since(&ts.callback).map(|d| d.as_secs_f64()).unwrap_or(0.0);
-            latency.store((dev_lat * rate) as u64 + frames as u64, Ordering::Relaxed);
+            latency.store(sync_latency_frames(dev_lat, rate, frames), Ordering::Relaxed);
         },
         |e| log::warn!("audio output: {e}"),
         None,
@@ -230,6 +248,28 @@ mod tests {
         assert_eq!(preferred_buffer_frames(192_000), 1920);
     }
 
+    /// Real device latencies (ALSA/Pulse/PipeWire report a few to a few hundred milliseconds)
+    /// are used as they are, plus the callback's own buffer.
+    #[test]
+    fn sync_latency_keeps_a_plausible_device_latency() {
+        assert_eq!(sync_latency_frames(0.02, 48_000.0, 480), 960 + 480);
+        assert_eq!(sync_latency_frames(0.0, 48_000.0, 480), 480);
+        assert_eq!(sync_latency_frames(0.3, 44_100.0, 441), 13_230 + 441);
+    }
+
+    /// cpal's AAudio backend (Android) subtracts two different clocks — `playback` is
+    /// CLOCK_MONOTONIC since boot, `callback` since the stream was created — so the difference
+    /// is the tablet's uptime. That must not become the A/V-sync latency: pinned there, the
+    /// preview's clock never leaves its first frame (picture frozen while the sound plays).
+    #[test]
+    fn sync_latency_drops_an_uptime_sized_device_latency() {
+        // A tablet up ~5 days at 48 kHz would otherwise ask for ~2e10 frames of latency.
+        assert_eq!(sync_latency_frames(432_000.0, 48_000.0, 480), 480);
+        assert_eq!(sync_latency_frames(f64::INFINITY, 48_000.0, 480), 480);
+        assert_eq!(sync_latency_frames(f64::NAN, 48_000.0, 480), 480);
+        assert_eq!(sync_latency_frames(-0.5, 48_000.0, 480), 480);
+    }
+
     #[test]
     fn wait_to_play_returns_once_the_feeder_has_samples() {
         let feed = Arc::new(AudioFeed::default());
diff --git a/apps/effectcraft/src/lib.rs b/apps/effectcraft/src/lib.rs
new file mode 100644
--- /dev/null
+++ b/apps/effectcraft/src/lib.rs
@@ -0,0 +1,779 @@
+//! EffectCraft app library: the window, hooks and launch logic shared by the desktop
+//! binary (`main.rs`, whose `main` calls [`run`]) and Android's NativeActivity entry point
+//! (`apps/effectcraft-android`, whose `android_main` calls `run_android`).
+//!
+//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [--] [project.ecproj | media files…]`
+//!
+//! An unknown option or a bad port prints an error and exits with status 1 before anything starts.
+//!
+//! Without a project the app starts with an empty Untitled Project, as After Effects does;
+//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
+//!
+//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
+//! see `effectcraft_ui_egui::control` for the methods.
+//!
+//! On Android there are no command-line arguments, so the app opens an empty Untitled Project.
+//! There is no system file picker (rfd has no Android backend): Save, Open Project, exports,
+//! folder picks and media import are served by the in-app browser instead. It browses the
+//! app-private storage (`Projects/`, `Exports/`) always, and shared storage
+//! (`/storage/emulated/0`) too once the user grants "All files access" — the grant is checked
+//! and requested over JNI by the entry crate (`effectcraft-android`), which opts out of this
+//! workspace's `unsafe_code = "forbid"`; this crate stays unsafe-free and only receives
+//! [`SharedStorageAccess`] as plain closures.
+#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+
+// Built everywhere so its tests run on every platform; only Android uses it (its callers are
+// cfg(android), so elsewhere these tests are the reason it compiles at all).
+#[cfg_attr(not(target_os = "android"), allow(dead_code))]
+mod android_storage;
+mod appearance;
+// Built everywhere so its tests run on every platform; only Linux AppImages use it.
+#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
+mod appimage;
+#[cfg(target_os = "macos")]
+mod apple_events;
+mod audio_out;
+mod control_server;
+mod launch_guard;
+#[cfg(target_os = "macos")]
+mod native_menu;
+// Built everywhere so it stays checked; the macOS and Linux apps use it. Android has no rfd
+// at all (no backend there), so it isn't built for Android.
+#[cfg(not(target_os = "android"))]
+#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
+mod sheet_dialogs;
+mod window;
+
+use effectcraft_ui_egui::EffectcraftApp;
+use serde_json::json;
+
+/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by
+/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.
+/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).
+#[cfg(not(target_os = "macos"))]
+const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
+
+fn app_icon() -> egui::IconData {
+    #[cfg(target_os = "macos")]
+    let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png");
+    #[cfg(not(target_os = "macos"))]
+    let png: &[u8] = ICON_PNG;
+    eframe::icon_data::from_png_bytes(png).unwrap_or_default()
+}
+
+/// Both native JSON projects and Save a Copy As XML projects are openable on launch.
+/// Keep this classification shared by project opening and the subsequent media-import pass.
+fn is_project_file(path: &str) -> bool {
+    std::path::Path::new(path)
+        .extension()
+        .and_then(|ext| ext.to_str())
+        .is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
+}
+
+/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.
+#[cfg(target_os = "linux")]
+fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {
+    (wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))
+        .then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).")
+}
+
+/// The command line.
+#[derive(Debug, Default, PartialEq)]
+struct Args {
+    control_port: Option<u16>,
+    files: Vec<String>,
+    /// Open the demo project when no project is given (`--demo`).
+    demo: bool,
+    /// Show or skip the Home screen (`--home`), else the Startup preference decides.
+    home: Option<bool>,
+}
+
+/// Parse the arguments after the program name; `Ok(None)` for `--version`. `env_port` is
+/// `EFFECTCRAFT_CONTROL_PORT`, used when `--control` isn't given. An unknown option or a bad port
+/// is an error, so it is neither imported as a file nor dropped; after `--` every argument is a file.
+fn parse_args(args: impl IntoIterator<Item = String>, env_port: Result<String, std::env::VarError>) -> Result<Option<Args>, String> {
+    let mut out = Args::default();
+    let mut args = args.into_iter();
+    let mut options = true;
+    while let Some(a) = args.next() {
+        if !options {
+            out.files.push(a);
+            continue;
+        }
+        match a.as_str() {
+            "--" => options = false,
+            "--control" => out.control_port = Some(control_port("--control", args.next().as_deref())?),
+            "--demo" => out.demo = true,
+            "--empty" => out.demo = false,
+            "--home" => out.home = Some(true),
+            "--version" => return Ok(None),
+            _ => match a.strip_prefix("--control=") {
+                Some(v) => out.control_port = Some(control_port("--control", Some(v))?),
+                // macOS LaunchServices may pass a process serial number (`-psn_0_1234`), notably on
+                // the first launch of a downloaded app; it is not an option of ours.
+                None if a.starts_with("-psn_") => {}
+                None if a.starts_with('-') && a != "-" => return Err(format!("unknown option `{a}`")),
+                None => out.files.push(a),
+            },
+        }
+    }
+    if out.control_port.is_none() {
+        out.control_port = match env_port {
+            Ok(v) if v.is_empty() => None,
+            Ok(v) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v))?),
+            Err(std::env::VarError::NotPresent) => None,
+            Err(std::env::VarError::NotUnicode(v)) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v.to_string_lossy()))?),
+        };
+    }
+    Ok(Some(out))
+}
+
+/// A control-channel port from 1 to 65535 (0 would listen on a port nobody is told); `name` is
+/// the flag or variable it came from.
+fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
+    let need = format!("{name} needs a port number from 1 to 65535");
+    match value {
+        None => Err(need),
+        Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),
+    }
+}
+
+/// Open the window and run the app until its window closes. The desktop binary calls this
+/// from `main`; Android's NativeActivity entry point calls [`run_android`] from `android_main`
+/// (which has no arguments, so an empty Untitled Project opens).
+pub fn run() -> eframe::Result {
+    #[cfg(target_os = "android")]
+    {
+        run_inner(None, None)
+    }
+    #[cfg(not(target_os = "android"))]
+    {
+        run_inner()
+    }
+}
+
+/// Android's "All files access" capabilities (`MANAGE_EXTERNAL_STORAGE`), built by the entry
+/// crate — which owns the JNI to the activity's JVM, since this workspace forbids `unsafe` —
+/// and handed to the in-app file browser as hooks: whether shared storage may be browsed, and
+/// how to ask for the grant. `None` when the entry crate could not reach the JVM (the browser
+/// then only ever offers the app-private root).
+#[cfg(target_os = "android")]
+pub struct SharedStorageAccess {
+    /// Whether the grant holds right now (re-checked whenever the browser shows the root).
+    pub granted: Box<dyn Fn() -> bool>,
+    /// Ask for it: the system settings page on Android 11+, the permission dialog before that.
+    pub ask: Box<dyn Fn()>,
+}
+
+/// Android entry point: like [`run`], but with the activity's handle, which eframe's event
+/// loop cannot be created without — leaving it out ends the activity as soon as it starts —
+/// and with the optional shared-storage capabilities [`SharedStorageAccess`] the entry crate
+/// resolves through JNI.
+#[cfg(target_os = "android")]
+pub fn run_android(android_app: android_activity::AndroidApp, access: Option<SharedStorageAccess>) -> eframe::Result {
+    run_inner(Some(android_app), access)
+}
+
+/// Shared startup; `android_app` is the NativeActivity handle eframe needs on Android.
+fn run_inner(
+    #[cfg(target_os = "android")] android_app: Option<android_activity::AndroidApp>,
+    #[cfg(target_os = "android")] access: Option<SharedStorageAccess>,
+) -> eframe::Result {
+    // First, so the panic hook and every start-up warning are recorded. Records go to stderr
+    // and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
+    // feed the compatibility report.
+    effectcraft_engine::logging::install();
+    effectcraft_engine::logging::install_panic_hook();
+    let Args { control_port, files, demo, home } = match parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT")) {
+        Ok(Some(args)) => args,
+        Ok(None) => {
+            println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
+            return Ok(());
+        }
+        // Before the session log, preferences and the window: nothing has started yet.
+        Err(e) => {
+            eprintln!("effectcraft: {e}");
+            std::process::exit(1);
+        }
+    };
+    // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
+    // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
+    // `--version` leaves no file behind; records logged until now are written to it first.
+    if let Some(dir) = effectcraft_host::config_dir() {
+        match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
+            Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
+            Err(e) => log::warn!("no log file: {e}"),
+        }
+    }
+    // The font menus list the installed fonts: read their names while the window opens.
+    effectcraft_engine::text::fonts::scan_system_in_background();
+    // Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
+    #[cfg(target_os = "linux")]
+    appimage::integrate_from_env(ICON_PNG);
+    // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
+    // stopped as its window opened (a crashing graphics driver, #243).
+    let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
+    let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
+    let notice = launch.notice.take();
+    // eframe cannot create its event loop on Android without the activity's handle; without
+    // it `run_native` fails before the window exists and the activity ends on launch.
+    // The handle also resolves the app-private storage below (saves, exports, settings).
+    #[cfg(target_os = "android")]
+    let android_files: Option<std::path::PathBuf> = android_app.as_ref().and_then(|app| {
+        let dir = app.internal_data_path();
+        if dir.is_none() {
+            log::warn!("android storage: no internal data path; save and open stay unavailable");
+        }
+        dir
+    });
+    // Shared storage (`/storage/emulated/0`) is offered as a second browse root beside the
+    // app-private directory, so saves/exports/autosaves can land outside the app dir. Reaching it
+    // needs the user to grant "All files access" in system settings, which the browser asks for
+    // (and the app can only detect, never grant silently).
+    #[cfg(target_os = "android")]
+    let android_shared: Option<std::path::PathBuf> = android_app.as_ref().map(|_| std::path::PathBuf::from("/storage/emulated/0"));
+    let options = eframe::NativeOptions {
+        viewport: egui::ViewportBuilder::default()
+            .with_title("EffectCraft")
+            .with_inner_size(window::DEFAULT_SIZE)
+            .with_min_inner_size(window::MIN_SIZE)
+            .with_drag_and_drop(true)
+            // Started for an agent (`--control`): open without taking the user's keyboard focus.
+            .with_active(control_port.is_none())
+            .with_icon(app_icon())
+            // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.
+            .with_app_id(appimage::APP_ID),
+        event_loop_builder: agent_event_loop(control_port.is_some()),
+        wgpu_options: wgpu_options(launch.backends),
+        // The activity's handle resolved above (Android only; the event loop needs it).
+        #[cfg(target_os = "android")]
+        android_app,
+        ..Default::default()
+    };
+    // Start-up milestones go to stderr and the session log, so a window that never appears
+    // shows how far start-up got (#234).
+    log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
+    // Registered before the event loop starts, so it catches the Finder event that launched us as
+    // well as later ones. Lives until the event loop returns; the app creator only borrows it.
+    #[cfg(target_os = "macos")]
+    let apple_events = apple_events::AppleEvents::install();
+    #[cfg(target_os = "macos")]
+    let apple_events = &apple_events;
+    let result = eframe::run_native(
+        "EffectCraft",
+        options,
+        Box::new(move |cc| {
+            match &cc.wgpu_render_state {
+                Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
+                None => log::info!("graphics: no wgpu device"),
+            }
+            // This executable owns the shared device's handlers. Install them before
+            // EffectCraft's compositor pipelines are built, without changing handlers from
+            // inside Gpu::new. eframe has already constructed its presentation renderer here.
+            let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
+            if let Some(rs) = &cc.wgpu_render_state {
+                let errors = gpu_failures.clone();
+                rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
+                    let message = format!("uncaptured GPU error: {error}");
+                    if errors.report(&message, false) {
+                        log::error!("{message}");
+                    }
+                }));
+                let lost = gpu_failures.clone();
+                rs.device.set_device_lost_callback(move |reason, message| {
+                    lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
+                });
+            }
+            let mut session = effectcraft_host::session();
+            // Lazy open and non-blocking auto-save: footage is checked and auto-saves are
+            // written on background threads (M13.14).
+            session.check_footage_on_open = true;
+            session.autosave.background = true;
+            // Settings, shortcut presets and the crash-recovery sentinel live in the platform
+            // config directory. Agent-driven runs (`--control`) skip crash recovery.
+            if let Some(dir) = effectcraft_host::config_dir() {
+                // WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
+                let plugins = dir.join("Plug-ins");
+                if plugins.is_dir() {
+                    let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));
+                }
+                session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
+            }
+            // Android has no platform config directory: settings, auto-save and crash recovery
+            // live in app-private storage instead (quietly off when it is unavailable).
+            #[cfg(target_os = "android")]
+            let android_layout = android_files.as_deref().map(android_storage::layout);
+            #[cfg(target_os = "android")]
+            if let Some(l) = &android_layout {
+                if let Err(e) = android_storage::ensure(l) {
+                    log::warn!("android storage: {e}");
+                }
+                session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(l.config.clone())));
+            }
+            session.load_settings();
+            // Fit the window to its monitor, or put it back where it was (still hidden).
+            let saved_window = session.config.as_deref().and_then(window::load);
+            let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
+            // Render output defaults to app-private Exports on Android: the working directory is
+            // `/` there and nothing else in the fallback chain resolves to somewhere writable.
+            #[cfg(target_os = "android")]
+            if let Some(l) = &android_layout
+                && session.prefs.export.default_output_folder.trim().is_empty()
+            {
+                session.prefs.export.default_output_folder = l.exports.to_string_lossy().to_string();
+            }
+            let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
+            let project = files.iter().find(|f| is_project_file(f)).cloned();
+            if let Some(p) = project {
+                if let Err(e) = session.execute("file.open", json!({"path": p})) {
+                    log::warn!("{p}: {e}");
+                }
+            } else if demo {
+                let _ = session.execute("file.openDemoProject", json!({}));
+            }
+            let media: Vec<String> = files.iter().filter(|f| !is_project_file(f)).cloned().collect();
+            if !media.is_empty()
+                && let Err(e) = session.execute("file.import", json!({"paths": media}))
+            {
+                log::warn!("import: {e}");
+            }
+            let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
+            // The ScriptUI panels open when the app last quit open again (a failing one is
+            // reported in a toast and forgotten).
+            let _ = session.execute("window.restoreScriptPanels", json!({}));
+            let mut app = EffectcraftApp::new(session);
+            app.set_gpu_failure_bridge(gpu_failures);
+            app.ui.start_screen = show_home;
+            if let Some(n) = notice {
+                app.ui.status = n;
+            }
+            if let Some(r) = recovery {
+                app.offer_recovery(r);
+            }
+            #[cfg(target_os = "linux")]
+            if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref())
+                && app.ui.status.is_empty()
+            {
+                app.ui.status = note.into();
+            }
+            // No file dialogs on Android (rfd has no backend there): the hooks stay empty and
+            // the commands fail with "no file dialog available".
+            #[cfg(not(target_os = "android"))]
+            {
+                app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
+                    rfd::FileDialog::new()
+                        .add_filter("Media", exts)
+                        .pick_files()
+                        .unwrap_or_default()
+                        .into_iter()
+                        .map(|p| p.to_string_lossy().to_string())
+                        .collect()
+                }));
+                app.hooks.pick_save = Some(Box::new(|name: &str| {
+                    rfd::FileDialog::new()
+                        .add_filter("EffectCraft Project", &["ecproj"])
+                        .set_file_name(name)
+                        .save_file()
+                        .map(|p| p.to_string_lossy().to_string())
+                }));
+                // The window closes after a lost graphics device (#591); say why, unless an agent drives
+                // the app through the control channel. Android has no message dialogs either.
+                if control_port.is_none() {
+                    app.hooks.device_lost = Some(Box::new(|saved: Option<Result<String, String>>| {
+                        let saved = match saved {
+                            Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."),
+                            Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."),
+                            None => String::new(),
+                        };
+                        rfd::MessageDialog::new()
+                            .set_level(rfd::MessageLevel::Error)
+                            .set_title("EffectCraft: graphics device lost")
+                            .set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}"))
+                            .show();
+                    }));
+                }
+                app.hooks.pick_open_project = Some(Box::new(|| {
+                    rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
+                }));
+                app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
+                app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
+                    // A default with a folder that exists opens the dialog there.
+                    let path = std::path::Path::new(name);
+                    // (No filter for a name without an extension, such as a folder to create.)
+                    let mut d = if ext.is_empty() { rfd::FileDialog::new() } else { rfd::FileDialog::new().add_filter(ext, &[ext]) };
+                    if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
+                        d = d.set_directory(dir);
+                    }
+                    let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
+                    d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
+                }));
+            }
+            app.hooks.system_theme = appearance::service();
+            app.hooks.audio_device = Some(Box::new(audio_out::open));
+            app.hooks.audio_devices = Some(Box::new(audio_out::devices));
+            // The in-app Save / Open Project prompts read and write here (nothing when the
+            // storage directory was unavailable: the commands keep their "no dialog" error).
+            // Shared storage is a second browse root, gated on the "All files access" grant.
+            #[cfg(target_os = "android")]
+            {
+                if let Some(base) = android_files {
+                    app.hooks.app_files_dir = Some(base);
+                }
+                if let Some(a) = access {
+                    // The shared root joins the grant hooks: with them absent (Android 10's
+                    // decline in `capture`, or a JVM the app could not reach) the browser
+                    // would list a root it cannot read inside, next to a grant prompt
+                    // nothing answers.
+                    if let Some(root) = android_shared {
+                        app.hooks.shared_storage_root = Some(root);
+                    }
+                    app.hooks.shared_storage_granted = Some(a.granted);
+                    app.hooks.shared_storage_grant = Some(a.ask);
+                }
+            }
+            // The commands' file dialogs don't block the frame where the platform's backend
+            // would (#511, #1275): on macOS as sheets instead of nested modal loops, on Linux
+            // through the desktop portal from a worker thread — a blocking portal dialog leaves
+            // the main thread waiting on its answer without pumping events, so the window stops
+            // answering the compositor and the desktop offers to force-quit it while the Open
+            // dialog is up. Windows' blocking dialog pumps its own messages; Android has no
+            // rfd at all (the in-app browser serves the dialogs).
+            #[cfg(any(target_os = "macos", target_os = "linux"))]
+            {
+                let ctx = cc.egui_ctx.clone();
+                app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
+            }
+            #[cfg(target_os = "macos")]
+            {
+                app.hooks.app_action = Some(Box::new(native_menu::app_action));
+                app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
+            }
+            if let Some(port) = control_port {
+                disable_app_nap();
+                let rx = control_server::start(port, cc.egui_ctx.clone());
+                app = app.with_control(rx);
+            }
+            log::info!("app created; the window shows after its first frame");
+            Ok(Box::new(Desktop {
+                #[cfg(target_os = "macos")]
+                menu: native_menu::NativeBar::new(&cc.egui_ctx),
+                #[cfg(target_os = "macos")]
+                apple_events: apple_events.connect(&cc.egui_ctx),
+                app,
+                window: window::Tracker::new(opened, saved_window, control_port.is_none()),
+                launch,
+                drawn: false,
+            }))
+        }),
+    );
+    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")));
+        }
+    }
+    result
+}
+
+/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)
+/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
+/// #198) still opens the app, and the compositor then checks what it needs and falls back to
+/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
+fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
+    use eframe::egui_wgpu::WgpuSetup;
+    let mut config = eframe::WgpuConfiguration::default();
+    if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
+        if let Some(b) = backends {
+            setup.instance_descriptor.backends = b;
+        }
+        // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs
+        // a compute shader when the device is created, one that Metal can't compile on an Intel
+        // Iris Pro: the device is lost and the window never opens.
+        // `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.
+        setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
+        setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));
+    }
+    config
+}
+
+/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,
+/// so memory the compositor frees on the shared device goes back to the driver.
+fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {
+    eframe::wgpu::DeviceDescriptor {
+        label: Some("EffectCraft device"),
+        required_limits: device_limits(adapter),
+        memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,
+        ..Default::default()
+    }
+}
+
+/// The limits to request from an adapter that offers `adapter`: all of them, with textures
+/// capped at 16384 px like the headless renderer's device.
+fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
+    eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }
+}
+
+/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting
+/// silently, since release builds on Windows have no console. `gl_next`: the launch guard has
+/// the next launch try OpenGL. Android has no message dialogs; the message is logged (stderr
+/// reaches logcat) and the activity's thread ends.
+fn startup_failed(e: &eframe::Error, gl_next: bool) {
+    let next = if gl_next { "The next launch will try OpenGL instead. " } else { "" };
+    let message = format!(
+        "EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
+    );
+    // Standard error and the session log; standard error alone when RUST_LOG turned errors off.
+    if log::log_enabled!(log::Level::Error) {
+        log::error!("{message}");
+    } else {
+        eprintln!("effectcraft: {message}");
+    }
+    // No message dialog on Android (rfd has no backend there): stderr already carries the
+    // message into logcat, and the activity's thread ends.
+    #[cfg(not(target_os = "android"))]
+    let _ = rfd::MessageDialog::new()
+        .set_level(rfd::MessageLevel::Error)
+        .set_title("EffectCraft")
+        .set_description(message)
+        .set_buttons(rfd::MessageButtons::Ok)
+        .show();
+}
+
+/// The desktop app: the egui app plus, on macOS, the system menu bar.
+struct Desktop {
+    app: EffectcraftApp,
+    #[cfg(target_os = "macos")]
+    menu: native_menu::NativeBar,
+    /// Documents opened from Finder, the Dock or `open` (macOS).
+    #[cfg(target_os = "macos")]
+    apple_events: apple_events::Opened,
+    /// The window's size and position, saved for the next launch.
+    window: window::Tracker,
+    launch: launch_guard::Launch,
+    /// The first frame was drawn (logged once).
+    drawn: bool,
+}
+
+impl eframe::App for Desktop {
+    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
+        #[cfg(target_os = "macos")]
+        self.menu.update(&mut self.app, ctx);
+        self.app.logic(ctx, frame);
+    }
+
+    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
+        self.app.ui(ui, frame);
+        self.window.frame(ui.ctx(), self.app.session.config.as_deref());
+        if !self.drawn {
+            self.drawn = true;
+            log::info!("first frame drawn");
+        }
+        // The launch guard's marker goes once the window has kept drawing for a few seconds: a
+        // driver may still crash as the window is shown and its surface re-created.
+        if let Some(left) = self.launch.frame_drawn() {
+            ui.ctx().request_repaint_after(left);
+        }
+    }
+
+    fn on_exit(&mut self) {
+        self.launch.exited();
+        self.window.save(self.app.session.config.as_deref());
+        self.app.on_exit();
+    }
+
+    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
+        #[cfg(target_os = "macos")]
+        self.apple_events.feed(self.app.is_ready(), ctx, raw_input);
+        self.app.raw_input_hook(ctx, raw_input);
+    }
+}
+
+/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
+/// focus stays where it is (macOS; winit activates ignoring other apps by default).
+fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
+    #[cfg(target_os = "macos")]
+    if agent {
+        return Some(Box::new(|b| {
+            use winit::platform::macos::EventLoopBuilderExtMacOS;
+            b.with_activate_ignoring_other_apps(false);
+        }));
+    }
+    let _ = agent;
+    None
+}
+
+/// Agents drive the app while its window is covered or on another Space. macOS App Nap would
+/// throttle the event loop then, stalling the control channel; opt out for `--control` runs.
+fn disable_app_nap() {
+    #[cfg(target_os = "macos")]
+    {
+        use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString};
+        let info = NSProcessInfo::processInfo();
+        let reason = NSString::from_str("EffectCraft control channel");
+        let opts = NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical;
+        // Leaked on purpose: the activity lasts for the life of the process.
+        std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{Args, parse_args, sheet_dialogs};
+    use effectcraft_ui_egui::{DialogDone, FileDialog};
+    use std::env::VarError;
+
+    fn parse_env(args: &[&str], env_port: Result<String, VarError>) -> Result<Option<Args>, String> {
+        parse_args(args.iter().map(|a| a.to_string()), env_port)
+    }
+
+    fn parse(args: &[&str]) -> Option<Args> {
+        parse_env(args, Err(VarError::NotPresent)).unwrap()
+    }
+
+    fn fails(args: &[&str]) -> String {
+        parse_env(args, Err(VarError::NotPresent)).unwrap_err()
+    }
+
+    #[cfg(target_os = "linux")]
+    #[test]
+    fn wayland_drop_notice_recommends_import_or_xwayland() {
+        use std::ffi::OsStr;
+        let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();
+        assert!(warning.contains("File ▸ Import") && warning.contains("XWayland"));
+        assert!(super::wayland_drop_notice(None, None).is_none());
+        assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());
+        assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none());
+    }
+
+    /// The window's wgpu instance skips indirect-call validation (Intel Metal, #1153).
+    #[test]
+    fn the_window_instance_skips_indirect_call_validation() {
+        if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {
+            return;
+        }
+        let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = super::wgpu_options(None).wgpu_setup else {
+            panic!("the default setup creates its own instance");
+        };
+        // Intel Metal can't compile the validation's compute shader, and the device is lost.
+        assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
+    }
+
+    /// The window's device never asks for more than the adapter offers (#198).
+    #[test]
+    fn device_limits_fit_the_adapter() {
+        use eframe::wgpu::Limits;
+        for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {
+            let l = super::device_limits(adapter.clone());
+            assert!(l.check_limits(&adapter), "{l:?}");
+            assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);
+        }
+        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
+        assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
+    }
+
+    /// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.
+    #[test]
+    fn the_window_device_favours_memory_usage() {
+        use eframe::wgpu::Limits;
+        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
+        let d = super::device_descriptor(big);
+        assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));
+        assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);
+        assert_eq!(d.label, Some("EffectCraft device"));
+    }
+
+    /// A positional .ecprojx is a project file, not an unsupported media import (#528).
+    #[test]
+    fn launch_classifies_xml_and_json_projects_as_projects() {
+        for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] {
+            assert!(super::is_project_file(path), "{path}");
+        }
+        for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] {
+            assert!(!super::is_project_file(path), "{path}");
+        }
+        let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()];
+        assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx"));
+        assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::<Vec<_>>(), vec!["clip.mov"],);
+    }
+
+    /// Launching without a project opens an empty project, not the demo (#204).
+    #[test]
+    fn no_arguments_start_an_empty_project() {
+        assert_eq!(parse(&[]), Some(Args::default()));
+        assert!(parse(&["--demo"]).is_some_and(|a| a.demo));
+        assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));
+        let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();
+        assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));
+        assert_eq!(parse(&["--version"]), None);
+        assert_eq!(parse_env(&[], Ok("9000".into())).unwrap().map(|a| a.control_port), Some(Some(9000)));
+    }
+
+    /// A missing, non-numeric or zero `--control` value stops the launch instead of starting
+    /// without a control server, and the argument after `--control` isn't reinterpreted (#997).
+    #[test]
+    fn control_needs_a_port_from_1_to_65535() {
+        let need = "--control needs a port number from 1 to 65535";
+        assert_eq!(fails(&["--control"]), need);
+        assert_eq!(fails(&["--control", "--demo"]), format!("{need}, not `--demo`"));
+        assert_eq!(fails(&["--control", "--version"]), format!("{need}, not `--version`"));
+        for bad in ["abc", "0", "70000", "-1", ""] {
+            assert_eq!(fails(&["--control", bad]), format!("{need}, not `{bad}`"));
+            assert_eq!(fails(&[&format!("--control={bad}")]), format!("{need}, not `{bad}`"));
+        }
+        assert_eq!(parse(&["--control=9877"]).map(|a| a.control_port), Some(Some(9877)));
+        assert_eq!(parse(&["--control", "65535"]).map(|a| a.control_port), Some(Some(65535)));
+    }
+
+    /// An unknown option is an error, not a file to import (#997); `--` ends the options.
+    #[test]
+    fn unknown_options_are_errors_not_files() {
+        for bad in ["--automation-read-root", "--automation-write-root=/tmp", "-h", "--Demo", "--demo=yes"] {
+            assert_eq!(fails(&[bad, "/tmp/folder"]), format!("unknown option `{bad}`"));
+        }
+        // Parsed left to right: an error before `--version` wins.
+        assert_eq!(fails(&["--nope", "--version"]), "unknown option `--nope`");
+        // Finder's process serial number is ignored, not imported or refused.
+        assert_eq!(parse(&["-psn_0_12345"]).map(|a| a.files), Some(vec![]));
+        let a = parse(&["clip.mov", "-", "--", "--demo", "-name.png"]).unwrap_or_default();
+        assert_eq!(a.files, ["clip.mov", "-", "--demo", "-name.png"]);
+        assert!(!a.demo);
+    }
+
+    /// `--control` wins over `EFFECTCRAFT_CONTROL_PORT`; the variable is checked only when it is
+    /// used, so a bad value doesn't stop `--control` or `--version`.
+    #[test]
+    fn control_port_variable_is_checked_when_used() {
+        let need = "EFFECTCRAFT_CONTROL_PORT needs a port number from 1 to 65535";
+        assert_eq!(parse_env(&[], Ok("abc".into())).unwrap_err(), format!("{need}, not `abc`"));
+        assert_eq!(parse_env(&[], Ok("0".into())).unwrap_err(), format!("{need}, not `0`"));
+        assert_eq!(parse_env(&[], Ok(String::new())), Ok(Some(Args::default())));
+        assert_eq!(parse_env(&["--control", "9877"], Ok("abc".into())).unwrap().map(|a| a.control_port), Some(Some(9877)));
+        assert_eq!(parse_env(&["--version"], Ok("abc".into())), Ok(None));
+        #[cfg(unix)]
+        {
+            use std::os::unix::ffi::OsStringExt;
+            let not_utf8 = VarError::NotUnicode(std::ffi::OsString::from_vec(vec![0x39, 0xff]));
+            assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));
+        }
+    }
+
+    /// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the
+    /// compositor while it is open, so the commands' file dialogs go through the non-blocking
+    /// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.
+    #[test]
+    fn commands_file_dialogs_go_through_the_non_blocking_hook() {
+        let mut app = crate::EffectcraftApp::new(effectcraft_engine::Session::default());
+        #[cfg(any(target_os = "macos", target_os = "linux"))]
+        {
+            let ctx = egui::Context::default();
+            app.hooks.file_dialog_async = Some(Box::new(move |dialog: FileDialog, done: DialogDone| sheet_dialogs::show(dialog, done, ctx.clone())));
+        }
+        #[cfg(any(target_os = "macos", target_os = "linux"))]
+        assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
+        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
+        assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
+    }
+}
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
@@ -1,647 +1,7 @@
-//! EffectCraft desktop app.
-//!
-//! Usage: `effectcraft [--control <port>] [--demo|--empty|--home] [--] [project.ecproj | media files…]`
-//!
-//! An unknown option or a bad port prints an error and exits with status 1 before anything starts.
-//!
-//! Without a project the app starts with an empty Untitled Project, as After Effects does;
-//! `--demo` opens the demo project instead (also Help ▸ Open Demo Project).
-//!
-//! `--control <port>` (or `EFFECTCRAFT_CONTROL_PORT`) starts a localhost JSON-lines control server;
-//! see `effectcraft_ui_egui::control` for the methods.
+//! EffectCraft desktop app binary. The app itself is the library (`lib.rs`, [`effectcraft::run`]);
+//! Android's NativeActivity entry point is the `effectcraft-android` crate.
 #![cfg_attr(all(target_os = "windows", not(debug_assertions)), windows_subsystem = "windows")]
-#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
-
-// Built everywhere so its tests run on every platform; only Linux AppImages use it.
-mod appearance;
-#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
-mod appimage;
-#[cfg(target_os = "macos")]
-mod apple_events;
-mod audio_out;
-mod control_server;
-mod launch_guard;
-#[cfg(target_os = "macos")]
-mod native_menu;
-// Built everywhere so it stays checked; the macOS and Linux apps use it.
-#[cfg_attr(not(any(target_os = "macos", target_os = "linux")), allow(dead_code))]
-mod sheet_dialogs;
-mod window;
-
-use effectcraft_ui_egui::EffectcraftApp;
-use serde_json::json;
-
-/// The app icon for the window, taskbar and Dock (assets/app-icon, regenerated by
-/// `packaging/icons.sh`). macOS gets the padded variant so it sits on Apple's icon grid in the Dock.
-/// The 256 px app icon (Windows, Linux; the AppImage's desktop integration installs it too).
-#[cfg(not(target_os = "macos"))]
-const ICON_PNG: &[u8] = include_bytes!("../../../assets/app-icon/hicolor/256x256/apps/ai.storyteller.effectcraft.png");
-
-fn app_icon() -> egui::IconData {
-    #[cfg(target_os = "macos")]
-    let png: &[u8] = include_bytes!("../../../assets/app-icon/effectcraft-macos-512.png");
-    #[cfg(not(target_os = "macos"))]
-    let png: &[u8] = ICON_PNG;
-    eframe::icon_data::from_png_bytes(png).unwrap_or_default()
-}
-
-/// Both native JSON projects and Save a Copy As XML projects are openable on launch.
-/// Keep this classification shared by project opening and the subsequent media-import pass.
-fn is_project_file(path: &str) -> bool {
-    std::path::Path::new(path)
-        .extension()
-        .and_then(|ext| ext.to_str())
-        .is_some_and(|ext| ext.eq_ignore_ascii_case("ecproj") || ext.eq_ignore_ascii_case("ecprojx"))
-}
-
-/// winit 0.30 cannot deliver native Wayland file drops. XWayland can.
-#[cfg(target_os = "linux")]
-fn wayland_drop_notice(wayland_display: Option<&std::ffi::OsStr>, backend: Option<&std::ffi::OsStr>) -> Option<&'static str> {
-    (wayland_display.is_some_and(|v| !v.is_empty()) && backend != Some(std::ffi::OsStr::new("x11")))
-        .then_some("Native Wayland file drops are unavailable. Use File ▸ Import ▸ File… or run via XWayland (see README, Linux).")
-}
-
-/// The command line.
-#[derive(Debug, Default, PartialEq)]
-struct Args {
-    control_port: Option<u16>,
-    files: Vec<String>,
-    /// Open the demo project when no project is given (`--demo`).
-    demo: bool,
-    /// Show or skip the Home screen (`--home`), else the Startup preference decides.
-    home: Option<bool>,
-}
-
-/// Parse the arguments after the program name; `Ok(None)` for `--version`. `env_port` is
-/// `EFFECTCRAFT_CONTROL_PORT`, used when `--control` isn't given. An unknown option or a bad port
-/// is an error, so it is neither imported as a file nor dropped; after `--` every argument is a file.
-fn parse_args(args: impl IntoIterator<Item = String>, env_port: Result<String, std::env::VarError>) -> Result<Option<Args>, String> {
-    let mut out = Args::default();
-    let mut args = args.into_iter();
-    let mut options = true;
-    while let Some(a) = args.next() {
-        if !options {
-            out.files.push(a);
-            continue;
-        }
-        match a.as_str() {
-            "--" => options = false,
-            "--control" => out.control_port = Some(control_port("--control", args.next().as_deref())?),
-            "--demo" => out.demo = true,
-            "--empty" => out.demo = false,
-            "--home" => out.home = Some(true),
-            "--version" => return Ok(None),
-            _ => match a.strip_prefix("--control=") {
-                Some(v) => out.control_port = Some(control_port("--control", Some(v))?),
-                // macOS LaunchServices may pass a process serial number (`-psn_0_1234`), notably on
-                // the first launch of a downloaded app; it is not an option of ours.
-                None if a.starts_with("-psn_") => {}
-                None if a.starts_with('-') && a != "-" => return Err(format!("unknown option `{a}`")),
-                None => out.files.push(a),
-            },
-        }
-    }
-    if out.control_port.is_none() {
-        out.control_port = match env_port {
-            Ok(v) if v.is_empty() => None,
-            Ok(v) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v))?),
-            Err(std::env::VarError::NotPresent) => None,
-            Err(std::env::VarError::NotUnicode(v)) => Some(control_port("EFFECTCRAFT_CONTROL_PORT", Some(&v.to_string_lossy()))?),
-        };
-    }
-    Ok(Some(out))
-}
-
-/// A control-channel port from 1 to 65535 (0 would listen on a port nobody is told); `name` is
-/// the flag or variable it came from.
-fn control_port(name: &str, value: Option<&str>) -> Result<u16, String> {
-    let need = format!("{name} needs a port number from 1 to 65535");
-    match value {
-        None => Err(need),
-        Some(v) => v.parse().ok().filter(|&p| p != 0).ok_or(format!("{need}, not `{v}`")),
-    }
-}
-
-/// The commands' file dialogs open without blocking the frame where the host's backend would:
-/// on macOS as sheets instead of nested modal loops (#511), on Linux through the desktop
-/// portal from a worker thread — a blocking portal dialog leaves the main thread waiting on its
-/// answer without pumping events, so the window stops answering the compositor and the desktop
-/// offers to force-quit it while the Open dialog is up (#1275). Windows' blocking dialog pumps
-/// its own messages, so it keeps [`effectcraft_ui_egui::Hooks::pick_files`] and friends.
-fn install_file_dialogs(app: &mut EffectcraftApp, ctx: &egui::Context) {
-    #[cfg(any(target_os = "macos", target_os = "linux"))]
-    {
-        let ctx = ctx.clone();
-        app.hooks.file_dialog_async = Some(Box::new(move |dialog, done| sheet_dialogs::show(dialog, done, ctx.clone())));
-    }
-    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
-    let _ = (app, ctx);
-}
 
 fn main() -> eframe::Result {
-    // First, so the panic hook and every start-up warning are recorded. Records go to stderr
-    // and the session log; Help ▸ Enable Logging writes through this logger too, and warnings
-    // feed the compatibility report.
-    effectcraft_engine::logging::install();
-    effectcraft_engine::logging::install_panic_hook();
-    let Args { control_port, files, demo, home } = match parse_args(std::env::args().skip(1), std::env::var("EFFECTCRAFT_CONTROL_PORT")) {
-        Ok(Some(args)) => args,
-        Ok(None) => {
-            println!("effectcraft {}", env!("CARGO_PKG_VERSION"));
-            return Ok(());
-        }
-        // Before the session log, preferences and the window: nothing has started yet.
-        Err(e) => {
-            eprintln!("effectcraft: {e}");
-            std::process::exit(1);
-        }
-    };
-    // The session log lives in the settings folder (`Logs/effectcraft.log`, the previous two runs
-    // kept): a launch from the Dock or a menu has no terminal. Opened after the arguments, so
-    // `--version` leaves no file behind; records logged until now are written to it first.
-    if let Some(dir) = effectcraft_host::config_dir() {
-        match effectcraft_engine::logging::attach_session_log(&effectcraft_engine::logging::log_dir(Some(&dir))) {
-            Ok(path) => log::info!("effectcraft {}, log file {}", env!("CARGO_PKG_VERSION"), path.display()),
-            Err(e) => log::warn!("no log file: {e}"),
-        }
-    }
-    // The font menus list the installed fonts: read their names while the window opens.
-    effectcraft_engine::text::fonts::scan_system_in_background();
-    // Wayland shows the window's icon from its desktop entry: an AppImage brings its own.
-    #[cfg(target_os = "linux")]
-    appimage::integrate_from_env(ICON_PNG);
-    // Settings ▸ Startup & Repair ▸ Window Graphics, switched to OpenGL when the last launch
-    // stopped as its window opened (a crashing graphics driver, #243).
-    let mut launch = launch_guard::Launch::begin(effectcraft_host::config_dir().as_deref(), std::env::var_os("WGPU_BACKEND").is_some());
-    let on_gl = launch.backends == Some(eframe::wgpu::Backends::GL);
-    let notice = launch.notice.take();
-    let options = eframe::NativeOptions {
-        viewport: egui::ViewportBuilder::default()
-            .with_title("EffectCraft")
-            .with_inner_size(window::DEFAULT_SIZE)
-            .with_min_inner_size(window::MIN_SIZE)
-            .with_drag_and_drop(true)
-            // Started for an agent (`--control`): open without taking the user's keyboard focus.
-            .with_active(control_port.is_none())
-            .with_icon(app_icon())
-            // Wayland matches the window to `ai.storyteller.effectcraft.desktop` by this ID.
-            .with_app_id(appimage::APP_ID),
-        event_loop_builder: agent_event_loop(control_port.is_some()),
-        wgpu_options: wgpu_options(launch.backends),
-        ..Default::default()
-    };
-    // Start-up milestones go to stderr and the session log, so a window that never appears
-    // shows how far start-up got (#234).
-    log::info!("effectcraft {}: opening the window", env!("CARGO_PKG_VERSION"));
-    // Registered before the event loop starts, so it catches the Finder event that launched us as
-    // well as later ones. Lives until the event loop returns; the app creator only borrows it.
-    #[cfg(target_os = "macos")]
-    let apple_events = apple_events::AppleEvents::install();
-    #[cfg(target_os = "macos")]
-    let apple_events = &apple_events;
-    let result = eframe::run_native(
-        "EffectCraft",
-        options,
-        Box::new(move |cc| {
-            match &cc.wgpu_render_state {
-                Some(rs) => log::info!("graphics: {:?}", rs.adapter.get_info()),
-                None => log::info!("graphics: no wgpu device"),
-            }
-            // This executable owns the shared device's handlers. Install them before
-            // EffectCraft's compositor pipelines are built, without changing handlers from
-            // inside Gpu::new. eframe has already constructed its presentation renderer here.
-            let gpu_failures = effectcraft_ui_egui::gpu_failure::GpuFailureBridge::new(&cc.egui_ctx);
-            if let Some(rs) = &cc.wgpu_render_state {
-                let errors = gpu_failures.clone();
-                rs.device.on_uncaptured_error(std::sync::Arc::new(move |error| {
-                    let message = format!("uncaptured GPU error: {error}");
-                    if errors.report(&message, false) {
-                        log::error!("{message}");
-                    }
-                }));
-                let lost = gpu_failures.clone();
-                rs.device.set_device_lost_callback(move |reason, message| {
-                    lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
-                });
-            }
-            let mut session = effectcraft_host::session();
-            // Lazy open and non-blocking auto-save: footage is checked and auto-saves are
-            // written on background threads (M13.14).
-            session.check_footage_on_open = true;
-            session.autosave.background = true;
-            // Settings, shortcut presets and the crash-recovery sentinel live in the platform
-            // config directory. Agent-driven runs (`--control`) skip crash recovery.
-            if let Some(dir) = effectcraft_host::config_dir() {
-                // WebAssembly effect plug-ins in <config>/Plug-ins load before the menus are built.
-                let plugins = dir.join("Plug-ins");
-                if plugins.is_dir() {
-                    let _ = session.execute("effect.plugins.load", json!({"folder": plugins.to_string_lossy()}));
-                }
-                session.config = Some(std::sync::Arc::new(effectcraft_engine::config::DirConfig::new(dir)));
-            }
-            session.load_settings();
-            // Fit the window to its monitor, or put it back where it was (still hidden).
-            let saved_window = session.config.as_deref().and_then(window::load);
-            let opened = cc.winit_window().map(|w| window::restore(w, saved_window));
-            let recovery = if control_port.is_none() { session.begin_recovery() } else { None };
-            let project = files.iter().find(|f| is_project_file(f)).cloned();
-            if let Some(p) = project {
-                if let Err(e) = session.execute("file.open", json!({"path": p})) {
-                    log::warn!("{p}: {e}");
-                }
-            } else if demo {
-                let _ = session.execute("file.openDemoProject", json!({}));
-            }
-            let media: Vec<String> = files.iter().filter(|f| !is_project_file(f)).cloned().collect();
-            if !media.is_empty()
-                && let Err(e) = session.execute("file.import", json!({"paths": media}))
-            {
-                log::warn!("import: {e}");
-            }
-            let show_home = home.unwrap_or(session.prefs.startup.show_home_on_launch && files.is_empty() && control_port.is_none());
-            // The ScriptUI panels open when the app last quit open again (a failing one is
-            // reported in a toast and forgotten).
-            let _ = session.execute("window.restoreScriptPanels", json!({}));
-            let mut app = EffectcraftApp::new(session);
-            app.set_gpu_failure_bridge(gpu_failures);
-            app.ui.start_screen = show_home;
-            if let Some(n) = notice {
-                app.ui.status = n;
-            }
-            if let Some(r) = recovery {
-                app.offer_recovery(r);
-            }
-            #[cfg(target_os = "linux")]
-            if let Some(note) = wayland_drop_notice(std::env::var_os("WAYLAND_DISPLAY").as_deref(), std::env::var_os("WINIT_UNIX_BACKEND").as_deref())
-                && app.ui.status.is_empty()
-            {
-                app.ui.status = note.into();
-            }
-            app.hooks.pick_files = Some(Box::new(|exts: &[&str]| {
-                rfd::FileDialog::new().add_filter("Media", exts).pick_files().unwrap_or_default().into_iter().map(|p| p.to_string_lossy().to_string()).collect()
-            }));
-            app.hooks.pick_save = Some(Box::new(|name: &str| {
-                rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj"]).set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string())
-            }));
-            // The window closes after a lost graphics device (#591); say why, unless an agent drives
-            // the app through the control channel.
-            if control_port.is_none() {
-                app.hooks.device_lost = Some(Box::new(|saved: Option<Result<String, String>>| {
-                    let saved = match saved {
-                        Some(Ok(path)) => format!("\n\nYour unsaved changes were saved to:\n{path}\n\nReopen EffectCraft to recover them."),
-                        Some(Err(e)) => format!("\n\nYour unsaved changes could not be saved ({e})."),
-                        None => String::new(),
-                    };
-                    rfd::MessageDialog::new()
-                        .set_level(rfd::MessageLevel::Error)
-                        .set_title("EffectCraft: graphics device lost")
-                        .set_description(format!("The graphics driver stopped responding, so EffectCraft has to close.{saved}"))
-                        .show();
-                }));
-            }
-            app.hooks.pick_open_project = Some(Box::new(|| {
-                rfd::FileDialog::new().add_filter("EffectCraft Project", &["ecproj", "ecprojx"]).pick_file().map(|p| p.to_string_lossy().to_string())
-            }));
-            app.hooks.system_theme = appearance::service();
-            app.hooks.audio_device = Some(Box::new(audio_out::open));
-            app.hooks.audio_devices = Some(Box::new(audio_out::devices));
-            app.hooks.pick_folder = Some(Box::new(|| rfd::FileDialog::new().pick_folder().map(|p| p.to_string_lossy().to_string())));
-            app.hooks.pick_save_file = Some(Box::new(|name: &str, ext: &str| {
-                // A default with a folder that exists opens the dialog there.
-                let path = std::path::Path::new(name);
-                // (No filter for a name without an extension, such as a folder to create.)
-                let mut d = if ext.is_empty() { rfd::FileDialog::new() } else { rfd::FileDialog::new().add_filter(ext, &[ext]) };
-                if let Some(dir) = path.parent().filter(|d| d.is_dir()) {
-                    d = d.set_directory(dir);
-                }
-                let file = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
-                d.set_file_name(file).save_file().map(|p| p.to_string_lossy().to_string())
-            }));
-            // The commands' file dialogs don't block the frame where the platform's backend
-            // would (#511, #1275); see [`install_file_dialogs`].
-            install_file_dialogs(&mut app, &cc.egui_ctx);
-            #[cfg(target_os = "macos")]
-            {
-                app.hooks.app_action = Some(Box::new(native_menu::app_action));
-                app.hooks.clipboard_text = Some(Box::new(native_menu::clipboard_text));
-            }
-            if let Some(port) = control_port {
-                disable_app_nap();
-                let rx = control_server::start(port, cc.egui_ctx.clone());
-                app = app.with_control(rx);
-            }
-            log::info!("app created; the window shows after its first frame");
-            Ok(Box::new(Desktop {
-                #[cfg(target_os = "macos")]
-                menu: native_menu::NativeBar::new(&cc.egui_ctx),
-                #[cfg(target_os = "macos")]
-                apple_events: apple_events.connect(&cc.egui_ctx),
-                app,
-                window: window::Tracker::new(opened, saved_window, control_port.is_none()),
-                launch,
-                drawn: false,
-            }))
-        }),
-    );
-    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")));
-        }
-    }
-    result
-}
-
-/// The window's wgpu device asks for the adapter's own limits (textures up to 16384 px)
-/// instead of eframe's fixed set: an older GPU that can't meet those (Intel HD Graphics 5500,
-/// #198) still opens the app, and the compositor then checks what it needs and falls back to
-/// the CPU when the device can't run it. `backends`: Window Graphics' choice, when it makes one.
-fn wgpu_options(backends: Option<eframe::wgpu::Backends>) -> eframe::WgpuConfiguration {
-    use eframe::egui_wgpu::WgpuSetup;
-    let mut config = eframe::WgpuConfiguration::default();
-    if let WgpuSetup::CreateNew(setup) = &mut config.wgpu_setup {
-        if let Some(b) = backends {
-            setup.instance_descriptor.backends = b;
-        }
-        // Nothing draws or dispatches indirectly, so wgpu's check of indirect arguments only costs
-        // a compute shader when the device is created, one that Metal can't compile on an Intel
-        // Iris Pro: the device is lost and the window never opens.
-        // `WGPU_VALIDATION_INDIRECT_CALL=1` turns the check back on.
-        setup.instance_descriptor.flags = setup.instance_descriptor.flags.difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL).with_env();
-        setup.device_descriptor = std::sync::Arc::new(|adapter| device_descriptor(adapter.limits()));
-    }
-    config
-}
-
-/// The window device's descriptor: the adapter's limits ([`device_limits`]) and `MemoryUsage`,
-/// so memory the compositor frees on the shared device goes back to the driver.
-fn device_descriptor(adapter: eframe::wgpu::Limits) -> eframe::wgpu::DeviceDescriptor<'static> {
-    eframe::wgpu::DeviceDescriptor {
-        label: Some("EffectCraft device"),
-        required_limits: device_limits(adapter),
-        memory_hints: eframe::wgpu::MemoryHints::MemoryUsage,
-        ..Default::default()
-    }
-}
-
-/// The limits to request from an adapter that offers `adapter`: all of them, with textures
-/// capped at 16384 px like the headless renderer's device.
-fn device_limits(adapter: eframe::wgpu::Limits) -> eframe::wgpu::Limits {
-    eframe::wgpu::Limits { max_texture_dimension_2d: adapter.max_texture_dimension_2d.min(16384), ..adapter }
-}
-
-/// The window couldn't open (no usable graphics adapter or device): say so instead of quitting
-/// silently, since release builds on Windows have no console. `gl_next`: the launch guard has
-/// the next launch try OpenGL.
-fn startup_failed(e: &eframe::Error, gl_next: bool) {
-    let next = if gl_next { "The next launch will try OpenGL instead. " } else { "" };
-    let message = format!(
-        "EffectCraft couldn't start its window: {e}\n\n{next}Updating the graphics driver often helps. If it keeps happening, please report it at https://github.com/storytold/effectcraft/issues with this message."
-    );
-    // Standard error and the session log; standard error alone when RUST_LOG turned errors off.
-    if log::log_enabled!(log::Level::Error) {
-        log::error!("{message}");
-    } else {
-        eprintln!("effectcraft: {message}");
-    }
-    let _ = rfd::MessageDialog::new()
-        .set_level(rfd::MessageLevel::Error)
-        .set_title("EffectCraft")
-        .set_description(message)
-        .set_buttons(rfd::MessageButtons::Ok)
-        .show();
-}
-
-/// The desktop app: the egui app plus, on macOS, the system menu bar.
-struct Desktop {
-    app: EffectcraftApp,
-    #[cfg(target_os = "macos")]
-    menu: native_menu::NativeBar,
-    /// Documents opened from Finder, the Dock or `open` (macOS).
-    #[cfg(target_os = "macos")]
-    apple_events: apple_events::Opened,
-    /// The window's size and position, saved for the next launch.
-    window: window::Tracker,
-    launch: launch_guard::Launch,
-    /// The first frame was drawn (logged once).
-    drawn: bool,
-}
-
-impl eframe::App for Desktop {
-    fn logic(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) {
-        #[cfg(target_os = "macos")]
-        self.menu.update(&mut self.app, ctx);
-        self.app.logic(ctx, frame);
-    }
-
-    fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
-        self.app.ui(ui, frame);
-        self.window.frame(ui.ctx(), self.app.session.config.as_deref());
-        if !self.drawn {
-            self.drawn = true;
-            log::info!("first frame drawn");
-        }
-        // The launch guard's marker goes once the window has kept drawing for a few seconds: a
-        // driver may still crash as the window is shown and its surface re-created.
-        if let Some(left) = self.launch.frame_drawn() {
-            ui.ctx().request_repaint_after(left);
-        }
-    }
-
-    fn on_exit(&mut self) {
-        self.launch.exited();
-        self.window.save(self.app.session.config.as_deref());
-        self.app.on_exit();
-    }
-
-    fn raw_input_hook(&mut self, ctx: &egui::Context, raw_input: &mut egui::RawInput) {
-        #[cfg(target_os = "macos")]
-        self.apple_events.feed(self.app.is_ready(), ctx, raw_input);
-        self.app.raw_input_hook(ctx, raw_input);
-    }
-}
-
-/// Driven by an agent (`--control`): don't activate the app on launch, so the user's keyboard
-/// focus stays where it is (macOS; winit activates ignoring other apps by default).
-fn agent_event_loop(agent: bool) -> Option<eframe::EventLoopBuilderHook> {
-    #[cfg(target_os = "macos")]
-    if agent {
-        return Some(Box::new(|b| {
-            use winit::platform::macos::EventLoopBuilderExtMacOS;
-            b.with_activate_ignoring_other_apps(false);
-        }));
-    }
-    let _ = agent;
-    None
-}
-
-/// Agents drive the app while its window is covered or on another Space. macOS App Nap would
-/// throttle the event loop then, stalling the control channel; opt out for `--control` runs.
-fn disable_app_nap() {
-    #[cfg(target_os = "macos")]
-    {
-        use objc2_foundation::{NSActivityOptions, NSProcessInfo, NSString};
-        let info = NSProcessInfo::processInfo();
-        let reason = NSString::from_str("EffectCraft control channel");
-        let opts = NSActivityOptions::UserInitiatedAllowingIdleSystemSleep | NSActivityOptions::LatencyCritical;
-        // Leaked on purpose: the activity lasts for the life of the process.
-        std::mem::forget(info.beginActivityWithOptions_reason(opts, &reason));
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::{Args, parse_args};
-    use std::env::VarError;
-
-    fn parse_env(args: &[&str], env_port: Result<String, VarError>) -> Result<Option<Args>, String> {
-        parse_args(args.iter().map(|a| a.to_string()), env_port)
-    }
-
-    fn parse(args: &[&str]) -> Option<Args> {
-        parse_env(args, Err(VarError::NotPresent)).unwrap()
-    }
-
-    fn fails(args: &[&str]) -> String {
-        parse_env(args, Err(VarError::NotPresent)).unwrap_err()
-    }
-
-    #[cfg(target_os = "linux")]
-    #[test]
-    fn wayland_drop_notice_recommends_import_or_xwayland() {
-        use std::ffi::OsStr;
-        let warning = super::wayland_drop_notice(Some(OsStr::new("wayland-0")), None).unwrap();
-        assert!(warning.contains("File ▸ Import") && warning.contains("XWayland"));
-        assert!(super::wayland_drop_notice(None, None).is_none());
-        assert!(super::wayland_drop_notice(Some(OsStr::new("")), None).is_none());
-        assert!(super::wayland_drop_notice(Some(OsStr::new("wayland-0")), Some(OsStr::new("x11"))).is_none());
-    }
-
-    /// The window's wgpu instance skips indirect-call validation (Intel Metal, #1153).
-    #[test]
-    fn the_window_instance_skips_indirect_call_validation() {
-        if std::env::var_os("WGPU_VALIDATION_INDIRECT_CALL").is_some() {
-            return;
-        }
-        let eframe::egui_wgpu::WgpuSetup::CreateNew(setup) = super::wgpu_options(None).wgpu_setup else {
-            panic!("the default setup creates its own instance");
-        };
-        // Intel Metal can't compile the validation's compute shader, and the device is lost.
-        assert!(!setup.instance_descriptor.flags.contains(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL));
-    }
-
-    /// The window's device never asks for more than the adapter offers (#198).
-    #[test]
-    fn device_limits_fit_the_adapter() {
-        use eframe::wgpu::Limits;
-        for adapter in [Limits::downlevel_defaults(), Limits::downlevel_webgl2_defaults(), Limits::default()] {
-            let l = super::device_limits(adapter.clone());
-            assert!(l.check_limits(&adapter), "{l:?}");
-            assert_eq!(l.max_storage_buffers_per_shader_stage, adapter.max_storage_buffers_per_shader_stage);
-        }
-        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
-        assert_eq!(super::device_limits(big).max_texture_dimension_2d, 16384);
-    }
-
-    /// The window device asks for `MemoryUsage`: the adapter's limits, unchanged.
-    #[test]
-    fn the_window_device_favours_memory_usage() {
-        use eframe::wgpu::Limits;
-        let big = Limits { max_texture_dimension_2d: 32768, ..Limits::default() };
-        let d = super::device_descriptor(big);
-        assert!(matches!(d.memory_hints, eframe::wgpu::MemoryHints::MemoryUsage));
-        assert_eq!(d.required_limits.max_texture_dimension_2d, 16384);
-        assert_eq!(d.label, Some("EffectCraft device"));
-    }
-
-    /// A positional .ecprojx is a project file, not an unsupported media import (#528).
-    #[test]
-    fn launch_classifies_xml_and_json_projects_as_projects() {
-        for path in ["Copy.ecprojx", "/tmp/Scene.ecproj", "C:\\\\work\\\\Shot.ECPROJX"] {
-            assert!(super::is_project_file(path), "{path}");
-        }
-        for path in ["scene.ecprojx.png", "scene.mp4", "scene", "copy.ecprojx.bak"] {
-            assert!(!super::is_project_file(path), "{path}");
-        }
-        let files = ["scene.ecprojx".to_string(), "clip.mov".to_string()];
-        assert_eq!(files.iter().find(|f| super::is_project_file(f)).map(String::as_str), Some("scene.ecprojx"));
-        assert_eq!(files.iter().filter(|f| !super::is_project_file(f)).map(String::as_str).collect::<Vec<_>>(), vec!["clip.mov"],);
-    }
-
-    /// Launching without a project opens an empty project, not the demo (#204).
-    #[test]
-    fn no_arguments_start_an_empty_project() {
-        assert_eq!(parse(&[]), Some(Args::default()));
-        assert!(parse(&["--demo"]).is_some_and(|a| a.demo));
-        assert!(parse(&["--demo", "--empty"]).is_some_and(|a| !a.demo));
-        let a = parse(&["--control", "9877", "--home", "a.ecproj"]).unwrap_or_default();
-        assert_eq!((a.control_port, a.home, a.files), (Some(9877), Some(true), vec!["a.ecproj".to_string()]));
-        assert_eq!(parse(&["--version"]), None);
-        assert_eq!(parse_env(&[], Ok("9000".into())).unwrap().map(|a| a.control_port), Some(Some(9000)));
-    }
-
-    /// A missing, non-numeric or zero `--control` value stops the launch instead of starting
-    /// without a control server, and the argument after `--control` isn't reinterpreted (#997).
-    #[test]
-    fn control_needs_a_port_from_1_to_65535() {
-        let need = "--control needs a port number from 1 to 65535";
-        assert_eq!(fails(&["--control"]), need);
-        assert_eq!(fails(&["--control", "--demo"]), format!("{need}, not `--demo`"));
-        assert_eq!(fails(&["--control", "--version"]), format!("{need}, not `--version`"));
-        for bad in ["abc", "0", "70000", "-1", ""] {
-            assert_eq!(fails(&["--control", bad]), format!("{need}, not `{bad}`"));
-            assert_eq!(fails(&[&format!("--control={bad}")]), format!("{need}, not `{bad}`"));
-        }
-        assert_eq!(parse(&["--control=9877"]).map(|a| a.control_port), Some(Some(9877)));
-        assert_eq!(parse(&["--control", "65535"]).map(|a| a.control_port), Some(Some(65535)));
-    }
-
-    /// An unknown option is an error, not a file to import (#997); `--` ends the options.
-    #[test]
-    fn unknown_options_are_errors_not_files() {
-        for bad in ["--automation-read-root", "--automation-write-root=/tmp", "-h", "--Demo", "--demo=yes"] {
-            assert_eq!(fails(&[bad, "/tmp/folder"]), format!("unknown option `{bad}`"));
-        }
-        // Parsed left to right: an error before `--version` wins.
-        assert_eq!(fails(&["--nope", "--version"]), "unknown option `--nope`");
-        // Finder's process serial number is ignored, not imported or refused.
-        assert_eq!(parse(&["-psn_0_12345"]).map(|a| a.files), Some(vec![]));
-        let a = parse(&["clip.mov", "-", "--", "--demo", "-name.png"]).unwrap_or_default();
-        assert_eq!(a.files, ["clip.mov", "-", "--demo", "-name.png"]);
-        assert!(!a.demo);
-    }
-
-    /// `--control` wins over `EFFECTCRAFT_CONTROL_PORT`; the variable is checked only when it is
-    /// used, so a bad value doesn't stop `--control` or `--version`.
-    #[test]
-    fn control_port_variable_is_checked_when_used() {
-        let need = "EFFECTCRAFT_CONTROL_PORT needs a port number from 1 to 65535";
-        assert_eq!(parse_env(&[], Ok("abc".into())).unwrap_err(), format!("{need}, not `abc`"));
-        assert_eq!(parse_env(&[], Ok("0".into())).unwrap_err(), format!("{need}, not `0`"));
-        assert_eq!(parse_env(&[], Ok(String::new())), Ok(Some(Args::default())));
-        assert_eq!(parse_env(&["--control", "9877"], Ok("abc".into())).unwrap().map(|a| a.control_port), Some(Some(9877)));
-        assert_eq!(parse_env(&["--version"], Ok("abc".into())), Ok(None));
-        #[cfg(unix)]
-        {
-            use std::os::unix::ffi::OsStringExt;
-            let not_utf8 = VarError::NotUnicode(std::ffi::OsString::from_vec(vec![0x39, 0xff]));
-            assert_eq!(parse_env(&[], Err(not_utf8)).unwrap_err(), format!("{need}, not `9\u{fffd}`"));
-        }
-    }
-
-    /// #1275: a blocking desktop-portal dialog on Linux leaves the window not answering the
-    /// compositor while it is open, so the commands' file dialogs go through the non-blocking
-    /// host hook there (as they do on macOS, #511). Windows' dialog pumps its own messages.
-    #[test]
-    fn commands_file_dialogs_do_not_block_the_frame() {
-        let mut app = effectcraft_ui_egui::EffectcraftApp::new(effectcraft_engine::Session::default());
-        super::install_file_dialogs(&mut app, &egui::Context::default());
-        #[cfg(any(target_os = "macos", target_os = "linux"))]
-        assert!(app.hooks.file_dialog_async.is_some(), "the commands' file dialogs must not block the frame");
-        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
-        assert!(app.hooks.file_dialog_async.is_none(), "the blocking dialog pumps its own messages here");
-    }
+    effectcraft::run()
 }
diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs
--- a/crates/engine/src/prefs.rs
+++ b/crates/engine/src/prefs.rs
@@ -184,7 +184,7 @@ page!(Appearance {
     favorite_themes: Vec<String> = Vec::new(),
     /// User interface brightness, -1 (darker) … 1 (lighter).
     brightness: f64 = 0.0,
-    /// The size of the whole interface in percent (75–200), on top of the display's own scale.
+    /// The size of the whole interface in percent (50–200), on top of the display's own scale.
     ui_scale: u32 = 100,
     use_label_color_for_handles: bool = true,
     use_label_color_for_tabs: bool = true,
@@ -641,7 +641,7 @@ impl Prefs {
             _ => {}
         }
         self.appearance.brightness = self.appearance.brightness.clamp(-1.0, 1.0);
-        self.appearance.ui_scale = self.appearance.ui_scale.clamp(75, 200);
+        self.appearance.ui_scale = self.appearance.ui_scale.clamp(50, 200);
         let defaults = default_labels();
         self.labels.truncate(defaults.len());
         for d in defaults.iter().skip(self.labels.len()) {
@@ -1265,7 +1265,7 @@ pub fn pages() -> Vec<Page> {
                 s("appearance.lightTheme", "Light Theme", Kind::Choice(LIGHT_THEMES), true),
                 s("appearance.darkTheme", "Dark Theme", Kind::Choice(DARK_THEMES), true),
                 s("appearance.brightness", "Brightness", Kind::Slider(-1.0, 1.0), true),
-                s("appearance.uiScale", "UI Scale", Kind::Int(75, 200, "%"), true),
+                s("appearance.uiScale", "UI Scale", Kind::Int(50, 200, "%"), true),
                 Section("Labels and Colors"),
                 s("appearance.useLabelColorForHandles", "Use Label Color for Layer Handles and Paths", B, true),
                 s("appearance.useLabelColorForTabs", "Use Label Color for Related Tabs", B, true),
diff --git a/crates/engine/src/tests_settings.rs b/crates/engine/src/tests_settings.rs
--- a/crates/engine/src/tests_settings.rs
+++ b/crates/engine/src/tests_settings.rs
@@ -87,10 +87,10 @@ fn prefs_set_validates_keys_types_and_ranges() {
     assert!(p.reset(Some("bogus")).is_err());
 }
 
-/// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 75–200;
+/// Settings ▸ Appearance ▸ UI Scale (#284): a percentage, 100 by default, kept within 50–200;
 /// the dialog types or drags any whole percentage in that range (#682).
 #[test]
-fn ui_scale_is_a_percentage_from_75_to_200() {
+fn ui_scale_is_a_percentage_from_50_to_200() {
     let mut p = Prefs::default();
     assert_eq!(p.appearance.ui_scale, 100);
     p.set("appearance.uiScale", json!("150")).unwrap();
@@ -99,9 +99,9 @@ fn ui_scale_is_a_percentage_from_75_to_200() {
     p.set("appearance.uiScale", json!(500)).unwrap();
     assert_eq!(p.appearance.ui_scale, 200);
     p.set("appearance.uiScale", json!(10)).unwrap();
-    assert_eq!(p.appearance.ui_scale, 75);
+    assert_eq!(p.appearance.ui_scale, 50);
     let page = crate::prefs::pages().into_iter().find(|pg| pg.id == "appearance").unwrap();
-    let field = crate::prefs::Item::Setting { key: "appearance.uiScale", label: "UI Scale", kind: crate::prefs::Kind::Int(75, 200, "%"), live: true };
+    let field = crate::prefs::Item::Setting { key: "appearance.uiScale", label: "UI Scale", kind: crate::prefs::Kind::Int(50, 200, "%"), live: true };
     assert!(page.items.contains(&field), "Settings ▸ Appearance offers it");
     // Older settings files without it get the default.
     assert_eq!(Prefs::from_json(r#"{"version": 2, "appearance": {"theme": "light"}}"#).appearance.ui_scale, 100);
diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs
--- a/crates/ui-egui/src/lib.rs
+++ b/crates/ui-egui/src/lib.rs
@@ -169,6 +169,24 @@ pub struct Hooks {
     /// a drag over the window (#511), and on Linux a blocking desktop-portal dialog never pumps
     /// events, so the compositor marks the window as not responding (#1275).
     pub file_dialog_async: Option<Box<dyn Fn(FileDialog, DialogDone)>>,
+    /// The app-private storage directory on Android (`AndroidApp::internal_data_path`), where the
+    /// in-app Save / Open Project prompts read and write. `None` elsewhere (or when it could not
+    /// be resolved): the commands fail with "no file dialog available", as before.
+    #[cfg(target_os = "android")]
+    pub app_files_dir: Option<std::path::PathBuf>,
+    /// Android's shared internal-storage root (`/storage/emulated/0`), offered as a second
+    /// browse root beside the app-private directory once "All files access" is granted.
+    /// `None` elsewhere (or when it could not be resolved).
+    #[cfg(target_os = "android")]
+    pub shared_storage_root: Option<std::path::PathBuf>,
+    /// Whether Android's "All files access" (`MANAGE_EXTERNAL_STORAGE`) is currently granted.
+    /// `None` elsewhere.
+    #[cfg(target_os = "android")]
+    pub shared_storage_granted: Option<Box<dyn Fn() -> bool>>,
+    /// Asks the user to grant "All files access" (the system settings page on Android 11+, the
+    /// permission dialog before that). `None` elsewhere.
+    #[cfg(target_os = "android")]
+    pub shared_storage_grant: Option<Box<dyn Fn()>>,
     /// Tells the user the graphics device was lost and the window is closing, once. `saved` is
     /// the auto-save of unsaved changes (`None`: nothing was unsaved), its path or why it failed.
     pub device_lost: Option<Box<dyn Fn(Option<Result<String, String>>)>>,
@@ -1626,6 +1644,9 @@ impl EffectcraftApp {
         self.run_dialog_answers();
         self.auto.begin_frame();
         self.frames.set_context(&ctx);
+        // A touch screen's bars: a fingertip has to land on a scroll bar (widgets.rs), and a
+        // theme change rebuilds the styles, so the widening is re-checked every frame.
+        widgets::touch_scroll_target(&ctx);
         if self.session.render_job.is_some() {
             self.session.poll_render();
             ctx.request_repaint_after(std::time::Duration::from_millis(100));
diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs
--- a/crates/ui-egui/src/panels/settings.rs
+++ b/crates/ui-egui/src/panels/settings.rs
@@ -119,7 +119,7 @@ fn page_ui(app: &mut EffectcraftApp, ui: &mut egui::Ui, page: &Page, cur: &Value
                                 Kind::Int(lo, hi, unit) => {
                                     let mut x = v.as_i64().unwrap_or(0);
                                     // A typed number applies once entered: typing 85 into UI Scale
-                                    // doesn't resize the interface to 75 % at its first digit.
+                                    // doesn't resize the interface to 50 % at its first digit.
                                     let drag = egui::DragValue::new(&mut x).range(lo..=hi).speed(0.25).update_while_editing(false);
                                     let r = ui.add(crate::widgets::drag_value(drag));
                                     if !unit.is_empty() {
diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs
--- a/crates/ui-egui/src/theme.rs
+++ b/crates/ui-egui/src/theme.rs
@@ -781,15 +781,21 @@ mod tests {
         ctx.run_ui(Default::default(), |_| {}).textures_delta.clear();
         for family in [FontFamily::Proportional, FontFamily::Monospace, FontFamily::Name("medium".into()), FontFamily::Name("semibold".into())] {
             let id = FontId::new(12.0, family);
-            // Not has_glyph: it compares face keys, so Inter's `•` reads as missing when no face in
-            // the stack has egui's `◻` and Inter's `?` becomes the replacement, as happens once
-            // another test has loaded a system face without `◻` (the fallback tries loaded faces first).
-            let uv = |s: String| ctx.fonts_mut(|f| f.layout_no_wrap(s, id.clone(), egui::Color32::WHITE).rows[0].glyphs[0].uv_rect);
-            let missing = uv("\u{10ffff}".into());
-            for c in "▸•›·▶◀●○⚠…×".chars() {
-                let drawn = uv(c.to_string());
-                assert_ne!((drawn.min, drawn.max), (missing.min, missing.max), "{c} has no glyph in {id:?}");
-            }
+            ctx.fonts_mut(|fonts| {
+                // Not `has_glyph`: it compares face keys and reports a false negative whenever a
+                // real glyph shares the replacement face, as `script_font_tests` notes. It called
+                // every one of these symbols missing while all of them drew, and which way it went
+                // turned on what the machine had installed. Compare the atlas entry like that
+                // module does, which is what "has a glyph" has to mean anyway.
+                let missing = fonts.layout_no_wrap("\u{10ffff}".into(), id.clone(), egui::Color32::WHITE);
+                let missing_uv = missing.rows[0].glyphs[0].uv_rect;
+                for c in "▸•›·▶◀●○⚠…×".chars() {
+                    let rendered = fonts.layout_no_wrap(c.to_string(), id.clone(), egui::Color32::WHITE);
+                    let uv = rendered.rows[0].glyphs[0].uv_rect;
+                    assert!(uv.size.x > 0.0 && uv.size.y > 0.0, "{c} draws nothing in {id:?}");
+                    assert_ne!((uv.min, uv.max), (missing_uv.min, missing_uv.max), "{c} draws a missing-glyph box in {id:?}");
+                }
+            });
         }
     }
 
diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs
--- a/crates/ui-egui/src/widgets.rs
+++ b/crates/ui-egui/src/widgets.rs
@@ -423,7 +423,13 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro
     let thumb_len = (len * len / (len + max.max(0.0)).max(1.0)).max(16.0).min(len).max(0.0);
     let travel = (len - thumb_len).max(1.0);
     let thumb_at = |s: f32| track.min[a] + (len - thumb_len) * if max > 0.0 { (s / max).clamp(0.0, 1.0) } else { 0.0 };
-    let resp = ui.interact(track.expand2(if vertical { vec2(3.0, 0.0) } else { vec2(0.0, 3.0) }), egui::Id::new(key), Sense::drag());
+    // The hit a finger lands on: a 4-point track becomes a 20-point target, widened only across
+    // the bar's thin axis (#1). A mouse always had this; a touch never could. The long axis stays
+    // put because it reaches neighbouring UI: a horizontal bar's expanded x would put its hit
+    // directly under the empty list area beside it, and egui's hit test then claims the click for
+    // the drag-only bar and the panel's own right-click menu never opens (kittest caught this).
+    let hit = track.expand2(if vertical { vec2(8.0, 0.0) } else { vec2(0.0, 8.0) });
+    let resp = ui.interact(hit, egui::Id::new(key), Sense::drag());
     let pressed = resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.any_pressed());
     let mut s = scroll;
     if let Some(p) = resp.interact_pointer_pos().filter(|p| pressed && !(thumb_at(scroll)..=thumb_at(scroll) + thumb_len).contains(&p[a])) {
@@ -447,9 +453,9 @@ pub fn scroll_bar(ui: &mut Ui, auto: &mut Registry, key: &str, track: Rect, scro
 }
 
 /// Vertical scrolling for a panel that lays out its own rows: [`PanelScroll::begin`] gives the
-/// offset to draw them at (moved by the mouse wheel over `area`), [`PanelScroll::end`] takes the
-/// rows' height and draws a [`scroll_bar`] on the right edge while they overflow, so every
-/// panel can be scrolled without a wheel or touchpad (#271).
+/// offset to draw them at (moved by the mouse wheel or a finger drag over `area`),
+/// [`PanelScroll::end`] takes the rows' height and draws a [`scroll_bar`] on the right edge while
+/// they overflow, so every panel can be scrolled without a wheel or touchpad (#271, #1).
 pub struct PanelScroll {
     id: egui::Id,
     area: Rect,
@@ -464,6 +470,20 @@ impl PanelScroll {
         if ui.rect_contains_pointer(area) {
             offset -= ui.input(|i| i.smooth_scroll_delta.y);
         }
+        // A drag over the panel moves the offset the same way, so a finger has a way in where
+        // there is neither a wheel nor a trackpad (#1). Registered before the rows are laid out,
+        // so whatever the panel draws on top — fields, buttons, rows that drag to apply — wins
+        // the pointer as it always did, and this only takes the drags that start on the panel's
+        // own empty space.
+        // Inset a few points from the panel's edges: the dock's resize gutters reach into the
+        // content rect (their hit is the gap expanded by 3), and they register before the panel
+        // does — a finger aiming at a divider must never land on the scroll surface instead.
+        let drag = ui.interact(area.shrink(4.0), id.with("panel-scroll-drag"), Sense::drag());
+        if drag.dragged() {
+            // With the finger: the content follows it, so down moves the offset towards zero.
+            offset -= drag.drag_delta().y;
+            ui.ctx().request_repaint();
+        }
         Self { id, area, offset: offset.clamp(0.0, max.max(0.0)) }
     }
 
@@ -664,7 +684,7 @@ pub fn any_menu_open(ctx: &egui::Context) -> bool {
 /// and the scroll bar are how a normally opened menu scrolls.
 pub fn menu_scroll<R>(ui: &mut Ui, browsing: bool, add: impl FnOnce(&mut Ui) -> R) -> R {
     let max_h = ui.ctx().content_rect().height() - 40.0;
-    let out = egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add);
+    let out = menu_scroll_area(max_h).show(ui, add);
     edge_scroll(ui, &out, browsing);
     out.inner
 }
@@ -731,6 +751,80 @@ fn edge_scroll<R>(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput
     }
 }
 
+/// The scroll area every menu body runs in: `max_h` points tall, and draggable to scroll with any
+/// pointer. See [`menu_drag`] for why the drag is forced on.
+fn menu_scroll_area(max_h: f32) -> egui::ScrollArea {
+    egui::ScrollArea::vertical()
+        .scroll_source(egui::containers::scroll_area::ScrollSource { drag: menu_drag(), ..egui::containers::scroll_area::ScrollSource::ALL })
+        .max_height(max_h)
+        .min_scrolled_height(max_h)
+}
+
+/// How a menu's contents respond to a drag: always, with the scroll bar and the wheel too.
+///
+/// egui's own default (`DragScroll::OnTouch`) gates drag-to-scroll behind
+/// `InputState::has_touch_screen`, and measured on a phone in landscape (2311DRK48G, 1220x2712)
+/// that gate is closed for menus: an 850 pt finger drag inside the Edit menu left it byte-for-byte
+/// unchanged, so the rows below the fold (Select All through Preferences, the last of 34) could not
+/// be reached at all (#269). Forcing the drag on opened it — the same drag now walks the menu from
+/// "Can't Undo" down to "Preferences" and back again.
+///
+/// The same drag did scroll the file browser's plain `ScrollArea`, which keeps egui's default, so
+/// touch input does reach the app and the two areas differ in more than this one setting:
+/// `ScrollSource::ALL` enables several sources of which the drag is only one, and the list may have
+/// scrolled by a fling rather than by dragging. That is left open on purpose. What is measured is
+/// the before/after on the menu itself, and a menu must never depend on that detection: if it is
+/// taller than the window it has to scroll, whatever pointer the user is holding.
+fn menu_drag() -> egui::containers::scroll_area::DragScroll {
+    egui::containers::scroll_area::DragScroll::Always
+}
+
+/// A fingertip cannot aim at a 6-point scroll bar: on a touch screen, widen the bar's whole
+/// `bar_width` (which is also the width inside which egui hit-tests the bar) to
+/// [`TOUCH_BAR_TARGET`] points.
+///
+/// The themes build `ScrollStyle::solid` with a 6-point `bar_width`; the app's own
+/// [`scroll_bar`] widens only its drawn hit. Every panel that lays out its own rows draws that
+/// one, but every `ScrollArea` (menus, the file browser, Settings' page list) hit-tests through
+/// this style value, and a finger misses the 6-point band — so on a touch screen it is widened.
+/// Because the styles are replaced outright whenever a theme is applied (`theme::apply_visuals`),
+/// widening is re-checked every frame rather than remembered once.
+///
+/// `bar_width` is also the width a *solid* bar draws, so widening it alone drew 24-point bars
+/// everywhere a stock `ScrollArea` scrolled. The bar therefore takes egui's floating shape: it
+/// rest-draws `floating_width` as thick as the theme drew it, reserves the sliver the solid bar
+/// reserved (the layout does not reflow), and expands to `bar_width` only while the finger works
+/// it — egui senses the full `bar_width` either way, at rest and drawn thin alike.
+pub fn touch_scroll_target(ctx: &egui::Context) {
+    if !(cfg!(target_os = "android") || ctx.input(|i| i.has_touch_screen())) {
+        return;
+    }
+    let narrower = |s: &egui::Style| s.spacing.scroll.bar_width < TOUCH_BAR_TARGET;
+    let narrow = narrower(&ctx.global_style()) || [egui::Theme::Dark, egui::Theme::Light].iter().any(|t| narrower(&ctx.style_of(*t)));
+    if !narrow {
+        return;
+    }
+    ctx.all_styles_mut(|s| {
+        let sc = &mut s.spacing.scroll;
+        let resting = sc.bar_width;
+        sc.bar_width = sc.bar_width.max(TOUCH_BAR_TARGET);
+        sc.floating = true;
+        sc.floating_width = sc.floating_width.max(resting);
+        sc.floating_allocated_width = sc.floating_allocated_width.max(sc.bar_inner_margin + resting + sc.bar_outer_margin);
+        // Thin like the solid bar was while the finger is elsewhere:
+        sc.dormant_background_opacity = 1.0;
+        sc.dormant_handle_opacity = 1.0;
+        sc.active_background_opacity = 1.0;
+        sc.active_handle_opacity = 1.0;
+        // Translucent when the finger expands it over the content it scrolls.
+        sc.interact_background_opacity = 0.6;
+        sc.interact_handle_opacity = 0.6;
+    });
+}
+
+/// The scroll bar's interaction target on a touch screen, in points.
+const TOUCH_BAR_TARGET: f32 = 24.0;
+
 /// egui's menu style with no gap between the rows: the gap the app's item spacing left between
 /// two entries highlighted neither and swallowed a click there. The rows grow by that gap, so a
 /// menu keeps its size.
@@ -853,4 +947,212 @@ mod tests {
             assert!((r.center().y - field.center().y).abs() < 0.5 && r.height() >= h - 2.5, "{h}: {r:?} in {field:?}");
         }
     }
+
+    /// #269: a menu taller than its window has to be scrollable, and the Edit menu on a phone in
+    /// landscape is exactly that — 34 rows against the 21 that fit, with Select All, Label, Purge,
+    /// Templates, Keyboard Shortcuts and Preferences below the fold.
+    #[test]
+    fn a_menu_taller_than_the_window_scrolls_by_drag() {
+        // Measured on the phone (2311DRK48G, landscape): with egui's default `DragScroll::OnTouch`
+        // an 850 pt finger drag left the Edit menu byte-for-byte unchanged, while the same drag
+        // scrolled the file browser's plain ScrollArea. Forcing the drag on is what opened the menu.
+        assert_eq!(menu_drag(), egui::containers::scroll_area::DragScroll::Always, "menus must drag-scroll regardless of egui's touch-screen detection");
+
+        // And the layout really does overflow: in a 300 pt window (a phone in landscape is barely
+        // taller) the last of 42 rows starts below the bottom edge, so without scrolling the menu
+        // simply runs off the screen.
+        let ctx = egui::Context::default();
+        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 300.0));
+        let mut last_y = f32::MAX;
+        let input = egui::RawInput { screen_rect: Some(screen), ..Default::default() };
+        let mut out = ctx.run_ui(input, |ui| {
+            menu_scroll(ui, false, |ui| {
+                for i in 0..42 {
+                    let r = ui.label(format!("row {i}"));
+                    if i == 41 {
+                        last_y = r.rect.min.y;
+                    }
+                }
+            });
+        });
+        out.textures_delta.clear();
+        assert!(last_y > screen.height(), "the test menu must overflow its window: last row at {last_y} pt of {}", screen.height());
+    }
+
+    /// #1: the panels have neither a wheel nor a trackpad, so a drag over a panel's own empty
+    /// space has to move it. With only `smooth_scroll_delta` the offset ignored a finger
+    /// completely — the right sidebar and every `PanelScroll` panel was frozen past the fold.
+    #[test]
+    fn a_panel_scrolls_when_a_pointer_drags_its_empty_space() {
+        let ctx = egui::Context::default();
+        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(300.0, 400.0));
+        let area = screen;
+        let id = egui::Id::new("drag-scroll-test");
+        let offset = std::cell::Cell::new(f32::NAN);
+        let run = |events: Vec<egui::Event>| {
+            // 500 points of rows in a 400 pt panel: 100 points of scroll.
+            let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
+                let scroll = PanelScroll::begin(ui, id, area);
+                offset.set(scroll.offset);
+                scroll.end(ui, &mut Registry::default(), "drag-scroll-test", 500.0, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark));
+            });
+            out.textures_delta.clear();
+        };
+        let p = |y: f32| egui::Event::PointerMoved(pos2(150.0, y));
+        let press = |y: f32, down: bool| egui::Event::PointerButton {
+            pos: pos2(150.0, y),
+            button: egui::PointerButton::Primary,
+            pressed: down,
+            modifiers: egui::Modifiers::NONE,
+        };
+        let at = |want: f32, what: &str| {
+            let got = offset.get();
+            assert!((got - want).abs() < 0.5, "{what}: offset {got}, wanted {want}");
+        };
+        run(vec![p(300.0)]);
+        run(vec![press(300.0, true)]);
+        // The content follows the finger: up goes to later rows, down back towards the first.
+        run(vec![p(260.0)]);
+        at(40.0, "the drag moved the offset with the finger");
+        run(vec![p(220.0)]);
+        at(80.0, "and keeps following it");
+        run(vec![p(240.0)]);
+        at(60.0, "down too");
+        // Clamped at both ends, never past the rows.
+        run(vec![p(40.0)]);
+        at(100.0, "clamped at the end of the rows");
+        run(vec![press(40.0, false)]);
+        run(vec![p(250.0)]);
+        run(vec![press(250.0, true)]);
+        run(vec![p(390.0)]);
+        at(0.0, "and clamped back at the first row");
+        run(vec![press(390.0, false)]);
+    }
+
+    /// #1: the scroll bar's track is 4 points wide — a mouse always landed on its 10-point hit, a
+    /// finger could not. The drag it registers now reaches 8 points past each side, so a press
+    /// well beside the track scrolls the content (it landed on the panel underneath before).
+    #[test]
+    fn the_scroll_bar_reaches_past_its_track_for_a_finger() {
+        let ctx = egui::Context::default();
+        let screen = Rect::from_min_size(pos2(0.0, 0.0), vec2(200.0, 400.0));
+        // A horizontal bar: the widened hit runs past the track's top and bottom edges, which is
+        // where a press beside a vertical bar's track lands too (the same expansion, other axis).
+        let track = Rect::from_min_max(pos2(10.0, 300.0), pos2(190.0, 304.0));
+        // ~12 points past the track and past the thumb, so the press is a track press that
+        // centres the thumb there. The old 3-point expansion (with egui's own hit slop) reached
+        // only about 315, so before the widening this press landed on the panel and did nothing.
+        let at = pos2(170.0, 316.0);
+        let scrolled = std::cell::Cell::new(f32::NAN);
+        let run = |events: Vec<egui::Event>| {
+            let mut out = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), events, ..Default::default() }, |ui| {
+                scrolled.set(scroll_bar(
+                    ui,
+                    &mut Registry::default(),
+                    "bar-reach-test",
+                    track,
+                    0.0,
+                    100.0,
+                    &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark),
+                ));
+            });
+            out.textures_delta.clear();
+        };
+        run(vec![egui::Event::PointerMoved(at)]);
+        run(vec![egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: egui::Modifiers::NONE }]);
+        assert!(scrolled.get() > 0.0, "a press 12 points past the track centres the thumb there: {}", scrolled.get());
+    }
+
+    /// A touch screen's scroll bars: `ScrollArea` hit-tests through the style's `bar_width`
+    /// (6 points here), which a finger cannot land on, so [`touch_scroll_target`] widens it —
+    /// and keeps it widened after a theme change rebuilds the styles.
+    #[test]
+    fn a_touch_screen_widens_the_scroll_bar_target() {
+        let bar_width = |events: Vec<egui::Event>| {
+            let ctx = egui::Context::default();
+            let idle = ctx.style_of(egui::Theme::Dark).spacing.scroll.bar_width;
+            ctx.begin_pass(egui::RawInput { events, ..Default::default() });
+            super::touch_scroll_target(&ctx);
+            let width = ctx.style_of(egui::Theme::Dark).spacing.scroll.bar_width;
+            ctx.end_pass().drop_without_applying_deltas();
+            (idle, width)
+        };
+        let (idle, plain) = bar_width(Vec::new());
+        assert_eq!(plain, idle, "a window with no touch screen keeps the theme's bar");
+        let touch = egui::Event::Touch {
+            device_id: egui::TouchDeviceId(3),
+            id: egui::TouchId(1),
+            phase: egui::TouchPhase::Start,
+            pos: egui::pos2(10.0, 10.0),
+            force: None,
+        };
+        let (_, wide) = bar_width(vec![touch]);
+        assert!(wide >= super::TOUCH_BAR_TARGET, "a finger has to be able to land on the bar: {wide}");
+        assert!(wide > plain, "the target only ever widens: {plain} -> {wide}");
+
+        // A theme switch replaces each style outright; the wider target has to come back.
+        let ctx = egui::Context::default();
+        let touch = egui::Event::Touch {
+            device_id: egui::TouchDeviceId(3),
+            id: egui::TouchId(1),
+            phase: egui::TouchPhase::Start,
+            pos: egui::pos2(10.0, 10.0),
+            force: None,
+        };
+        ctx.begin_pass(egui::RawInput { events: vec![touch.clone()], ..Default::default() });
+        super::touch_scroll_target(&ctx);
+        assert!(ctx.style_of(egui::Theme::Dark).spacing.scroll.bar_width >= super::TOUCH_BAR_TARGET);
+        ctx.end_pass().drop_without_applying_deltas();
+        crate::theme::apply_visuals(&ctx, &crate::theme::Tokens::for_kind(crate::theme::ThemeKind::Dark));
+        assert!(ctx.style_of(egui::Theme::Dark).spacing.scroll.bar_width < super::TOUCH_BAR_TARGET, "the theme rebuild starts from the thin bar again");
+        ctx.begin_pass(egui::RawInput { events: vec![touch], ..Default::default() });
+        super::touch_scroll_target(&ctx);
+        let after_theme = ctx.style_of(egui::Theme::Dark).spacing.scroll.bar_width;
+        ctx.end_pass().drop_without_applying_deltas();
+        assert!(after_theme >= super::TOUCH_BAR_TARGET, "a theme switch must not take the touch target away: {after_theme}");
+    }
+
+    /// The widened target must not widen the drawn bar: egui uses `bar_width` both for the hit
+    /// test and (on a solid bar) for drawing, so the solid widening drew 24-point bars
+    /// everywhere a stock `ScrollArea` scrolled (menus, the file browser, Settings…). On a
+    /// touch screen the bar therefore takes egui's floating shape: it rest-draws
+    /// `floating_width` as thick as the theme drew it, reserves the sliver the solid bar
+    /// reserved, and expands to `bar_width` only while the finger works it (a hover too).
+    #[test]
+    fn a_touch_screen_draws_the_scroll_bar_thin_between_touches() {
+        let touch = egui::Event::Touch {
+            device_id: egui::TouchDeviceId(3),
+            id: egui::TouchId(1),
+            phase: egui::TouchPhase::Start,
+            pos: egui::pos2(10.0, 10.0),
+            force: None,
+        };
+        let ctx = egui::Context::default();
+        let before = ctx.style_of(egui::Theme::Dark).spacing.scroll;
+        ctx.begin_pass(egui::RawInput { events: vec![touch], ..Default::default() });
+        super::touch_scroll_target(&ctx);
+        let after = ctx.style_of(egui::Theme::Dark).spacing.scroll;
+        ctx.end_pass().drop_without_applying_deltas();
+
+        assert!(after.floating, "the bar takes egui's floating (rest-thin) shape");
+        assert_eq!(after.floating_width, before.bar_width, "the bar rests as thick as the theme drew it: {}", before.bar_width);
+        assert_eq!(
+            after.floating_allocated_width,
+            before.bar_inner_margin + before.bar_width + before.bar_outer_margin,
+            "the layout keeps the sliver the solid bar reserved, so content does not reflow"
+        );
+        assert_eq!(after.dormant_background_opacity, 1.0, "the resting bar is as visible as the solid one was");
+        assert_eq!(after.dormant_handle_opacity, 1.0);
+        assert!(after.interact_handle_opacity < 1.0, "the expanded bar is translucent over the content while the finger drags");
+
+        // No touch screen: every field untouched (the floating branch is touch-only).
+        let ctx = egui::Context::default();
+        let before = ctx.style_of(egui::Theme::Dark).spacing.scroll;
+        ctx.begin_pass(Default::default());
+        super::touch_scroll_target(&ctx);
+        let after = ctx.style_of(egui::Theme::Dark).spacing.scroll;
+        ctx.end_pass().drop_without_applying_deltas();
+        assert_eq!(after.floating, before.floating);
+        assert_eq!(after.bar_width, before.bar_width);
+    }
 }
diff --git a/crates/ui-egui/tests/it/shared_docking_ui.rs b/crates/ui-egui/tests/it/shared_docking_ui.rs
--- a/crates/ui-egui/tests/it/shared_docking_ui.rs
+++ b/crates/ui-egui/tests/it/shared_docking_ui.rs
@@ -374,6 +374,9 @@ fn accordion_resize_routes_pointer_and_keyboard_without_changing_closed_entries(
     }
     let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") };
     assert_eq!(entries[0].height, Some(108.0));
+    // The layout lands a frame after the state does: read the gutter where it now is, not where
+    // it sat before the keyboard resize (the registry still holds that frame's rect).
+    h.run_steps(2);
     let from = rect(&h, "dock.gutter.s0").center();
     drag(&mut h, from, from + vec2(0.0, 16.0), Modifiers::NONE);
     let DockNode::Stack { entries } = &h.state().ui.dock else { panic!("stack lost") };
diff --git a/crates/ui-egui/tests/it/ui_panels.rs b/crates/ui-egui/tests/it/ui_panels.rs
--- a/crates/ui-egui/tests/it/ui_panels.rs
+++ b/crates/ui-egui/tests/it/ui_panels.rs
@@ -325,8 +325,8 @@ fn ui_scale_setting_sizes_the_interface() {
     h.run_steps(2);
     assert_eq!(scale(&h), (100, 1.0), "Cancel put it back from 200 %");
     open_settings(&mut h);
-    type_scale(&mut h, "50");
-    assert_eq!(scale(&h), (75, 0.75), "at least 75");
+    type_scale(&mut h, "10");
+    assert_eq!(scale(&h), (50, 0.5), "at least 50");
     type_scale(&mut h, "90");
     click(&mut h, "settings.ok");
     h.run_steps(2);
diff --git a/docs/architecture.md b/docs/architecture.md
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -66,6 +66,30 @@ focused control into view. Active composition breadcrumbs use the selected-row t
 The `viewer_geometry` tests inspect actual painted contrast, scroll geometry and pointer/keyboard
 reachability across the three themes.
 
+Two layers make a physical keyboard and mouse usable on Android. winit 0.30.13 (the last 0.30
+release, pinned by eframe 0.36) reports no modifier state there — it ignores the meta state the OS
+carries on every key event and never emits the `ModifiersChanged` egui-winit learns `Ctrl`/`Shift`/
+`Alt` from — and drops every motion action that is not a touch, so a mouse has no hover, no wheel
+and no right or middle button. The workspace therefore vends winit with that mouse path filled in
+([third_party/winit](../third_party/winit/README.md): one Android-only file, pinned by
+`[patch.crates-io]`; every other target compiles the published crate byte-for-byte) and stamps the
+modifier keys onto each frame's key events itself (`stamp_android_modifiers` in `ui-egui`, driven
+from `raw_input_hook` after the frame's events are gathered, before egui builds its input state).
+The stamping also keeps a chord from degrading into its plain-key binding: without it a chord's
+letter arrives unmodified and fires whatever that plain key is bound to — the Zoom tool for `Z` —
+so the chord itself never runs. The vendored mouse path also matches Android's scroll direction on
+the horizontal axis (the OS's `Hscroll` runs the other way round from winit's positive `x`) and
+reports a held button once, deduplicating the events of a device that repeats `Down` while held
+and also sends `ButtonPress` for the same press. Touch and touch-dragged stylus input keeps the
+untouched `Touch` path there. The app side makes the chrome reachable under a finger as well:
+panel content drags like a wheel does, the drawn scroll bar's hit area widens beyond its 6-point
+line, and the dock's resize gutters stop eating the panel scroll drag; and `touch_scroll_target`
+re-applies egui's scroll-bar width at 24 points every frame on a touch screen (or on Android), so
+a theme change that rebuilds the styles can't drop the widening
+(`a_touch_screen_widens_the_scroll_bar_target`). The same two layers were validated first on
+FilmCraft's Android port (filmcraft `47f202cb`, `ecea29d6`, `685c9cc3`), and the scroll-bar work
+on PhotoCraft's (PhotoCraft #6931).
+
 ## 2. Time
 
 All time is an integer `Tick`, 254 016 000 000 per second, the least common multiple of every
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -1,6 +1,6 @@
 # Where EffectCraft falls short
 
-> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-13 · **Change:** minor (path-editing layer bounds, #1220; issue, PR and catalog references gap 21: percentage guides and effect colour labels in; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89)
+> **Last reviewed:** 2026-10-12 · **Last updated:** 2026-10-25 · **Change:** minor (G2: the Android arm64 port's touch pass — panels and scroll bars move under a finger, a 24-point scroll-bar hit re-applied every frame on touch screens, an overwriting in-app save asks Replace? first, thumbnails re-decode after a file changes — PR #1508; earlier change: re-audited against `upstream/main` 470ea7a3; ranked gap list from the 10 October re-measure otherwise unchanged) · **Target:** Adobe After Effects 2026 (26.5.0.89)
 
 An honest assessment of how far EffectCraft is from being a real replacement for After Effects,
 and the work that closes the gap. This document is meant for contributors and agents choosing
@@ -217,6 +217,17 @@ The most important missing piece: it turns every other estimate here into a meas
   `shape_tool_options_paint_new_shapes`, `masks_of_every_shape_tool_kind`,
   `tool_creates_mask_draws_a_mask_on_the_selected_shape_layer` and
   `fill_options_paint_the_next_shape` (Tool Creates Shape / Mask, Fill and Stroke Options).
+- Android evidence for [#1508](https://github.com/storytold/effectcraft/pull/1508), checked
+  headlessly with egui's kittest: panels and scroll bars move under a finger, not only a wheel —
+  panel content drags, the drawn scroll bar's hit area widens, dock resize gutters stop eating
+  the panel scroll drag — and `a_touch_screen_widens_the_scroll_bar_target` re-applies a
+  24-point scroll-bar hit every frame on a touch screen, so a theme change that rebuilds the
+  styles cannot drop it. An in-app save that would overwrite an existing file asks
+  "{name} already exists. Replace it?" first (translated in all seven input catalogs), and
+  thumbnails re-decode when a file's size or modification time changes, so a re-saved image is
+  not shown stale. The vendored winit's Android mouse-path fixes (hover, wheel direction,
+  button press/release, dedup of a held button) are described in
+  [architecture.md](architecture.md) §1 and [third_party/winit/README.md](../third_party/winit/README.md).
 - Done when: no open bug blocks a basic workflow (import, arrange, animate, preview, render) on
   any of the three desktop platforms, and every bug users report gets triaged within a day.
 
@@ -335,6 +346,7 @@ Issues #41–#47 (all from the Linux AppImage 0.1.1, 5 October): panning the vie
 
 | Date | Change | Summary |
 |---|---|---|
+| 2026-10-25 | minor | G2: the Android arm64 port's touch pass (PR #1508) — panel scroll drag and widened scroll-bar hit (`touch_scroll_target`, re-applied per frame on touch screens), Replace? before an overwriting in-app save, thumbnails keyed by size+mtime, vendored winit Android mouse-path delta; `upstream/main` merged in |
 | 2026-10-16 | minor | Gap 8: #932 closed (Convert To Bezier Path converts a shape layer's parametric path, not only mask RotoBezier) |
 | 2026-10-13 | patch | G2: layer bounds no longer compete with editable shape/mask path points (#1220) |
 | 2026-10-12 | minor | Audited against `upstream/main` 470ea7a3: open-issue count and crash list, closed issues removed from rows 1, 3, 5, 6, 8–12, 15–17, 22–24, G8 and G9 (#759 `#include`), localization catalogs recounted (≈ 12% to ≈ 18%); rows 7, 14 and 21 left to open PRs #1102, #792 and #1098 |
diff --git a/docs/preferences.md b/docs/preferences.md
--- a/docs/preferences.md
+++ b/docs/preferences.md
@@ -62,7 +62,7 @@ Commands (CLI, MCP, control channel):
 - `appearance.theme`: the single theme from before appearance modes, kept for older settings files and automation. A saved file with only `theme` loads as a fixed Dark or Light mode showing it; setting it (`prefs.set`, `ui.set {"theme": …}`, `view.theme.dark|darker|light`) selects that theme and fixes the mode to its family. While the mode is Dark or Light it mirrors the theme in use.
 - `appearance.brightness`: Brightness
 - `appearance.uiScale`: UI Scale, the size of the whole interface (any whole percentage from
-  75 to 200, on top of the display's own scaling), dragged or typed; it applies as soon as it is
+  50 to 200, on top of the display's own scaling), dragged or typed; it applies as soon as it is
   dragged or entered
 - `appearance.useLabelColorForHandles`: Use Label Color for Layer Handles and Paths
 - `grids.gridColor`: Color
