diff --git a/apps/effectcraft-web/src/store.rs b/apps/effectcraft-web/src/store.rs
--- a/apps/effectcraft-web/src/store.rs
+++ b/apps/effectcraft-web/src/store.rs
@@ -366,6 +366,21 @@ impl ConfigStore for WebConfig {
         v.sort();
         v
     }
+    /// The first name part of the keys under `dir/`.
+    fn list_dirs(&self, dir: &str) -> Vec<String> {
+        let pre = format!("{CONFIG}{}/", dir.trim_end_matches('/'));
+        let mut v: Vec<String> = self
+            .store
+            .lock()
+            .list(&pre)
+            .into_iter()
+            .filter_map(|(k, ..)| k.strip_prefix(&pre)?.split_once('/').map(|(d, _)| d.to_string()))
+            .filter(|d| !d.is_empty())
+            .collect();
+        v.sort();
+        v.dedup();
+        v
+    }
     fn dir(&self) -> Option<PathBuf> {
         Some(PathBuf::from("/"))
     }
@@ -533,6 +548,22 @@ mod tests {
         assert_eq!(s.file("/clip.mp4").as_deref(), Some(&b"abc"[..]));
     }
 
+    /// Scripts in a subfolder of the Scripts folder (`Tools/Hello.jsx`) are listed, as on the desktop.
+    #[test]
+    fn config_store_lists_folders() {
+        let c = WebConfig::new(store());
+        for name in ["Scripts/Top.jsx", "Scripts/Tools/Hello.jsx", "Scripts/Tools/More.jsx", "Scripts/ScriptUI Panels/Kit/A.jsx"] {
+            c.write(name, "x").unwrap();
+        }
+        assert_eq!(c.list("Scripts"), ["Top.jsx"]);
+        assert_eq!(c.list_dirs("Scripts"), ["ScriptUI Panels", "Tools"]);
+        assert_eq!(c.list_dirs("Scripts/ScriptUI Panels"), ["Kit"]);
+        let mut s = effectcraft_engine::Session { config: Some(Arc::new(c)), ..Default::default() };
+        let list = s.execute("file.scripts.list", serde_json::json!({})).unwrap();
+        let installed: Vec<&str> = list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").filter_map(|e| e["name"].as_str()).collect();
+        assert_eq!(installed, ["Tools/Hello.jsx", "Tools/More.jsx", "Top.jsx", "Kit/A.jsx"]);
+    }
+
     #[test]
     fn config_store_and_file_ops() {
         let s = store();
diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs
--- a/crates/engine/src/cmd/scripts.rs
+++ b/crates/engine/src/cmd/scripts.rs
@@ -5,7 +5,8 @@
 //!   settings on the desktop, so dropping `.jsx` files in those folders works too. File ▸ Scripts
 //!   lists them with the bundled sample scripts and extensions ([`BUNDLED`]) and runs them by name; ScriptUI
 //!   panels appear at the bottom of the Window menu and open as dockable panels
-//!   (`window.scriptPanel`), running with `this` = the panel as in After Effects.
+//!   (`window.scriptPanel`), running with `this` = the panel as in After Effects. A subfolder of
+//!   either folder (one level) is a submenu named after it; its scripts are named `Folder/File.jsx`.
 //! * **ScriptUI windows** (`scriptui.*`): list the open script windows, read their control
 //!   trees, click buttons and set values like a user, close them (see [`crate::scriptui`]).
 //! * **Script settings** (`script.settings.*`): After Effects' `app.settings`, string values by
@@ -22,6 +23,8 @@ use crate::{EngineError, Event, Result, Session, cmd, scriptui};
 /// The settings-store folders of installed scripts.
 pub const SCRIPTS_DIR: &str = "Scripts";
 pub const PANELS_DIR: &str = "Scripts/ScriptUI Panels";
+/// [`PANELS_DIR`]'s name in [`SCRIPTS_DIR`]: it holds the panels, so it is not a folder of scripts.
+const PANELS_FOLDER: &str = "ScriptUI Panels";
 /// The ScriptUI panels open when EffectCraft last quit (settings store), reopened at launch.
 pub const OPEN_PANELS_FILE: &str = "scriptui_panels.json";
 /// Scripts' `app.settings` in the settings store: `{section: {key: value}}`, as After Effects
@@ -111,7 +114,7 @@ pub const BUNDLED: &[(&str, bool, &str, &str)] = &[
 /// A script File ▸ Scripts (or the Window menu, for panels) offers.
 #[derive(Clone, Debug, PartialEq, Serialize)]
 pub struct ScriptEntry {
-    /// File name (`Rename Layers.jsx`): what the menus show and commands take.
+    /// File name (`Rename Layers.jsx`), or `Folder/File.jsx` in a subfolder: what commands take.
     pub name: String,
     /// In the ScriptUI Panels folder: opens as a dockable panel.
     pub panel: bool,
@@ -119,16 +122,23 @@ pub struct ScriptEntry {
     pub source: &'static str,
 }
 
+/// Whether `name` ends in `.jsx` or `.js` (any letter case).
 fn is_script(name: &str) -> bool {
     let l = name.to_ascii_lowercase();
     l.ends_with(".jsx") || l.ends_with(".js")
 }
 
-/// Whether `name` can name an installed script: a bare file name, never a path (names are joined
-/// onto the Scripts folders of the settings store, which takes them as relative paths), and on
-/// Windows none of the names [`windows_unsafe_name`] lists.
+/// Whether `name` can name an installed script: a file name, or `Folder/File` for one in a
+/// subfolder of a Scripts folder (one level, and not `ScriptUI Panels/…`, the panels' own
+/// folder). Each part is a bare name, never a path (names are joined onto the Scripts folders of
+/// the settings store, which takes them as relative paths), and on Windows none of the names
+/// [`windows_unsafe_name`] lists.
 fn is_script_name(name: &str) -> bool {
-    is_bare_name(name) && !(cfg!(windows) && windows_unsafe_name(name))
+    let part = |n: &str| is_bare_name(n) && !(cfg!(windows) && windows_unsafe_name(n));
+    match name.split_once('/') {
+        Some((folder, file)) => part(folder) && part(file) && !folder.eq_ignore_ascii_case(PANELS_FOLDER),
+        None => part(name),
+    }
 }
 
 /// One path component on every platform: not empty, `.` or `..`, and without `/`, `\` or NUL.
@@ -155,8 +165,13 @@ pub fn scripts(s: &Session) -> Vec<ScriptEntry> {
     let mut v: Vec<ScriptEntry> = vec![];
     if let Some(cfg) = &s.config {
         for (dir, panel) in [(SCRIPTS_DIR, false), (PANELS_DIR, true)] {
-            for n in cfg.list(dir).into_iter().filter(|n| is_script(n) && is_script_name(n)) {
-                v.push(ScriptEntry { name: n, panel, source: "installed" });
+            // The folder itself, then each subfolder, whose scripts are named `Folder/File.jsx`.
+            let subfolders = cfg.list_dirs(dir).into_iter().map(|f| (format!("{dir}/{f}"), format!("{f}/")));
+            for (folder, prefix) in std::iter::once((dir.to_string(), String::new())).chain(subfolders) {
+                let names = cfg.list(&folder).into_iter().map(|n| format!("{prefix}{n}"));
+                for n in names.filter(|n| is_script(n) && is_script_name(n)) {
+                    v.push(ScriptEntry { name: n, panel, source: "installed" });
+                }
             }
         }
     }
@@ -216,12 +231,19 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> {
     Ok(json!(scripts(s)))
 }
 
+/// `file.uninstallScript {name}`: delete an installed script or panel at the top of the Scripts
+/// folders.
 fn uninstall(s: &mut Session, p: &Value) -> Result<Value> {
     let c = "file.uninstallScript";
     let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name`"))?;
     if !is_script(name) || !is_script_name(name) {
         return Err(bad(c, format!("`{name}` is not a script file name")));
     }
+    // A subfolder can be a link to files outside the settings folder (an extension's own
+    // folder), so only what Install puts at the top is removed, never a file through a folder.
+    if name.contains('/') {
+        return Err(bad(c, format!("`{name}` is in a subfolder: delete it there yourself (only scripts at the top of the Scripts folders are uninstalled)")));
+    }
     let cfg = s.config.clone().ok_or_else(|| bad(c, "no settings folder"))?;
     let mut removed = false;
     for dir in [SCRIPTS_DIR, PANELS_DIR] {
@@ -312,13 +334,15 @@ fn restore_panels(s: &mut Session, _: &Value) -> Result<Value> {
 fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
     let c = "window.scriptPanel";
     let name = str_p(p, "name").ok_or_else(|| bad(c, "missing `name` (a script in the ScriptUI Panels folder)"))?.to_string();
-    let title = name.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
+    let file = name.rsplit_once('/').map_or(name.as_str(), |(_, f)| f);
+    let title = file.trim_end_matches(".jsx").trim_end_matches(".js").to_string();
     if let Some(w) = s.script_ui.windows.iter().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name) {
         let id = w.id;
         s.events.push(Event::Frontend { command: c.into(), params: json!({"window": id}) });
         return Ok(json!({"window": id, "open": true}));
     }
-    let (_, code) = script_code(s, &name, Some(true)).or_else(|| script_code(s, &name, None)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
+    // Only panels: a script opened as one would be remembered, then forgotten at the next launch.
+    let (_, code) = script_code(s, &name, Some(true)).ok_or_else(|| bad(c, format!("no ScriptUI panel `{name}`")))?;
     let out = super::file_more::run_js_named(s, &panel_wrapper(&title, &code), &name)?;
     let id = s.script_ui.windows.iter().rev().find(|w| w.kind == scriptui::WindowKind::Panel && w.script == name).map(|w| w.id);
     if let Some(id) = id {
@@ -334,6 +358,7 @@ fn open_panel(s: &mut Session, p: &Value) -> Result<Value> {
     Ok(json!({"window": id, "result": out}))
 }
 
+/// The commands of File ▸ Scripts, ScriptUI panels and windows, and script settings.
 pub fn specs() -> Vec<CommandSpec> {
     vec![
         cmd!(
@@ -354,14 +379,22 @@ pub fn specs() -> Vec<CommandSpec> {
             always,
             |s, p| install(s, p, true)
         ),
-        cmd!("file.uninstallScript", "Uninstall Script", [], None, "{name}", always, uninstall),
-        crate::query!("file.scripts.list", "List Scripts", "{} → [{name, panel, source: installed|sample|extension}]", list),
+        cmd!(
+            "file.uninstallScript",
+            "Uninstall Script",
+            [],
+            None,
+            "{name (a script at the top of a Scripts folder, not `Folder/File.jsx`)}",
+            always,
+            uninstall
+        ),
+        crate::query!("file.scripts.list", "List Scripts", "{} → [{name (`Folder/File.jsx` in a subfolder), panel, source: installed|sample|extension}]", list),
         cmd!(
             "window.scriptPanel",
             "ScriptUI Panel",
             [],
             None,
-            "{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`)} → opens it as a dockable panel",
+            "{name (a script in the ScriptUI Panels folder, e.g. `Layer Tools.jsx`, or `Folder/Panel.jsx` in a subfolder)} → opens it as a dockable panel",
             always,
             open_panel
         ),
@@ -500,13 +533,15 @@ mod tests {
         }
     }
 
+    /// Bundled and ordinary script file names are accepted; paths are not.
     #[test]
     fn script_names_are_bare_file_names() {
         let more = ["..jsx", "a..b.js", "Äpfel ✓.jsx", "console.jsx", "Concert.jsx", "NULL.jsx", "COM10.jsx", "LPT.jsx"];
         for name in BUNDLED.iter().map(|b| b.0).chain(more) {
             assert!(is_script(name) && is_script_name(name), "{name}");
         }
-        for name in PATHS {
+        // `a/b.jsx` is a script in a folder (see below).
+        for name in PATHS.into_iter().filter(|n| *n != "a/b.jsx") {
             assert!(!is_script_name(name), "{name:?}");
         }
         for name in WINDOWS_ONLY {
@@ -524,6 +559,132 @@ mod tests {
         }
     }
 
+    /// `Folder/File.jsx` is accepted; deeper, upward, absolute and `\` names are not.
+    #[test]
+    fn script_names_may_have_one_folder() {
+        for name in ["Motion Kit/Animate.jsx", "a/b.jsx", "Äpfel/✓.js", "..jsx/x.jsx"] {
+            assert!(is_script_name(name), "{name:?}");
+        }
+        // Deeper, up, absolute, an empty part, a `\` path, and the panels' own folder in `Scripts`.
+        let refused = ["a/b/c.jsx", "../x.jsx", "a/../x.jsx", "./x.jsx", "a/..", "/x.jsx", "a//x.jsx", "a/", "a\\b.jsx", "a/b\\c.jsx", "a\0/x.jsx"];
+        for name in refused.into_iter().chain(["ScriptUI Panels/x.jsx", "scriptui panels/x.jsx"]) {
+            assert!(!is_script_name(name), "{name:?}");
+        }
+        for name in ["In:Out/x.jsx", "con/x.jsx", "x/COM1.jsx", "x./y.jsx"] {
+            assert_eq!(is_script_name(name), !cfg!(windows), "{name:?}");
+        }
+    }
+
+    /// Writes `code` to `rel` in the settings folder of [`session_in_tmp`]'s `root`.
+    fn put(root: &std::path::Path, rel: &str, code: &str) {
+        let p = root.join("settings").join(rel);
+        std::fs::create_dir_all(p.parent().unwrap()).unwrap();
+        std::fs::write(p, code).unwrap();
+    }
+
+    /// Every script command takes `Folder/File.jsx` names and refuses names outside one folder level.
+    #[test]
+    fn scripts_in_a_subfolder_are_listed_run_and_opened_as_folder_and_file() {
+        let (mut s, root) = session_in_tmp("folders");
+        put(&root, "Scripts/Tools/Hello.jsx", "hello");
+        put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "animate");
+        put(&root, "Scripts/ScriptUI Panels/Top.jsx", "top");
+        // Two folders deep: neither listed nor run.
+        put(&root, "Scripts/a/b/c.jsx", "too deep");
+        put(&root, "Scripts/ScriptUI Panels/Motion Kit/sub/Deep.jsx", "too deep");
+        let list = s.execute("file.scripts.list", json!({})).unwrap();
+        let installed: Vec<(&str, bool)> =
+            list.as_array().unwrap().iter().filter(|e| e["source"] == "installed").map(|e| (e["name"].as_str().unwrap(), e["panel"] == true)).collect();
+        assert_eq!(installed, [("Tools/Hello.jsx", false), ("Motion Kit/Animate.jsx", true), ("Top.jsx", true)]);
+        assert_eq!(s.execute("file.runScript", json!({"name": "Tools/Hello.jsx"})).unwrap()["ran"], "hello");
+        // A panel in a folder is titled after its file.
+        let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
+        let ran = r["result"]["ran"].as_str().unwrap_or_default();
+        assert!(ran.contains("animate") && ran.contains(r#"__uiDockPanel("Animate")"#), "{r}");
+        assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
+        // A script that is not a panel doesn't open as one.
+        for name in ["Tools/Hello.jsx", "Rename Layers.jsx"] {
+            assert!(bad_params(&s.execute("window.scriptPanel", json!({"name": name}))), "{name}");
+        }
+        for name in ["../x.jsx", "a/b/c.jsx", "a\\b.jsx", "Motion Kit/sub/Deep.jsx", "ScriptUI Panels/Top.jsx", "Tools/../../../outside.jsx"] {
+            assert_eq!(script_code(&s, name, None), None, "{name:?}");
+            for c in ["file.runScript", "window.scriptPanel", "file.uninstallScript"] {
+                let r = s.execute(c, json!({"name": name}));
+                assert!(bad_params(&r), "{c} `{name}`: {r:?}");
+            }
+        }
+        assert!(["Scripts/a/b/c.jsx", "Scripts/ScriptUI Panels/Top.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
+        assert!(root.join("outside.jsx").is_file());
+        // Uninstall removes only scripts at the top; one in a folder is deleted there by hand.
+        for name in ["Tools/Hello.jsx", "Motion Kit/Animate.jsx"] {
+            assert!(bad_params(&s.execute("file.uninstallScript", json!({"name": name}))), "{name}");
+        }
+        assert!(["Scripts/Tools/Hello.jsx", "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx"].iter().all(|p| root.join("settings").join(p).is_file()));
+        let _ = std::fs::remove_dir_all(&root);
+    }
+
+    /// A subfolder can be a link to an extension's own folder, outside the settings folder: its
+    /// scripts are listed and run from there, but uninstall never deletes through it.
+    #[cfg(unix)]
+    #[test]
+    fn uninstall_never_deletes_through_a_linked_subfolder() {
+        let (mut s, root) = session_in_tmp("linked");
+        std::fs::create_dir_all(root.join("extension")).unwrap();
+        std::fs::write(root.join("extension").join("Animate.jsx"), "animate").unwrap();
+        std::os::unix::fs::symlink(root.join("extension"), root.join("settings").join(PANELS_DIR).join("Motion Kit")).unwrap();
+        let list = s.execute("file.scripts.list", json!({})).unwrap();
+        assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}");
+        assert_eq!(script_code(&s, "Motion Kit/Animate.jsx", Some(true)), Some((true, "animate".to_string())));
+        let r = s.execute("file.uninstallScript", json!({"name": "Motion Kit/Animate.jsx"}));
+        assert!(bad_params(&r), "{r:?}");
+        assert!(root.join("extension").join("Animate.jsx").is_file(), "the extension's own file was deleted");
+        let _ = std::fs::remove_dir_all(&root);
+    }
+
+    /// Window ▸ Motion Kit ▸ Animate.jsx: a subfolder is a submenu named after it, where the folder
+    /// sorts; the entries outside folders stay as they were.
+    #[test]
+    fn a_subfolder_is_a_submenu() {
+        use crate::menus::{MenuNode, dynamic, dynamic_nodes};
+        let (s, root) = session_in_tmp("submenus");
+        put(&root, "Scripts/ScriptUI Panels/Motion Kit/Animate.jsx", "");
+        put(&root, "Scripts/ScriptUI Panels/Motion Kit/Rig.jsx", "");
+        put(&root, "Scripts/ScriptUI Panels/Top.jsx", "");
+        put(&root, "Scripts/Tools/Hello.jsx", "");
+        let cx = Default::default();
+        let item =
+            |label: &str, command: &str, name: &str| json!({"type": "item", "label": label, "command": command, "params": {"name": name}, "shortcut": null});
+        let menus = [
+            (
+                "scriptPanels",
+                json!({"type": "submenu", "label": "Motion Kit", "children": [
+                    item("Animate.jsx", "window.scriptPanel", "Motion Kit/Animate.jsx"),
+                    item("Rig.jsx", "window.scriptPanel", "Motion Kit/Rig.jsx"),
+                ]}),
+            ),
+            ("scripts", json!({"type": "submenu", "label": "Tools", "children": [item("Hello.jsx", "file.runScript", "Tools/Hello.jsx")]})),
+        ];
+        for (menu, folder) in menus {
+            let (nodes, _) = dynamic_nodes(&s, menu, &cx);
+            let (entries, _) = dynamic(&s, menu, &cx);
+            let items: Vec<_> = nodes.iter().filter_map(|n| if let MenuNode::Item(e) = n { Some(e) } else { None }).collect();
+            assert_eq!(items, entries.iter().filter(|e| !e.label.contains('/')).collect::<Vec<_>>(), "{menu}");
+            let folders: Vec<Value> = nodes.iter().filter(|n| matches!(n, MenuNode::Submenu { .. })).map(|n| serde_json::to_value(n).unwrap()).collect();
+            assert_eq!(folders, [folder], "{menu}");
+        }
+        let (panels, _) = dynamic_nodes(&s, "scriptPanels", &cx);
+        let labels: Vec<&str> = panels
+            .iter()
+            .filter_map(|n| match n {
+                MenuNode::Item(e) => Some(e.label.as_str()),
+                MenuNode::Submenu { label, .. } => Some(label),
+                _ => None,
+            })
+            .collect();
+        assert_eq!(labels, ["Ease Presets.jsx", "Layer Tools.jsx", "Motion Kit", "Top.jsx"]);
+        let _ = std::fs::remove_dir_all(&root);
+    }
+
     #[test]
     fn uninstall_takes_a_script_name_not_a_path() {
         let (mut s, root) = session_in_tmp("uninstall");
diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs
--- a/crates/engine/src/menus.rs
+++ b/crates/engine/src/menus.rs
@@ -75,7 +75,8 @@ fn holder_of(s: &Session, keys: &str) -> Option<String> {
 /// none: `recentProjects`, `recentFootage`, `recentPresets`, `history` (undo steps, newest
 /// first), `view3dShortcuts`, `workspaceShortcuts`, `savedWorkspaces` (Window ▸ Workspace) and
 /// `openViewers` (open compositions other than the active one), `scripts` (File ▸ Scripts) and
-/// `scriptPanels` (ScriptUI panels).
+/// `scriptPanels` (ScriptUI panels; a script in a subfolder is labelled `Folder/File.jsx` here,
+/// and [`dynamic_nodes`] puts it in the folder's submenu).
 pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<&'static str>) {
     use serde_json::json;
     let indexed = |list: &[String], cmd: &str, stem: bool| -> Vec<MenuEntry> {
@@ -153,6 +154,31 @@ pub fn dynamic(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuEntry>, Option<
     }
 }
 
+/// [`dynamic`] as menu nodes, which is what frontends draw: scripts and panels in a subfolder
+/// (`Motion Kit/Animate.jsx`) go into a submenu named after the folder (`Motion Kit ▸
+/// Animate.jsx`), placed where the folder sorts among the other entries.
+pub fn dynamic_nodes(s: &Session, name: &str, cx: &DynCtx) -> (Vec<MenuNode>, Option<&'static str>) {
+    let (entries, empty) = dynamic(s, name, cx);
+    let scripts = matches!(name, "scripts" | "scriptPanels");
+    let mut out: Vec<MenuNode> = vec![];
+    for mut e in entries {
+        let Some((folder, file)) = e.label.split_once('/').filter(|_| scripts).map(|(d, f)| (d.to_string(), f.to_string())) else {
+            out.push(MenuNode::Item(e));
+            continue;
+        };
+        e.label = file;
+        let open = out.iter_mut().find_map(|n| match n {
+            MenuNode::Submenu { label, children } if *label == folder => Some(children),
+            _ => None,
+        });
+        match open {
+            Some(children) => children.push(MenuNode::Item(e)),
+            None => out.push(MenuNode::Submenu { label: folder, children: vec![MenuNode::Item(e)] }),
+        }
+    }
+    (out, empty)
+}
+
 /// Display label of a submenu (the "Assign Shortcut to …" submenus name the current view or
 /// workspace, like After Effects).
 pub fn submenu_label(s: &Session, label: &str, cx: &DynCtx) -> String {
diff --git a/crates/engine/src/settings/config.rs b/crates/engine/src/settings/config.rs
--- a/crates/engine/src/settings/config.rs
+++ b/crates/engine/src/settings/config.rs
@@ -24,7 +24,9 @@ pub trait ConfigStore: Send + Sync {
         }
         Ok(text)
     }
+    /// Write entry `name`, replacing any earlier text.
     fn write(&self, name: &str, data: &str) -> std::io::Result<()>;
+    /// Remove entry `name`; a missing entry is no error.
     fn remove(&self, name: &str) -> std::io::Result<()>;
     /// Names of the entries directly in `dir` (a `/`-separated name prefix such as `Scripts`),
     /// sorted. Stores that can't list return nothing.
@@ -32,6 +34,11 @@ pub trait ConfigStore: Send + Sync {
         let _ = dir;
         vec![]
     }
+    /// Names of the folders directly in `dir`, sorted, like [`ConfigStore::list`].
+    fn list_dirs(&self, dir: &str) -> Vec<String> {
+        let _ = dir;
+        vec![]
+    }
     /// The folder next to the configuration (default Auto-Save folder for untitled projects),
     /// in the namespace of [`ConfigStore::files`]; `None` when there is none.
     fn dir(&self) -> Option<PathBuf> {
@@ -107,6 +114,15 @@ impl ConfigStore for MemoryConfig {
         let Ok(m) = self.files.lock() else { return vec![] };
         m.keys().filter_map(|k| k.strip_prefix(&pre)).filter(|n| !n.is_empty() && !n.contains('/')).map(str::to_string).collect()
     }
+    /// The first name part of the keys under `dir/`.
+    fn list_dirs(&self, dir: &str) -> Vec<String> {
+        let pre = format!("{}/", dir.trim_end_matches('/'));
+        let Ok(m) = self.files.lock() else { return vec![] };
+        let mut v: Vec<String> = m.keys().filter_map(|k| k.strip_prefix(&pre)?.split_once('/')).map(|(d, _)| d.to_string()).filter(|d| !d.is_empty()).collect();
+        v.sort();
+        v.dedup();
+        v
+    }
 }
 
 /// Files in one directory, written atomically.
@@ -153,6 +169,13 @@ impl ConfigStore for DirConfig {
         v.sort();
         v
     }
+    /// The folders in `dir`, following links like [`ConfigStore::list`] does for files.
+    fn list_dirs(&self, dir: &str) -> Vec<String> {
+        let Ok(rd) = std::fs::read_dir(self.dir.join(dir)) else { return vec![] };
+        let mut v: Vec<String> = rd.flatten().filter(|e| e.path().is_dir()).map(|e| e.file_name().to_string_lossy().to_string()).collect();
+        v.sort();
+        v
+    }
     fn remove(&self, name: &str) -> std::io::Result<()> {
         match std::fs::remove_file(self.dir.join(name)) {
             Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
diff --git a/crates/script/src/tests/panels.rs b/crates/script/src/tests/panels.rs
--- a/crates/script/src/tests/panels.rs
+++ b/crates/script/src/tests/panels.rs
@@ -86,3 +86,27 @@ fn open_panels_open_again_at_the_next_launch() {
     let mut s = session(&store);
     assert_eq!(s.execute("window.restoreScriptPanels", json!({})).unwrap(), json!({"opened": [], "failed": []}));
 }
+
+/// A panel in a subfolder (Window ▸ Motion Kit ▸ Animate.jsx) is `Motion Kit/Animate.jsx`: it opens
+/// titled after its file and opens again at the next launch.
+#[test]
+fn a_panel_in_a_subfolder_opens_and_opens_again_at_the_next_launch() {
+    let store = Arc::new(MemoryConfig::default());
+    store.write(&format!("{PANELS_DIR}/Motion Kit/Animate.jsx"), PANEL).unwrap();
+    let mut s = session(&store);
+    let list = s.execute("file.scripts.list", json!({})).unwrap();
+    assert!(list.as_array().unwrap().iter().any(|e| e["name"] == "Motion Kit/Animate.jsx" && e["panel"] == true), "{list}");
+    let r = s.execute("window.scriptPanel", json!({"name": "Motion Kit/Animate.jsx"})).unwrap();
+    let w = s.script_ui.window(r["window"].as_u64().unwrap() as u32).unwrap();
+    assert_eq!((w.kind, w.title.as_str()), (WindowKind::Panel, "Animate"));
+    assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]}));
+    // A saved name that leaves the folder, or goes two folders deep, fails and is forgotten.
+    store.write(OPEN_PANELS_FILE, &json!({"open": ["../Top.jsx", "Motion Kit/Animate.jsx", "a/b/c.jsx"]}).to_string()).unwrap();
+    drop(s);
+    let mut s = session(&store);
+    let r = s.execute("window.restoreScriptPanels", json!({})).unwrap();
+    assert_eq!(r["opened"], json!(["Motion Kit/Animate.jsx"]));
+    assert_eq!(r["failed"].as_array().unwrap().iter().map(|f| f["name"].as_str().unwrap()).collect::<Vec<_>>(), ["../Top.jsx", "a/b/c.jsx"]);
+    assert!(panel_window(&s, "Motion Kit/Animate.jsx").is_some());
+    assert_eq!(remembered(&store), json!({"open": ["Motion Kit/Animate.jsx"]}));
+}
diff --git a/crates/ui-egui/src/menus/bar.rs b/crates/ui-egui/src/menus/bar.rs
--- a/crates/ui-egui/src/menus/bar.rs
+++ b/crates/ui-egui/src/menus/bar.rs
@@ -149,13 +149,18 @@ fn menu_nodes(
                 // saved workspaces.
                 let ws = app.ui.workspace.clone();
                 let saved = app.saved_workspace_names();
-                let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &dyn_ctx(&ws, &saved));
-                if entries.is_empty()
+                let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &dyn_ctx(&ws, &saved));
+                if nodes.is_empty()
                     && let Some(e) = empty
                 {
                     ui.add_enabled(false, egui::Button::new((gutter(false), e)));
                 }
-                for (i, e) in entries.iter().enumerate() {
+                for (i, n) in nodes.iter().enumerate() {
+                    // A folder of scripts or panels: a submenu.
+                    let MenuNode::Item(e) = n else {
+                        menu_nodes(app, ui, std::slice::from_ref(n), clicked, nav, depth);
+                        continue;
+                    };
                     let tip = match name.as_str() {
                         "recentProjects" => app.session.prefs.recent_projects.get(i).cloned(),
                         "recentFootage" => app.session.prefs.recent_footage.get(i).cloned(),
diff --git a/crates/ui-egui/src/native_menu.rs b/crates/ui-egui/src/native_menu.rs
--- a/crates/ui-egui/src/native_menu.rs
+++ b/crates/ui-egui/src/native_menu.rs
@@ -149,6 +149,7 @@ pub fn build_now(app: &EffectcraftApp, ctx: &egui::Context) -> NativeMenu {
 
 /// Build the native menu spec for the app's current state.
 pub fn build(app: &EffectcraftApp) -> NativeMenu {
+    /// Append the native nodes of `nodes` to `out`; item ids start with `path`.
     fn rec(app: &EffectcraftApp, nodes: &[MenuNode], path: &str, out: &mut Vec<NativeNode>) {
         let mut services_added = false;
         for (i, n) in nodes.iter().enumerate() {
@@ -159,8 +160,8 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {
                     // Recent projects / footage / presets, undo history, shortcut slots, viewers,
                     // saved workspaces.
                     let saved = app.saved_workspace_names();
-                    let (entries, empty) = effectcraft_engine::menus::dynamic(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved));
-                    if entries.is_empty()
+                    let (nodes, empty) = effectcraft_engine::menus::dynamic_nodes(&app.session, name, &crate::menus::dyn_ctx(&app.ui.workspace, &saved));
+                    if nodes.is_empty()
                         && let Some(e) = empty
                     {
                         out.push(NativeNode::Item(NativeItem {
@@ -174,7 +175,12 @@ pub fn build(app: &EffectcraftApp) -> NativeMenu {
                             checked: None,
                         }));
                     }
-                    for (k, e) in entries.iter().enumerate() {
+                    for (k, n) in nodes.iter().enumerate() {
+                        // A folder of scripts or panels: a submenu.
+                        let MenuNode::Item(e) = n else {
+                            rec(app, std::slice::from_ref(n), &format!("{id}.d{k}"), out);
+                            continue;
+                        };
                         out.push(NativeNode::Item(NativeItem {
                             id: format!("{id}.d{k}"),
                             label: e.label.clone(),
diff --git a/docs/plugins.md b/docs/plugins.md
--- a/docs/plugins.md
+++ b/docs/plugins.md
@@ -12,11 +12,11 @@ Guide):
 | After Effects | EffectCraft | |
 |---|---|---|
 | Effect plug-ins (C/C++ SDK, `.aex` / `.plugin`) | [Effect plug-ins](#effect-plug-ins): WebAssembly modules (plug-in API v1) or Rust; the `Plug-ins` folder next to the settings loads at start-up | Different ABI: After Effects SDK plug-ins can't run (G8) |
-| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples | Same |
+| Scripts folder: its scripts are listed in File ▸ Scripts | `Scripts/` in the settings folder (File ▸ Scripts ▸ Install Script File… copies a script there), listed in File ▸ Scripts with the bundled samples; a subfolder (one level) is a submenu named after it | Same |
 | File ▸ Scripts ▸ Run Script File… (`.jsx`, `.jsxbin`) | `.jsx` / `.js` | Gap: `.jsxbin` (an undocumented binary encoding) |
 | ExtendScript preprocessor directives (`#include`, `#target`, `#targetengine`) | `#include` / `//@include` / `#includepath` inline files relative to the script (once each, depth-limited, file gate applies); `#target`, `#targetengine`, `#strict` are ignored | Same |
 | `Scripts/Startup` and `Scripts/Shutdown`: run in alphabetical order when the app starts and quits | Not run | Gap |
-| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu, docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |
+| `Scripts/ScriptUI Panels`: listed at the bottom of the Window menu, dockable like the app's own panels, `this` is the panel | `Scripts/ScriptUI Panels/` in the settings folder (File ▸ Scripts ▸ Install ScriptUI Panel… copies one there), listed at the bottom of the Window menu (a subfolder, one level, is a submenu named after it), docked like any panel, `this` is the panel; the panels open when the app quits open again at the next launch | Same. Where a panel was docked isn't kept: workspace layouts aren't saved between launches |
 | Keyboard shortcuts for scripts (the Keyboard Shortcuts editor lists the scripts of the Scripts folder) | Shortcuts bind to commands and menu entries; the File ▸ Scripts and Window menu entries of scripts aren't offered | Gap |
 | Running a script from the command line (`afterfx -r script.jsx`) | `effectcraft-cli script`, the control channel's and MCP's `script.run` | Same purpose, other entry points |
 | `app.settings` (kept in the preferences between sessions) | Kept in `script_settings.json` in the settings folder (`script.settings.get` / `script.settings.save`) | Same |
@@ -40,6 +40,11 @@ journaled and the same as the UI's action. The `effectcraft-script` crate's docs
   folder). It is listed in File ▸ Scripts at once (After Effects asks for a restart).
 * `file.scripts.list` lists installed and bundled scripts and panels; `file.runScript {name}` runs
   one; `file.uninstallScript {name}` removes an installed one.
+* A subfolder of `Scripts/` is a submenu of File ▸ Scripts named after the folder, and its scripts
+  are named `Folder/File.jsx` in `file.scripts.list` and `file.runScript`. One level of folders is
+  listed; deeper folders are not. `file.uninstallScript` removes only scripts at the top of the
+  Scripts folders: delete a script in a subfolder yourself. A subfolder may be a link to another
+  folder (an extension's own), and uninstall never deletes a file through it.
 
 ## ScriptUI panels
 
@@ -51,6 +56,13 @@ other panel. Closing the tab closes the panel (its `onClose` runs). Panels open
 quits open again at the next launch (`window.restoreScriptPanels`, which the desktop and web apps
 run at start-up; the list is `scriptui_panels.json` in the settings folder).
 
+To group panels, for example the several panels of one extension, make a subfolder of
+`Scripts/ScriptUI Panels/` and put them in it: `Scripts/ScriptUI Panels/My Tools/Animate.jsx` is
+listed as Window ▸ My Tools ▸ Animate.jsx, its tab is titled Animate, and commands name it
+`My Tools/Animate.jsx` (`window.scriptPanel {name}`). One level of folders is listed. Install
+ScriptUI Panel… copies a panel to the top of the folder; make the subfolder and move panels into it
+yourself, and delete them from it yourself too.
+
 Run from File ▸ Scripts instead, the same script gets no panel, so it should make a floating
 palette itself. A minimal panel:
 
diff --git a/docs/scripting-parity.md b/docs/scripting-parity.md
--- a/docs/scripting-parity.md
+++ b/docs/scripting-parity.md
@@ -22,7 +22,7 @@ our own scripts; no real-world script library has been run against both apps.
 | `$.evalFile`, `BridgeTalk` | Evaluate another script file; identify the host application and message other Adobe applications | `$.evalFile` evaluates a file relative to the calling file (nested at most 32 deep, under the scripts' file gate) and returns its last value; `BridgeTalk` names the host honestly (`appName` `effectcraft`, its version); messaging other applications throws "not supported" | partial | `tests_evalfile.rs` (#760); no inter-application messaging |
 | ScriptUI | Dialogs, palettes, dockable panels, resource strings, `onDraw` / ScriptUIGraphics, mouse events | Same, dockable panels reopen at launch | good | `ui_scriptui`, `tests_ui*` |
 | `app.scheduleTask` / `cancelTask` | One-shot and repeating tasks (polling, panel refresh) until `cancelTask` | Same: repeating tasks run every `delay` ms (at least 50 ms, at most 64 pending per script) until `cancelTask`, until they throw (reported once, then cancelled), or until their owner goes: a script's last window closing, or the project closing for scripts without windows; frontends tick them every frame, agents with `script.runTasks` | good | `tests_tasks.rs` (#773); one-shot tasks of a script file still run right after it, not after their delay |
-| Scripts menu, ScriptUI Panels folder | yes | yes | same | [plugins.md](plugins.md) |
+| Scripts menu, ScriptUI Panels folder | yes | yes; a subfolder (one level) of either folder is a submenu named after it | same | [plugins.md](plugins.md) |
 | Startup / Shutdown folders | yes | not run | missing | plugins.md |
 | `#include`, `#target` directives | yes | `#include` / `//@include` / `#includepath` inline files relative to the including script (once each, nested at most 32 deep, under the scripts' file gate; errors name the included file and line); `#target`, `#targetengine`, `#strict` are ignored | good | `tests_include.rs` (#759); installed scripts and panels have no folder of their own, so they include from the project's folder or absolute paths |
 | `.jsxbin` | yes | no | out of scope | No public specification |
