diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs
--- a/crates/ui-egui/src/app.rs
+++ b/crates/ui-egui/src/app.rs
@@ -471,6 +471,10 @@ impl VectorcraftApp {
         }
         if self.ui.toolbar && self.ui.screen_mode < 3 {
             toolbar::show(self, ui);
+        } else if self.ui.screen_mode < 3 && self.ui.tab_hid.is_none() {
+            // Window › Tools hides the Tools panel alone; Tab hides the toolbars made with New
+            // Toolbar too.
+            toolbar::show_custom(self, ui.ctx());
         }
         panels::library_panel::tidy(self);
         // The canvas and the panels: one docking layout. Tab hides the panels but not the canvas.
diff --git a/crates/ui-egui/src/custom_toolbars.rs b/crates/ui-egui/src/custom_toolbars.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/custom_toolbars.rs
@@ -0,0 +1,945 @@
+//! Window › Toolbars › New Toolbar (#1759): toolbars the user makes and names. New Toolbar asks
+//! for a name and makes a blank toolbar that floats on its own; the ••• button at its foot opens the
+//! All Tools drawer beside it, and tools dragged from the drawer fill it (`edit_toolbar`, with the
+//! `toolbar` param of its commands). Its title bar moves it, its × hides it and its double arrow
+//! shows its tools in one column or two. Each toolbar is listed under Window › Toolbars, where its
+//! item shows or hides it, and is kept with the workspace it was made in (`Workspace::toolbars`).
+//!
+//! Behavior from the public documentation:
+//! <https://helpx.adobe.com/illustrator/using/tools.html> (Create and manage toolbars: New Toolbar
+//! from Window › Toolbars or the drawer's flyout menu, a name and OK make a blank toolbar, its Edit
+//! Toolbar button opens the drawer to add tools, and a toolbar stays in the workspace it is made in;
+//! a toolbar moves by its title bar, whose double arrow toggles one column or two and whose close
+//! button hides it).
+
+use std::sync::atomic::Ordering;
+
+use egui::{CornerRadius, Sense, Stroke, pos2, vec2};
+use serde_json::{Value, json};
+use vectorcraft_tools::tool_info;
+
+use crate::state::{CustomToolbar, Dialog, UiState};
+use crate::theme::Tokens;
+use crate::{VectorcraftApp, icons, widgets};
+
+/// The most toolbars a workspace keeps.
+pub(crate) const MAX: usize = 64;
+/// Width of a toolbar of one column of tool buttons, inside its border.
+const WIDTH: f32 = 44.0;
+/// Width a second column of tool buttons adds.
+const BUTTON: f32 = 36.0;
+/// Height of the title bar at a toolbar's top, which moves it.
+const GRIP: f32 = 14.0;
+/// Width of the × and the double arrow at the ends of the title bar.
+const TITLE_BUTTON: f32 = 12.0;
+/// Where the first toolbar made floats, right of the Tools panel; the next ones go lower.
+const NEW_AT: [f32; 2] = [96.0, 120.0];
+
+/// A toolbar's name without leading and trailing spaces, in lowercase. Names with the same key are
+/// the same name: lookups, the check that a name is free, sanitizing and each toolbar's area
+/// compare keys.
+pub(crate) fn key(name: &str) -> String {
+    name.trim().to_lowercase()
+}
+
+/// The area a toolbar is drawn in.
+pub(crate) fn area_id(name: &str) -> egui::Id {
+    egui::Id::unique(("custom-toolbar", key(name)))
+}
+
+/// The toolbar named `name`, whatever its case.
+pub(crate) fn find<'a>(ui: &'a UiState, name: &str) -> Option<&'a CustomToolbar> {
+    let k = key(name);
+    ui.custom_toolbars.iter().find(|b| key(&b.name) == k)
+}
+
+pub(crate) fn find_mut<'a>(ui: &'a mut UiState, name: &str) -> Option<&'a mut CustomToolbar> {
+    let k = key(name);
+    ui.custom_toolbars.iter_mut().find(|b| key(&b.name) == k)
+}
+
+/// The `toolbar` param: the name of a toolbar made with New Toolbar, as it was given (none or
+/// null: the Tools panel's toolbar).
+pub(crate) fn target(ui: &UiState, p: &Value) -> Result<Option<String>, String> {
+    match p.get("toolbar") {
+        None | Some(Value::Null) => Ok(None),
+        Some(v) => {
+            let name = v.as_str().ok_or("toolbar (a toolbar's name) must be a string")?;
+            find(ui, name).map(|b| Some(b.name.clone())).ok_or_else(|| format!("no toolbar named `{name}`"))
+        }
+    }
+}
+
+/// The toolbar the open All Tools drawer edits: one made with New Toolbar, or none for the Tools
+/// panel's toolbar (and while the drawer is closed).
+pub(crate) fn drawer_target(ui: &UiState) -> Option<&str> {
+    ui.drawer_toolbar.as_deref().filter(|_| ui.tools_drawer)
+}
+
+/// Make a blank toolbar named `name`, shown; returns its name.
+pub(crate) fn create(ui: &mut UiState, name: &str) -> Result<String, String> {
+    let name = name.trim();
+    if name.is_empty() {
+        return Err("toolbar name can't be empty".into());
+    }
+    if find(ui, name).is_some() {
+        return Err(format!("there is already a toolbar named `{name}`"));
+    }
+    if ui.custom_toolbars.len() >= MAX {
+        return Err(format!("a workspace keeps at most {MAX} toolbars"));
+    }
+    let n = ui.custom_toolbars.len() as f32;
+    let pos = [NEW_AT[0] + 16.0 * n, NEW_AT[1] + 24.0 * n];
+    ui.custom_toolbars.push(CustomToolbar {
+        name: name.to_string(),
+        slots: vec![],
+        shown: true,
+        pos,
+        slot_tool: Default::default(),
+        double: false,
+        floating: vec![],
+    });
+    Ok(name.to_string())
+}
+
+/// Keep named toolbars, each name once whatever its case, of known tools, each once in its
+/// toolbar, at a position that is a number, with one strip per group torn off them (a hand-edited
+/// preferences file or workspace).
+pub(crate) fn sanitize(bars: &mut Vec<CustomToolbar>) {
+    let mut names = std::collections::BTreeSet::new();
+    bars.retain_mut(|b| {
+        b.name = b.name.trim().to_string();
+        let mut seen = std::collections::BTreeSet::new();
+        for s in &mut b.slots {
+            s.retain(|id| tool_info(id).is_some() && seen.insert(id.clone()));
+        }
+        b.slots.retain(|s| !s.is_empty());
+        crate::state::FloatingFlyout::sanitize(&mut b.floating);
+        if !b.pos.iter().all(|v| v.is_finite()) {
+            b.pos = NEW_AT;
+        }
+        !b.name.is_empty() && names.insert(key(&b.name))
+    });
+    bars.truncate(MAX);
+}
+
+/// The commands of the toolbars made with New Toolbar; `None` for other ids.
+pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
+    Some(match id {
+        "window.toolbar.new" => match p.get("name").filter(|v| !v.is_null()) {
+            Some(name) => match name.as_str() {
+                Some(name) => create(&mut app.ui, name).map(|name| json!({ "toolbar": name })),
+                None => Err("name (a toolbar's name) must be a string".into()),
+            },
+            None => {
+                let name = (1..).map(|i| format!("Toolbar {i}")).find(|n| find(&app.ui, n).is_none()).unwrap_or_default();
+                app.ui.dialog = Some(Dialog::new(KIND, json!({ "name": name })));
+                Ok(Value::Null)
+            }
+        },
+        "window.toolbar.custom" => (|| {
+            let show = crate::menus::opt_bool(p, "show")?;
+            let name = p.get("name").and_then(Value::as_str).ok_or("name (a toolbar's name) is required")?;
+            let bar = find_mut(&mut app.ui, name).ok_or_else(|| format!("no toolbar named `{name}`"))?;
+            bar.shown = show.unwrap_or(!bar.shown);
+            let (shown, name) = (bar.shown, bar.name.clone());
+            // The drawer beside a toolbar that is hidden closes with it.
+            if !shown && drawer_target(&app.ui) == Some(name.as_str()) {
+                app.ui.tools_drawer = false;
+                app.ui.drawer_toolbar = None;
+            }
+            Ok(json!(shown))
+        })(),
+        "window.toolbar.list" => Ok(json!(
+            app.ui
+                .custom_toolbars
+                .iter()
+                .map(|b| json!({"name": b.name, "shown": b.shown, "double": b.double, "slots": b.slots}))
+                .collect::<Vec<_>>()
+        )),
+        _ => return None,
+    })
+}
+
+/// New Toolbar's dialog.
+pub(crate) const KIND: &str = "newToolbar";
+
+/// OK in New Toolbar.
+pub(crate) fn confirm(app: &mut VectorcraftApp) -> Result<Value, String> {
+    let Some(d) = app.ui.dialog.clone() else { return Err("no dialog open".into()) };
+    let name = create(&mut app.ui, &d.str("name"))?;
+    app.ui.dialog = None;
+    Ok(json!({ "toolbar": name }))
+}
+
+/// The New Toolbar dialog: the name, OK and Cancel.
+pub(crate) fn show_dialog(app: &mut VectorcraftApp, ctx: &egui::Context) {
+    let Some(mut d) = app.ui.dialog.clone() else { return };
+    let t = Tokens::get(ctx);
+    let (mut ok, mut cancel) = (false, false);
+    crate::dialogs::modal::show(ctx, "New Toolbar", egui::Id::unique("dialog-new-toolbar"), -40.0, 22, |ui| {
+        ui.set_width(320.0);
+        crate::dialogs::modal::heading(ui, tl!("New Toolbar"));
+        ui.add_space(12.0);
+        ui.horizontal(|ui| {
+            ui.label(egui::RichText::new(tl!("Name:")).color(t.text_dim));
+            let mut name = d.str("name");
+            if ui.add(egui::TextEdit::singleline(&mut name).desired_width(220.0)).changed() {
+                d.fields.insert("name".into(), json!(name));
+            }
+        });
+        ui.add_space(16.0);
+        ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
+            if widgets::primary_button(ui, tl!("OK")).clicked() {
+                ok = true;
+            }
+            ui.add_space(8.0);
+            if widgets::secondary_button(ui, tl!("Cancel")).clicked() {
+                cancel = true;
+            }
+        });
+    });
+    if ctx.input(|i| i.key_pressed(egui::Key::Enter)) {
+        ok = true;
+    }
+    app.ui.dialog = Some(d);
+    if cancel {
+        app.ui.dialog = None;
+    } else if ok && let Err(e) = confirm(app) {
+        app.status(e);
+    }
+}
+
+// ---------- the menu ----------
+
+crate::shortcut_editor::ui_mirror!(fn names_store() -> Vec<&'static str>);
+
+/// Mirror the toolbars' names for the (static) menu tree.
+pub fn sync(ui: &UiState) {
+    let same = names_store()
+        .read()
+        .map(|v| v.len() == ui.custom_toolbars.len() && v.iter().zip(&ui.custom_toolbars).all(|(a, b)| *a == b.name))
+        .unwrap_or(true);
+    if !same {
+        if let Ok(mut v) = names_store().write() {
+            *v = ui.custom_toolbars.iter().map(|b| crate::shortcut_editor::intern(&b.name)).collect();
+        }
+        crate::shortcut_editor::GENERATION.fetch_add(1, Ordering::Relaxed);
+    }
+}
+
+/// Window › Toolbars: Advanced and Double Column, the toolbars made with New Toolbar (each checked
+/// while it shows), and New Toolbar….
+pub fn menu_items() -> Vec<crate::menus::Item> {
+    use crate::menus::Item;
+    let c = |label: &'static str, id: &'static str| Item::Cmd(label, id, Value::Null);
+    let mut v = vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns"), Item::Sep];
+    let names = names_store().read().map(|v| v.clone()).unwrap_or_default();
+    if !names.is_empty() {
+        v.extend(names.into_iter().map(|n| Item::Cmd(n, "window.toolbar.custom", json!({ "name": n }))));
+        v.push(Item::Sep);
+    }
+    v.push(c("New Toolbar…", "window.toolbar.new"));
+    v
+}
+
+// ---------- the toolbars ----------
+
+/// What a press on a toolbar did, applied once every toolbar is drawn.
+enum Action {
+    Select(&'static str),
+    Options(&'static str),
+    /// A group's flyout: its tools, its button, and the toolbar's name.
+    Flyout(Vec<&'static str>, egui::Rect, String),
+    EditToolbar(String),
+    /// The title bar's ×.
+    Hide(String),
+    /// The title bar's double arrow: two columns (true) or one.
+    Columns(String, bool),
+}
+
+/// The toolbars made with New Toolbar that show, each floating where its title bar was dragged: the
+/// title bar's × hides it and its double arrow lays the tools out in one column or two; then its
+/// tool buttons (a group's flyout opens as the Tools panel's do) and the ••• button that opens the
+/// All Tools drawer beside it.
+pub(crate) fn show(app: &mut VectorcraftApp, ctx: &egui::Context) {
+    if app.ui.custom_toolbars.iter().all(|b| !b.shown) {
+        return;
+    }
+    let t = Tokens::get(ctx);
+    let screen = ctx.content_rect();
+    let active = app.session.tool_id();
+    let editing = drawer_target(&app.ui).map(str::to_string);
+    let held_id = egui::Id::unique("custom-toolbar-held");
+    if ctx.input(|i| i.pointer.any_pressed()) {
+        ctx.data_mut(|d| d.remove::<egui::Id>(held_id));
+    }
+    let held: Option<egui::Id> = ctx.data(|d| d.get_temp(held_id));
+    let mut actions = vec![];
+    // Where the drawer opens (the toolbar's bounds and its ••• button): beside the toolbar it
+    // edits, or beside the one whose ••• button was just clicked.
+    let (mut anchor, mut clicked_anchor) = (None, None);
+    for k in 0..app.ui.custom_toolbars.len() {
+        let Some(bar) = app.ui.custom_toolbars.get(k).filter(|b| b.shown).cloned() else { continue };
+        let id = area_id(&bar.name);
+        let cols = if bar.double { 2 } else { 1 };
+        let width = WIDTH + BUTTON * (cols - 1) as f32;
+        // Kept on screen by its title bar and first tools: the rest scrolls.
+        let size = ctx.memory(|m| m.area_rect(id)).map_or(vec2(width, 60.0), |r| vec2(r.width(), r.height().min(160.0)));
+        let pos = crate::tools_float::clamp(bar.pos, size, screen);
+        let edit_this = editing.as_deref() == Some(bar.name.as_str());
+        let mut moved = None;
+        let mut drops = None;
+        egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {
+            crate::toolbar::flyout_frame(&t, false).inner_margin(egui::Margin { left: 0, right: 0, top: 0, bottom: 4 }).show(ui, |ui| {
+                ui.set_width(width);
+                // The tools and the ••• button scroll when they don't fit in the window under the
+                // title bar.
+                ui.set_max_height(screen.bottom() - pos.y - 12.0);
+                ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
+                let middle = ui.max_rect().center().x;
+                // The title bar: × at its left end, the double arrow at its right end, and the grip
+                // between them, whose drag moves the toolbar.
+                let (title, _) = ui.allocate_exact_size(vec2(ui.available_width(), GRIP), Sense::hover());
+                ui.painter().rect_filled(title, 0.0, t.tab_strip);
+                let close = egui::Rect::from_min_size(title.min, vec2(TITLE_BUTTON, GRIP));
+                let arrows = egui::Rect::from_min_size(pos2(title.right() - TITLE_BUTTON, title.top()), vec2(TITLE_BUTTON, GRIP));
+                let grip = egui::Rect::from_min_max(pos2(close.right(), title.top()), pos2(arrows.left(), title.bottom()));
+                let cresp = ui.interact(close, id.with("close"), Sense::click());
+                icons::paint(ui, "x", close.shrink2(vec2(1.5, 2.5)), if cresp.hovered() { t.text_strong } else { t.text });
+                cresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Close")));
+                if cresp.on_hover_text(tl!("Close")).clicked() {
+                    actions.push(Action::Hide(bar.name.clone()));
+                }
+                let aresp = ui.interact(arrows, id.with("columns"), Sense::click());
+                let chevrons = if bar.double { "chevrons-left" } else { "chevrons-right" };
+                icons::paint(ui, chevrons, arrows.shrink2(vec2(1.0, 2.0)), if aresp.hovered() { t.text_strong } else { t.text });
+                aresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Toggle single/double column")));
+                if aresp.on_hover_text(tl!("Toggle single/double column")).clicked() {
+                    actions.push(Action::Columns(bar.name.clone(), !bar.double));
+                }
+                let gresp = ui.interact(grip, id.with("grip"), Sense::drag());
+                for n in 0..3 {
+                    let y = grip.center().y - 2.0 + n as f32 * 2.0;
+                    let half = (grip.width() / 2.0 - 2.0).clamp(0.0, 8.0);
+                    ui.painter().line_segment([pos2(grip.center().x - half, y), pos2(grip.center().x + half, y)], Stroke::new(0.5, t.text_disabled));
+                }
+                gresp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), &bar.name));
+                // Held where the press took it, from the toolbar's corner, while it is dragged.
+                let grab = id.with("grab");
+                if gresp.dragged() {
+                    ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
+                    let (origin, at) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos()));
+                    if let Some(at) = at {
+                        let off = ui.data_mut(|d| *d.get_temp_mut_or_insert_with(grab, || origin.unwrap_or(at) - pos));
+                        moved = Some(at - off);
+                    }
+                } else {
+                    ui.data_mut(|d| d.remove::<egui::Vec2>(grab));
+                    gresp.on_hover_cursor(egui::CursorIcon::Grab).on_hover_text(&bar.name);
+                }
+                // A gap keeps a press near the grip bar's lower edge on the bar.
+                ui.add_space(4.0);
+                let mut buttons: Vec<(usize, egui::Rect)> = vec![];
+                let edit = widgets::strip_scroll(ui, &format!("custom-toolbar-{}", key(&bar.name)), |ui| {
+                    let slots: Vec<(usize, &Vec<String>)> = bar.slots.iter().enumerate().collect();
+                    let press = Press { bar: &bar, active, edit_this, held, held_id };
+                    for row in slots.chunks(cols) {
+                        ui.horizontal(|ui| {
+                            ui.add_space((middle - BUTTON / 2.0 * row.len() as f32 - ui.cursor().min.x).max(0.0));
+                            for &(s, slot) in row {
+                                if let Some(rect) = slot_button(ui, &t, &press, slot, &mut actions)
+                                    && edit_this
+                                {
+                                    buttons.push((s, rect));
+                                }
+                            }
+                        });
+                    }
+                    ui.add_space(4.0);
+                    // Edit Toolbar: the All Tools drawer opens beside this toolbar, by this button.
+                    ui.vertical_centered(|ui| widgets::icon_button(ui, "ellipsis", "Edit Toolbar", edit_this, 26.0)).inner
+                });
+                if edit.clicked() {
+                    actions.push(Action::EditToolbar(bar.name.clone()));
+                }
+                // The toolbar as drawn (inside its border): where tools drop, and where a click
+                // leaves the drawer open; also on the click that opens the drawer here.
+                let bounds = ui.min_rect();
+                if edit.clicked() {
+                    clicked_anchor = Some((bounds, edit.rect));
+                } else if edit_this {
+                    anchor = Some((bounds, edit.rect));
+                }
+                if edit_this {
+                    drops = Some((buttons, bounds));
+                }
+            });
+            if let Some((buttons, bounds)) = drops.take() {
+                crate::edit_toolbar::toolbar_drop(app, ui, &buttons, cols, bounds, Some(&bar.name));
+            }
+        });
+        if let Some(p) = moved.filter(|p| p.x.is_finite() && p.y.is_finite())
+            && let Some(b) = app.ui.custom_toolbars.get_mut(k)
+        {
+            b.pos = [p.x, p.y];
+        }
+    }
+    if let Some((bounds, button)) = clicked_anchor.or(anchor) {
+        ctx.data_mut(|d| {
+            d.insert_temp(crate::edit_toolbar::toolbar_id(), bounds);
+            d.insert_temp(crate::edit_toolbar::button_id(), button);
+        });
+    }
+    for a in actions {
+        match a {
+            Action::Select(id) => app.select_tool(id),
+            Action::Options(id) => {
+                app.select_tool(id);
+                app.run("tool.options", json!({ "tool": id })).ok();
+            }
+            // A group that floats already (one strip per group) is raised instead, as the Tools
+            // panel's buttons do.
+            Action::Flyout(tools, _, name) if tools.first().is_some_and(|key| crate::toolbar::is_floating(app, Some(&name), key)) => {
+                if let Some(key) = tools.first() {
+                    crate::toolbar::raise_floating(ctx, Some(&name), key);
+                }
+            }
+            Action::Flyout(tools, rect, name) => {
+                app.ui.flyout = Some(0);
+                ctx.data_mut(|d| {
+                    d.insert_temp(egui::Id::unique("flyout-anchor"), rect);
+                    d.insert_temp(egui::Id::unique("flyout-tools"), tools.iter().map(|s| s.to_string()).collect::<Vec<String>>());
+                    d.insert_temp(crate::toolbar::flyout_toolbar_id(), Some(name));
+                });
+            }
+            Action::EditToolbar(name) => {
+                if let Err(e) = app.run("window.editToolbar", json!({ "toolbar": name })) {
+                    app.status(e);
+                }
+            }
+            Action::Hide(name) => {
+                if let Err(e) = app.run("window.toolbar.custom", json!({ "name": name, "show": false })) {
+                    app.status(e);
+                }
+            }
+            Action::Columns(name, double) => {
+                if let Err(e) = app.run("window.toolbarColumns", json!({ "toolbar": name, "double": double })) {
+                    app.status(e);
+                }
+            }
+        }
+    }
+}
+
+/// What a toolbar's tool buttons need from the frame: the toolbar, the active tool, whether the
+/// drawer edits the toolbar, and the button whose long press opened its flyout.
+struct Press<'a> {
+    bar: &'a CustomToolbar,
+    active: &'static str,
+    edit_this: bool,
+    held: Option<egui::Id>,
+    held_id: egui::Id,
+}
+
+/// One slot's tool button, as the Tools panel's: it shows the tool chosen last from its group, a
+/// press selects it, and a long or right press opens the group's flyout; while the drawer edits the
+/// toolbar, it drags instead. Returns where it is drawn.
+fn slot_button(ui: &mut egui::Ui, t: &Tokens, p: &Press, slot: &[String], actions: &mut Vec<Action>) -> Option<egui::Rect> {
+    let tools: Vec<&'static str> = slot.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect();
+    let &first = tools.first()?;
+    let is_active = tools.contains(&p.active);
+    let shown_id = if is_active { p.active } else { p.bar.slot_tool.get(first).and_then(|id| tool_info(id)).map_or(first, |t| t.id) };
+    let shown = tool_info(shown_id).filter(|t| tools.contains(&t.id)).or_else(|| tool_info(first))?;
+    let (rect, resp) = ui.allocate_exact_size(vec2(BUTTON, crate::toolbar::PITCH - 1.0), Sense::click_and_drag());
+    let well = egui::Rect::from_center_size(rect.center(), vec2(30.0, 27.5));
+    if is_active {
+        ui.painter().rect_filled(well, CornerRadius::same(6), t.tool_active);
+    } else if resp.hovered() {
+        ui.painter().rect_filled(well, CornerRadius::same(6), t.hover);
+    }
+    let ir = egui::Rect::from_center_size(rect.center(), vec2(18.0, 18.0));
+    icons::paint(ui, icons::tool_icon(shown.icon), ir, if is_active { t.text_strong } else { t.icon });
+    if tools.len() > 1 {
+        let c = rect.center() + vec2(12.5, 10.0);
+        ui.painter().add(egui::Shape::convex_polygon(vec![c, c + vec2(-3.5, 0.0), c + vec2(0.0, -3.5)], t.icon, Stroke::NONE));
+    }
+    if p.edit_this {
+        resp.dnd_set_drag_payload(crate::edit_toolbar::ToolDrag { tool: shown.id, from_toolbar: true });
+    }
+    let press = if tools.len() > 1 && !p.edit_this { crate::toolbar::flyout_press(ui, &resp, rect) } else { None };
+    let alt = ui.input(|i| i.modifiers.alt);
+    if press.is_some() {
+        actions.push(Action::Flyout(tools.clone(), rect, p.bar.name.clone()));
+        if press == Some(crate::toolbar::FlyoutPress::Primary) {
+            ui.data_mut(|d| d.insert_temp(p.held_id, resp.id));
+        }
+    } else if resp.clicked() && p.held == Some(resp.id) {
+        // Releasing the long press that opened the flyout leaves it open.
+    } else if resp.clicked() && alt && tools.len() > 1 {
+        let i = tools.iter().position(|x| *x == shown.id).unwrap_or(0);
+        actions.push(Action::Select(tools[(i + 1) % tools.len()]));
+    } else if resp.double_clicked() {
+        actions.push(Action::Options(shown.id));
+    } else if resp.clicked() {
+        actions.push(Action::Select(shown.id));
+    }
+    let tip = crate::toolbar::tip(shown);
+    resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::Button, ui.is_enabled(), is_active, &tip));
+    resp.on_hover_text(tip);
+    Some(rect)
+}
+
+/// Remember `id` as the tool shown by each slot of a toolbar made with New Toolbar that holds it,
+/// however it was chosen (`toolbar::remember` calls it).
+pub(crate) fn remember(ui: &mut UiState, id: &str) {
+    for bar in &mut ui.custom_toolbars {
+        let firsts: Vec<String> = bar.slots.iter().filter(|s| s.len() > 1 && s.iter().any(|t| t == id)).filter_map(|s| s.first().cloned()).collect();
+        for first in firsts {
+            bar.slot_tool.insert(first, id.to_string());
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use vectorcraft_engine::Session;
+
+    use super::*;
+    use crate::menus::Item;
+
+    fn app() -> VectorcraftApp {
+        VectorcraftApp::new(Session::new(), Default::default())
+    }
+
+    /// The items of Window › Toolbars: (label, command id, params).
+    fn toolbars_menu(app: &VectorcraftApp) -> Vec<(&'static str, &'static str, Value)> {
+        crate::shortcut_editor::sync(&app.ui);
+        let window = crate::menus::menu_tree().into_iter().find(|(m, _)| *m == "Window").map(|(_, items)| items).unwrap();
+        let Some(Item::Sub(_, items)) = window.into_iter().find(|i| matches!(i, Item::Sub("Toolbars", _))) else { panic!("no Toolbars submenu") };
+        items.into_iter().filter_map(|i| if let Item::Cmd(l, id, p) = i { Some((l, id, p)) } else { None }).collect()
+    }
+
+    /// New Toolbar named "Mine" is listed under Window › Toolbars, checked while it shows; its item
+    /// hides and shows it. Without a name, a dialog asks for one.
+    #[test]
+    fn a_new_toolbar_is_listed_under_window_toolbars() {
+        let mut app = app();
+        assert_eq!(toolbars_menu(&app).last().map(|(l, id, _)| (*l, *id)), Some(("New Toolbar…", "window.toolbar.new")));
+        assert_eq!(app.run("window.toolbar.new", json!({"name": " Mine "})).unwrap(), json!({"toolbar": "Mine"}));
+        let menu = toolbars_menu(&app);
+        let item = menu.iter().find(|(l, ..)| *l == "Mine").expect("Mine is listed");
+        assert_eq!((item.1, &item.2), ("window.toolbar.custom", &json!({"name": "Mine"})));
+        assert_eq!(crate::menus::checked(&app, item.1, &item.2), Some(true));
+        assert_eq!(crate::menus::display_label(&app, item.1, item.0), "Mine", "listed by its own name");
+        // A blank toolbar, floating, shown.
+        let bar = find(&app.ui, "mine").unwrap();
+        assert!(bar.slots.is_empty() && bar.shown);
+        // Its item hides it and shows it again.
+        assert_eq!(app.run("window.toolbar.custom", json!({"name": "Mine"})).unwrap(), json!(false));
+        assert_eq!(crate::menus::checked(&app, item.1, &item.2), Some(false));
+        assert_eq!(app.run("window.toolbar.custom", json!({"name": "MINE", "show": true})).unwrap(), json!(true));
+        // Names: not empty, not taken whatever the case.
+        assert!(app.run("window.toolbar.new", json!({"name": "  "})).unwrap_err().contains("empty"));
+        assert!(app.run("window.toolbar.new", json!({"name": "mine"})).unwrap_err().contains("already"));
+        // Whatever the case outside ASCII too: one name, one toolbar.
+        app.run("window.toolbar.new", json!({"name": "É"})).unwrap();
+        assert!(app.run("window.toolbar.new", json!({"name": "é"})).unwrap_err().contains("already"));
+        assert!(app.run("window.toolbar.custom", json!({"name": "é", "show": true})).is_ok());
+        app.ui.custom_toolbars.retain(|b| b.name != "É");
+        assert!(app.run("window.toolbar.custom", json!({"name": "Nope"})).unwrap_err().contains("Nope"));
+        // A name is looked up without its leading and trailing spaces, as it was made.
+        assert_eq!(app.run("window.toolbar.custom", json!({"name": " Mine ", "show": true})).unwrap(), json!(true));
+        assert!(app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine "})).is_ok());
+        assert!(app.run("toolbar.removeTool", json!({"tool": "pen", "toolbar": "Mine"})).is_ok());
+        // A name that is not a string is reported as one.
+        assert!(app.run("window.toolbar.new", json!({"name": 5})).unwrap_err().contains("must be a string"));
+        assert!(app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": 5})).unwrap_err().contains("must be a string"));
+        // Without a name: the dialog, its OK makes the toolbar.
+        assert_eq!(app.run("window.toolbar.new", json!({})).unwrap(), Value::Null);
+        let d = app.ui.dialog.clone().unwrap();
+        assert_eq!((d.kind.as_str(), d.str("name").as_str()), (KIND, "Toolbar 1"));
+        crate::dialogs::confirm(&mut app).unwrap();
+        assert!(app.ui.dialog.is_none());
+        let names: Vec<&str> = toolbars_menu(&app).into_iter().filter(|(_, id, _)| *id == "window.toolbar.custom").map(|(l, ..)| l).collect();
+        assert_eq!(names, ["Mine", "Toolbar 1"]);
+        let list = app.run("window.toolbar.list", json!({})).unwrap();
+        assert_eq!(
+            list,
+            json!([{"name": "Mine", "shown": true, "double": false, "slots": []}, {"name": "Toolbar 1", "shown": true, "double": false, "slots": []}])
+        );
+    }
+
+    /// The New Toolbar dialog: a name typed in it and Enter make the toolbar; a name that is taken is
+    /// reported in the status line and the dialog stays open; OK makes it once the name is free, and
+    /// Cancel closes the dialog without a toolbar.
+    #[test]
+    fn the_new_toolbar_dialog_takes_the_name_typed() {
+        let mut app = app();
+        let ctx = egui::Context::default();
+        crate::theme::install_fonts(&ctx);
+        let frame = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
+            let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0));
+            let mut out = ctx.run_ui(egui::RawInput { events, screen_rect: Some(screen), ..Default::default() }, |ui| show_dialog(app, ui.ctx()));
+            out.textures_delta.clear();
+            out.shapes
+                .iter()
+                .filter_map(
+                    |c| if let egui::Shape::Text(t) = &c.shape { Some((t.galley.text().to_string(), t.visual_bounding_rect())) } else { None },
+                )
+                .collect::<Vec<_>>()
+        };
+        let key = |key, modifiers| egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers };
+        let button =
+            |at, pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
+        let at =
+            |texts: &[(String, egui::Rect)], s: &str| texts.iter().find(|(t, _)| t == s).unwrap_or_else(|| panic!("no {s} in the dialog")).1.center();
+        // Typed into the Name field, then Enter. The dialog is sized in its first frame and drawn
+        // from the second.
+        app.run("window.toolbar.new", json!({})).unwrap();
+        frame(&mut app, vec![]);
+        let texts = frame(&mut app, vec![]);
+        let field = at(&texts, "Toolbar 1");
+        frame(&mut app, vec![egui::Event::PointerMoved(field), button(field, true)]);
+        frame(&mut app, vec![button(field, false)]);
+        frame(&mut app, vec![key(egui::Key::A, egui::Modifiers::COMMAND), egui::Event::Text("Mine".into())]);
+        assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("name")).as_deref(), Some("Mine"));
+        frame(&mut app, vec![key(egui::Key::Enter, egui::Modifiers::NONE)]);
+        assert!(app.ui.dialog.is_none(), "Enter makes the toolbar");
+        assert!(find(&app.ui, "Mine").is_some());
+        // A name that is taken: the status line says so, and the dialog stays open.
+        app.run("window.toolbar.new", json!({})).unwrap();
+        app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("MINE"));
+        frame(&mut app, vec![]);
+        let texts = frame(&mut app, vec![]);
+        let ok = at(&texts, "OK");
+        frame(&mut app, vec![egui::Event::PointerMoved(ok), button(ok, true)]);
+        frame(&mut app, vec![button(ok, false)]);
+        assert!(app.ui.status.contains("already"), "{}", app.ui.status);
+        assert!(app.ui.dialog.is_some(), "the dialog stays open");
+        // OK with a free name makes it.
+        app.ui.dialog.as_mut().unwrap().fields.insert("name".into(), json!("Other"));
+        frame(&mut app, vec![egui::Event::PointerMoved(ok), button(ok, true)]);
+        frame(&mut app, vec![button(ok, false)]);
+        assert!(app.ui.dialog.is_none() && find(&app.ui, "Other").is_some());
+        // Cancel makes none.
+        app.run("window.toolbar.new", json!({})).unwrap();
+        frame(&mut app, vec![]);
+        let texts = frame(&mut app, vec![]);
+        let cancel = at(&texts, "Cancel");
+        frame(&mut app, vec![egui::Event::PointerMoved(cancel), button(cancel, true)]);
+        frame(&mut app, vec![button(cancel, false)]);
+        assert!(app.ui.dialog.is_none());
+        assert_eq!(app.ui.custom_toolbars.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Mine", "Other"]);
+    }
+
+    /// Switching workspaces while the drawer edits a toolbar of the workspace left closes the
+    /// drawer, so it names no toolbar that the new workspace lacks.
+    #[test]
+    fn switching_workspaces_closes_the_drawer_on_a_toolbar_left() {
+        let mut app = app();
+        app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap();
+        app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
+        app.run("window.editToolbar", json!({"toolbar": "Mine", "open": true})).unwrap();
+        assert_eq!(drawer_target(&app.ui), Some("Mine"));
+        app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
+        assert!(!app.ui.tools_drawer && app.ui.drawer_toolbar.is_none());
+        assert!(app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).is_err(), "Mine is not in Essentials");
+    }
+
+    /// Tools added to a toolbar made with New Toolbar leave the Tools panel's toolbar as it was, and
+    /// the toolbar comes back with them after switching workspaces and after a restart.
+    #[test]
+    fn tools_added_to_a_new_toolbar_survive_a_workspace_reload() {
+        let mut app = app();
+        let shipped = crate::toolbar::slots(&app);
+        app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap();
+        app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
+        app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();
+        let r = app.run("toolbar.addTool", json!({"tool": "curvature", "toolbar": "mine", "slot": 0, "place": "group"})).unwrap();
+        assert_eq!(r, json!([["pen", "curvature"]]));
+        let r = app.run("toolbar.addTool", json!({"tool": "artboard", "toolbar": "Mine", "slot": 0})).unwrap();
+        assert_eq!(r, json!([["artboard"], ["pen", "curvature"]]));
+        assert_eq!(crate::toolbar::slots(&app), shipped, "the Tools panel's toolbar is untouched");
+        assert!(app.ui.toolbar_edits.basic.is_none());
+        assert!(app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Nope"})).is_err());
+        let r = app.run("toolbar.removeTool", json!({"tool": "artboard", "toolbar": "Mine"})).unwrap();
+        assert_eq!(r, json!([["pen", "curvature"]]));
+        let mine = find(&app.ui, "Mine").cloned().unwrap();
+        // Another workspace has its own toolbars (none); coming back brings Mine back.
+        app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
+        assert!(app.ui.custom_toolbars.is_empty());
+        assert!(toolbars_menu(&app).iter().all(|(l, ..)| *l != "Mine"));
+        app.run("window.workspace", json!({"name": "Drawing"})).unwrap();
+        assert_eq!(find(&app.ui, "Mine"), Some(&mine));
+        // Saved under a workspace and in the preferences, it loads again.
+        app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap();
+        let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
+        let mut ui = prefs.sanitized();
+        assert_eq!(find(&ui, "Mine"), Some(&mine));
+        crate::workspaces::switch(&mut ui, "Essentials").unwrap();
+        crate::workspaces::switch(&mut ui, "Drawing").unwrap();
+        assert_eq!(find(&ui, "Mine"), Some(&mine));
+        // Reset makes it blank again.
+        assert_eq!(app.run("toolbar.reset", json!({"toolbar": "Mine"})).unwrap(), json!([]));
+        // Workspaces saved before New Toolbar load without toolbars.
+        let mut old = serde_json::to_value(crate::workspaces::capture(&app.ui, "Old")).unwrap();
+        old.as_object_mut().unwrap().remove("toolbars");
+        assert!(serde_json::from_value::<crate::workspaces::Workspace>(old).unwrap().toolbars.is_empty());
+    }
+
+    /// One frame of the whole window, `height` points tall.
+    fn window_frame(app: &mut VectorcraftApp, ctx: &egui::Context, time: f64, height: f32) {
+        let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, height));
+        let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), ..Default::default() };
+        ctx.run_ui(raw, |ui| {
+            app.logic(ui.ctx());
+            app.ui(ui);
+        })
+        .textures_delta
+        .clear();
+    }
+
+    fn drawn(ctx: &egui::Context, id: egui::Id, order: egui::Order) -> bool {
+        ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(order, id)))
+    }
+
+    /// Window › Tools hides the Tools panel alone: a toolbar made with New Toolbar still shows, and
+    /// the drawer opens beside it. Tab hides it with the other panels, and a second Tab shows it.
+    #[test]
+    fn a_new_toolbar_shows_without_the_tools_panel_until_tab() {
+        let mut app = app();
+        app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
+        let ctx = egui::Context::default();
+        app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
+        app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();
+        let mut time = 0.0;
+        let mut frames = |app: &mut VectorcraftApp| {
+            for _ in 0..3 {
+                time += 0.05;
+                window_frame(app, &ctx, time, 800.0);
+            }
+        };
+        frames(&mut app);
+        assert!(drawn(&ctx, area_id("Mine"), egui::Order::Middle));
+        app.run("window.toolbar", json!({})).unwrap();
+        assert!(!app.ui.toolbar);
+        app.run("window.editToolbar", json!({"toolbar": "Mine"})).unwrap();
+        frames(&mut app);
+        assert!(drawn(&ctx, area_id("Mine"), egui::Order::Middle), "shown without the Tools panel");
+        assert!(drawn(&ctx, crate::edit_toolbar::drawer_id(), egui::Order::Foreground), "the drawer beside it");
+        app.run("window.editToolbar", json!({"open": false})).unwrap();
+        // A group torn off this toolbar shows too; one torn off the Tools panel stays hidden with it.
+        let strip = |tools: &[&str]| crate::state::FloatingFlyout { tools: tools.iter().map(|s| s.to_string()).collect(), pos: [400.0, 300.0] };
+        find_mut(&mut app.ui, "Mine").unwrap().floating = vec![strip(&["pen", "curvature"])];
+        app.ui.floating_flyouts = vec![strip(&["rectangle", "star"])];
+        frames(&mut app);
+        assert!(drawn(&ctx, egui::Id::unique(("tool-floating", "pen")).with(key("Mine")), egui::Order::Middle));
+        assert!(!drawn(&ctx, egui::Id::unique(("tool-floating", "rectangle")), egui::Order::Middle));
+        // Hidden with the toolbar it was torn off, and shown again with it.
+        app.run("window.toolbar.custom", json!({"name": "Mine", "show": false})).unwrap();
+        frames(&mut app);
+        assert!(!drawn(&ctx, egui::Id::unique(("tool-floating", "pen")).with(key("Mine")), egui::Order::Middle));
+        app.run("window.toolbar.custom", json!({"name": "Mine", "show": true})).unwrap();
+        frames(&mut app);
+        assert!(drawn(&ctx, egui::Id::unique(("tool-floating", "pen")).with(key("Mine")), egui::Order::Middle));
+        app.run("window.dock", json!({})).unwrap();
+        frames(&mut app);
+        assert!(!drawn(&ctx, area_id("Mine"), egui::Order::Middle), "Tab hides it");
+        app.run("window.dock", json!({})).unwrap();
+        frames(&mut app);
+        assert!(drawn(&ctx, area_id("Mine"), egui::Order::Middle), "a second Tab shows it");
+    }
+
+    /// A group's button shows the tool chosen last from it however it was chosen (its flyout, a
+    /// shortcut), and the Tools panel's buttons keep their own.
+    #[test]
+    fn a_group_shows_the_tool_chosen_last() {
+        let mut app = app();
+        app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
+        app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();
+        app.run("toolbar.addTool", json!({"tool": "curvature", "toolbar": "Mine", "slot": 0, "place": "group"})).unwrap();
+        app.select_tool("curvature");
+        app.select_tool("selection");
+        assert_eq!(find(&app.ui, "Mine").unwrap().slot_tool.get("pen").map(String::as_str), Some("curvature"));
+        assert_ne!(app.ui.slot_tool.get("pen").map(String::as_str), Some("curvature"), "the Tools panel's Pen group holds no Curvature tool");
+        // Kept with the toolbar.
+        let prefs: UiState = serde_json::from_slice(&serde_json::to_vec(&app.ui).unwrap()).unwrap();
+        assert_eq!(find(&prefs.sanitized(), "Mine").unwrap().slot_tool.get("pen").map(String::as_str), Some("curvature"));
+    }
+
+    /// A group of a toolbar made with New Toolbar floats on its own, apart from a Tools panel group
+    /// with the same first tool: both strips show, and each toolbar finds only its own.
+    #[test]
+    fn groups_float_apart_from_the_tools_panels() {
+        let mut app = app();
+        app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
+        let ctx = egui::Context::default();
+        app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
+        app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();
+        app.run("toolbar.addTool", json!({"tool": "curvature", "toolbar": "Mine", "slot": 0, "place": "group"})).unwrap();
+        app.run("window.floatTools", json!({"tool": "pen", "floating": true})).unwrap();
+        assert!(crate::toolbar::is_floating(&app, None, "pen"));
+        assert!(!crate::toolbar::is_floating(&app, Some("Mine"), "pen"), "the Tools panel's Pen group floats, not Mine's");
+        assert_eq!(app.run("window.floatTools", json!({"tool": "curvature", "toolbar": "mine"})).unwrap(), json!(true));
+        assert!(crate::toolbar::is_floating(&app, Some("Mine"), "pen"));
+        assert_eq!(crate::menus::checked(&app, "window.floatTools", &json!({"tool": "curvature", "toolbar": "Mine"})), Some(true));
+        assert!(app.run("window.floatTools", json!({"tool": "selection", "toolbar": "Mine"})).is_err(), "not in a group of Mine");
+        assert!(app.run("window.floatTools", json!({"tool": "pen", "toolbar": "Nope"})).is_err());
+        // Both kept by the preferences, Mine's with Mine: a build without New Toolbar reads the
+        // Tools panel's strips alone. Both are drawn.
+        let saved = serde_json::to_value(&app.ui).unwrap();
+        let tools_panel = &app.ui.floating_flyouts[0].tools;
+        assert_eq!(saved["floating_flyouts"], json!([{"tools": tools_panel, "pos": [90.0, 100.0]}]));
+        let prefs = serde_json::from_value::<UiState>(saved).unwrap().sanitized();
+        assert_eq!(prefs.floating_flyouts.len(), 1);
+        assert_eq!(find(&prefs, "Mine").unwrap().floating.iter().map(|f| f.tools.clone()).collect::<Vec<_>>(), [["pen", "curvature"]]);
+        for k in 0..3 {
+            window_frame(&mut app, &ctx, f64::from(k) * 0.05, 800.0);
+        }
+        let main = egui::Id::unique(("tool-floating", "pen"));
+        assert!(drawn(&ctx, main, egui::Order::Middle) && drawn(&ctx, main.with(key("Mine")), egui::Order::Middle));
+        // Putting the Tools panel's group back leaves Mine's floating.
+        app.run("window.floatTools", json!({"tool": "pen", "floating": false})).unwrap();
+        assert!(app.ui.floating_flyouts.is_empty() && find(&app.ui, "Mine").unwrap().floating.len() == 1);
+        assert_eq!(crate::menus::checked(&app, "window.floatTools", &json!({"tool": "pen"})), Some(false));
+        // And Mine's goes back by its command too.
+        assert_eq!(app.run("window.floatTools", json!({"tool": "pen", "toolbar": "Mine"})).unwrap(), json!(false));
+        assert!(find(&app.ui, "Mine").unwrap().floating.is_empty());
+    }
+
+    /// A toolbar with more tools than the window's height holds stays in the window: its tools
+    /// scroll.
+    #[test]
+    fn a_tall_toolbar_scrolls_inside_the_window() {
+        let mut app = app();
+        let ctx = egui::Context::default();
+        app.run("window.toolbar.new", json!({"name": "Tall"})).unwrap();
+        for tool in vectorcraft_tools::TOOL_GROUPS.iter().flat_map(|g| g.iter()).take(30) {
+            app.run("toolbar.addTool", json!({"tool": tool.id, "toolbar": "Tall"})).unwrap();
+        }
+        assert_eq!(find(&app.ui, "Tall").unwrap().slots.len(), 30);
+        for k in 0..4 {
+            window_frame(&mut app, &ctx, f64::from(k) * 0.05, 500.0);
+        }
+        let r = ctx.memory(|m| m.area_rect(area_id("Tall"))).unwrap();
+        assert!(r.bottom() <= 500.0 && r.top() >= 0.0, "{r:?}");
+    }
+
+    /// Preferences › Devices › Enable Touch Workspace switches to Touch and back as Window ›
+    /// Workspace does: the toolbars made in the workspace left come back with it.
+    #[test]
+    fn the_touch_workspace_brings_the_toolbars_back() {
+        let mut app = app();
+        app.run("window.workspace", json!({"name": "Typography"})).unwrap();
+        app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
+        app.session.prefs.touch_workspace = true;
+        crate::workspaces::sync_touch_workspace(&mut app, true);
+        assert_eq!(app.ui.workspace, crate::workspaces::TOUCH);
+        assert!(app.ui.custom_toolbars.is_empty());
+        app.session.prefs.touch_workspace = false;
+        crate::workspaces::sync_touch_workspace(&mut app, true);
+        assert_eq!(app.ui.workspace, "Typography");
+        assert!(find(&app.ui, "Mine").is_some(), "back with Typography");
+    }
+
+    /// Reset Workspace keeps the toolbars made in the workspace, in a built-in one too, and New
+    /// Workspace leaves the workspace it starts from with its toolbars: the new one starts with a
+    /// copy, and switching back brings the first workspace's own back.
+    #[test]
+    fn toolbars_stay_in_their_workspace_through_reset_and_new_workspace() {
+        let mut app = app();
+        app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
+        app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();
+        app.run("window.toolbar", json!({})).unwrap();
+        app.run("window.workspace.reset", json!({})).unwrap();
+        assert!(app.ui.toolbar, "the layout is reset");
+        assert_eq!(find(&app.ui, "Mine").map(|b| b.slots.clone()), Some(vec![vec!["pen".to_string()]]), "Mine stays in Essentials");
+        let mine = find(&app.ui, "Mine").cloned().unwrap();
+        // The layout changes go into the new workspace; Essentials keeps only its toolbars.
+        app.run("window.toolbar", json!({})).unwrap();
+        app.run("window.workspace.new", json!({"name": "Drawing"})).unwrap();
+        assert_eq!(find(&app.ui, "Mine"), Some(&mine), "Drawing starts with a copy");
+        app.run("window.toolbar.new", json!({"name": "Only in Drawing"})).unwrap();
+        app.run("window.workspace", json!({"name": "Essentials"})).unwrap();
+        assert!(app.ui.toolbar, "Essentials as saved");
+        assert_eq!(app.ui.custom_toolbars, std::slice::from_ref(&mine), "with its own toolbars");
+        app.run("window.workspace", json!({"name": "Drawing"})).unwrap();
+        assert_eq!(app.ui.custom_toolbars.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["Mine", "Only in Drawing"]);
+    }
+
+    /// The title bar's × hides the toolbar, and its double arrow lays its tools out in two columns
+    /// and back in one (`window.toolbarColumns` with `toolbar`).
+    #[test]
+    fn the_title_bar_hides_the_toolbar_and_toggles_two_columns() {
+        let mut app = app();
+        app.run("file.new", json!({"width": 300, "height": 300})).unwrap();
+        let ctx = egui::Context::default();
+        app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
+        for tool in ["pen", "rectangle", "selection"] {
+            app.run("toolbar.addTool", json!({"tool": tool, "toolbar": "Mine"})).unwrap();
+        }
+        let mut time = 0.0;
+        let mut frames = |app: &mut VectorcraftApp, events: Vec<egui::Event>| {
+            for e in std::iter::once(events).chain(std::iter::repeat_with(Vec::new)).take(3) {
+                time += 0.05;
+                let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, vec2(1200.0, 800.0));
+                let raw = egui::RawInput { time: Some(time), screen_rect: Some(screen), events: e, ..Default::default() };
+                ctx.run_ui(raw, |ui| {
+                    app.logic(ui.ctx());
+                    app.ui(ui);
+                })
+                .textures_delta
+                .clear();
+            }
+        };
+        let click = |p: egui::Pos2| {
+            let button =
+                |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
+            vec![egui::Event::PointerMoved(p), button(true), button(false)]
+        };
+        frames(&mut app, vec![]);
+        let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap();
+        let one = r.width();
+        // The double arrow at the right end of the title bar.
+        frames(&mut app, click(pos2(r.right() - 1.0 - TITLE_BUTTON / 2.0, r.top() + 1.0 + GRIP / 2.0)));
+        assert!(find(&app.ui, "Mine").unwrap().double);
+        assert_eq!(crate::menus::checked(&app, "window.toolbarColumns", &json!({"toolbar": "Mine"})), Some(true));
+        assert!(!app.ui.toolbar_double, "the Tools panel keeps one column");
+        frames(&mut app, vec![]);
+        let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap();
+        assert!((r.width() - one - BUTTON).abs() < 0.5, "{one} then {}", r.width());
+        assert_eq!(app.run("window.toolbarColumns", json!({"toolbar": "mine"})).unwrap(), json!(false));
+        assert!(app.run("window.toolbarColumns", json!({"toolbar": "Nope"})).is_err());
+        // The × at the left end hides it.
+        frames(&mut app, vec![]);
+        let r = ctx.memory(|m| m.area_rect(area_id("Mine"))).unwrap();
+        frames(&mut app, click(pos2(r.left() + 1.0 + TITLE_BUTTON / 2.0, r.top() + 1.0 + GRIP / 2.0)));
+        assert!(!find(&app.ui, "Mine").unwrap().shown);
+        assert!(!drawn(&ctx, area_id("Mine"), egui::Order::Middle));
+    }
+
+    /// A hand-edited file: unnamed toolbars and repeated names go, unknown tools go, each tool once.
+    #[test]
+    fn toolbars_load_sanitized() {
+        let bar = |name: &str, slots: &[&[&str]]| CustomToolbar {
+            name: name.into(),
+            slots: slots.iter().map(|s| s.iter().map(|id| id.to_string()).collect()).collect(),
+            shown: true,
+            pos: [f32::NAN, 0.0],
+            slot_tool: Default::default(),
+            double: false,
+            floating: ["pen", "nope", "pen"]
+                .iter()
+                .map(|id| crate::state::FloatingFlyout { tools: vec![id.to_string(), "curvature".into()], pos: [0.0, 0.0] })
+                .collect(),
+        };
+        let mut bars = vec![bar("A", &[&["pen", "nope"], &["pen"], &["nope"]]), bar("a", &[]), bar(" ", &[]), bar("B", &[])];
+        sanitize(&mut bars);
+        assert_eq!(bars.iter().map(|b| b.name.as_str()).collect::<Vec<_>>(), ["A", "B"]);
+        assert_eq!(bars[0].slots, [["pen"]]);
+        assert_eq!(bars[0].pos, NEW_AT);
+        // One strip per group, of known tools.
+        assert_eq!(bars[0].floating.iter().map(|f| f.tools.join(",")).collect::<Vec<_>>(), ["pen,curvature", "curvature"]);
+    }
+}
diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs
--- a/crates/ui-egui/src/dialogs.rs
+++ b/crates/ui-egui/src/dialogs.rs
@@ -277,6 +277,7 @@ registry! {
     Preferences: ["preferences"] => DialogSpec::window(crate::prefs_dialog::show, |app, _| crate::prefs_dialog::confirm(app)),
     Shortcuts: ["shortcuts"] => DialogSpec::window(crate::shortcut_editor::show, |app, _| crate::shortcut_editor::confirm(app)),
     Workspaces: ["newWorkspace", "manageWorkspaces"] => DialogSpec::window(crate::workspaces::show, |app, _| crate::workspaces::confirm(app)),
+    NewToolbar: [crate::custom_toolbars::KIND] => DialogSpec::window(crate::custom_toolbars::show_dialog, |app, _| crate::custom_toolbars::confirm(app)),
     FindFont: ["findFont"] => DialogSpec::window(crate::find_font::show, |app, _| crate::find_font::confirm(app)),
     SwatchOptions: [swatch_options::KIND] => swatch_options::SPEC,
     Confirm: [confirm::KIND] => confirm::SPEC,
diff --git a/crates/ui-egui/src/edit_toolbar.rs b/crates/ui-egui/src/edit_toolbar.rs
--- a/crates/ui-egui/src/edit_toolbar.rs
+++ b/crates/ui-egui/src/edit_toolbar.rs
@@ -11,7 +11,9 @@
 //! is open, and the ones already in the toolbar show dimmed in it).
 //!
 //! Each toolbar (Basic, Advanced) keeps its own edits in `UiState::toolbar_edits`, saved with the
-//! preferences; `toolbar.addTool`, `toolbar.removeTool` and `toolbar.reset` make them.
+//! preferences; `toolbar.addTool`, `toolbar.removeTool` and `toolbar.reset` make them. With their
+//! `toolbar` param they edit a toolbar made with New Toolbar instead (`custom_toolbars`), whose •••
+//! button opens the drawer beside it.
 
 use std::collections::BTreeSet;
 use std::sync::OnceLock;
@@ -154,11 +156,40 @@ fn edit(app: &mut VectorcraftApp, f: impl FnOnce(&mut Vec<ToolbarCategory>) -> R
     Ok(slots_json(app))
 }
 
+/// Change the toolbar made with New Toolbar called `name` with `f`. Returns its slots.
+fn edit_custom(app: &mut VectorcraftApp, name: &str, f: impl FnOnce(&mut Vec<ToolbarCategory>) -> Result<(), String>) -> Result<Value, String> {
+    let bar = crate::custom_toolbars::find_mut(&mut app.ui, name).ok_or_else(|| format!("no toolbar named `{name}`"))?;
+    let mut cats = vec![ToolbarCategory { name: None, slots: bar.slots.clone() }];
+    f(&mut cats)?;
+    prune(&mut cats);
+    bar.slots = cats.into_iter().flat_map(|c| c.slots).collect();
+    Ok(json!(bar.slots))
+}
+
+/// Change the toolbar the `toolbar` param names (none: the current Basic or Advanced toolbar).
+fn edit_target(app: &mut VectorcraftApp, p: &Value, f: impl FnOnce(&mut Vec<ToolbarCategory>, bool) -> Result<(), String>) -> Result<Value, String> {
+    match crate::custom_toolbars::target(&app.ui, p)? {
+        Some(name) => edit_custom(app, &name, |cats| f(cats, false)),
+        None => {
+            let named = !app.ui.toolbar_advanced;
+            edit(app, |cats| f(cats, named))
+        }
+    }
+}
+
 /// The current toolbar's slots, each a list of tool ids (`toolbar.*` commands return it).
 fn slots_json(app: &VectorcraftApp) -> Value {
     json!(crate::toolbar::slots(app).into_iter().map(|(_, s)| s).collect::<Vec<_>>())
 }
 
+/// The tools of the toolbar the drawer edits: one made with New Toolbar, or the Tools panel's.
+fn target_tools(app: &VectorcraftApp) -> BTreeSet<&'static str> {
+    match crate::custom_toolbars::drawer_target(&app.ui).and_then(|name| crate::custom_toolbars::find(&app.ui, name)) {
+        Some(bar) => bar.slots.iter().flatten().filter_map(|id| tool_info(id).map(|t| t.id)).collect(),
+        None => crate::toolbar::slots(app).into_iter().flat_map(|(_, s)| s).collect(),
+    }
+}
+
 /// Put `tool` by the slot numbered `slot` (over the toolbar's slots, 0 = the first; none or null:
 /// at the end), moving it if it is in the toolbar already. A tool appended to a toolbar left without
 /// categories starts one (`named`: under its own heading).
@@ -222,24 +253,36 @@ fn tool_param(p: &Value) -> Result<&'static str, String> {
 /// The Edit Toolbar commands; `None` for other ids.
 pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<Result<Value, String>> {
     Some(match id {
-        "window.editToolbar" => crate::menus::opt_bool(p, "open").map(|open| {
-            let open = open.unwrap_or(!app.ui.tools_drawer);
+        "window.editToolbar" => (|| {
+            let open = crate::menus::opt_bool(p, "open")?;
+            let target = crate::custom_toolbars::target(&app.ui, p)?;
+            // Toggled: open, or open beside another toolbar, closes or moves it.
+            let open = open.unwrap_or(!(app.ui.tools_drawer && app.ui.drawer_toolbar == target));
             app.ui.tools_drawer = open;
+            app.ui.drawer_toolbar = target.filter(|_| open);
             if open {
                 app.ui.flyout = None;
+                // The toolbar the drawer opens beside shows.
+                match app.ui.drawer_toolbar.clone() {
+                    Some(name) => {
+                        if let Some(bar) = crate::custom_toolbars::find_mut(&mut app.ui, &name) {
+                            bar.shown = true;
+                        }
+                    }
+                    None => app.ui.toolbar = true,
+                }
             }
-            json!(open)
-        }),
+            Ok(json!(open))
+        })(),
         "toolbar.addTool" => (|| {
             let tool = tool_param(p)?;
             let place = Place::parse(p)?;
-            let named = !app.ui.toolbar_advanced;
-            edit(app, |cats| add(cats, tool, p.get("slot"), place, named))
+            edit_target(app, p, |cats, named| add(cats, tool, p.get("slot"), place, named))
         })(),
         "toolbar.removeTool" => (|| {
             let tool = tool_param(p)?;
             let group = crate::menus::opt_bool(p, "group")?.unwrap_or(false);
-            edit(app, |cats| {
+            edit_target(app, p, |cats, _| {
                 remove(cats, tool, group);
                 Ok(())
             })
@@ -257,10 +300,17 @@ pub(crate) fn command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
             }
             Ok(app.ui.toolbar_show.json())
         })(),
-        "toolbar.reset" => {
+        "toolbar.reset" => (|| {
+            if let Some(name) = crate::custom_toolbars::target(&app.ui, p)? {
+                // A toolbar made with New Toolbar goes back to blank, as it was made.
+                return edit_custom(app, &name, |cats| {
+                    cats.clear();
+                    Ok(())
+                });
+            }
             *current(&mut app.ui.toolbar_edits, app.ui.toolbar_advanced) = None;
             Ok(slots_json(app))
-        }
+        })(),
         _ => return None,
     })
 }
@@ -290,9 +340,9 @@ pub(crate) fn drop_target(slots: &[(usize, Rect)], at: Pos2, cols: usize) -> (Op
     (Some(k), if before { Place::Before } else { Place::After })
 }
 
-/// While a tool is dragged over the toolbar (`bounds`): mark where it would go, and on the release
-/// put it there (`toolbar.addTool`).
-pub(crate) fn toolbar_drop(app: &mut VectorcraftApp, ui: &Ui, slots: &[(usize, Rect)], cols: usize, bounds: Rect) {
+/// While a tool is dragged over the toolbar (`bounds`; `toolbar`: one made with New Toolbar): mark
+/// where it would go, and on the release put it there (`toolbar.addTool`).
+pub(crate) fn toolbar_drop(app: &mut VectorcraftApp, ui: &Ui, slots: &[(usize, Rect)], cols: usize, bounds: Rect, toolbar: Option<&str>) {
     let Some(drag) = DragAndDrop::payload::<ToolDrag>(ui.ctx()) else { return };
     let Some(at) = ui.ctx().pointer_interact_pos().filter(|p| bounds.contains(*p)) else { return };
     let t = Tokens::get(ui.ctx());
@@ -319,7 +369,7 @@ pub(crate) fn toolbar_drop(app: &mut VectorcraftApp, ui: &Ui, slots: &[(usize, R
     }
     if ui.input(|i| i.pointer.any_released()) {
         DragAndDrop::clear_payload(ui.ctx());
-        let p = json!({ "tool": drag.tool, "slot": slot, "place": place.name() });
+        let p = json!({ "tool": drag.tool, "slot": slot, "place": place.name(), "toolbar": toolbar });
         if let Err(e) = app.run("toolbar.addTool", p) {
             app.ui.status = e;
         }
@@ -417,7 +467,8 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
     // buttons) and the bars under it.
     let top = bar.top().max(screen.top() + GAP);
     let y = button.top().min(screen.bottom() - size.y - GAP).max(top);
-    let in_toolbar: BTreeSet<&str> = crate::toolbar::slots(app).into_iter().flat_map(|(_, s)| s).collect();
+    let in_toolbar = target_tools(app);
+    let target = crate::custom_toolbars::drawer_target(&app.ui).map(str::to_string);
     let active = app.session.tool_id();
     let dragged = DragAndDrop::payload::<ToolDrag>(ctx).map(|d| *d);
     let pointer = ctx.pointer_interact_pos();
@@ -425,13 +476,15 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
     let removing = dragged.is_some_and(|d| d.from_toolbar) && ctx.memory(|m| m.area_rect(id)).is_some_and(over);
     let mut picked = None;
     let mut reset = false;
+    let mut new_toolbar = false;
     let mut advanced = None;
     let mut show_toggle = None;
     let resp = egui::Area::new(id).order(egui::Order::Foreground).fixed_pos(pos2(x, y)).show(ctx, |ui| {
         crate::toolbar::flyout_frame(&t, removing).inner_margin(egui::Margin::same(8)).show(ui, |ui| {
             let width = WIDTH.min(screen.width() - 2.0 * GAP - 16.0).max(160.0);
             ui.set_width(width);
-            // All Tools, and the drawer's flyout menu: Reset, and the Basic or Advanced toolbar.
+            // All Tools, and the drawer's flyout menu: Reset, the Basic or Advanced toolbar, and New
+            // Toolbar.
             ui.horizontal(|ui| {
                 ui.label(egui::RichText::new(tl!("All Tools")).size(13.0).color(t.text_strong));
                 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@@ -450,6 +503,10 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
                                 advanced = Some(adv);
                             }
                         }
+                        ui.separator();
+                        if widgets::menu_item(ui, "New Toolbar…", true, false) {
+                            new_toolbar = true;
+                        }
                     });
                 });
             });
@@ -478,7 +535,11 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
                     }
                 }
             });
-            // Show: the controls under the toolbar's tools.
+            // Show: the controls under the Tools panel's tools (a toolbar made with New Toolbar has
+            // none).
+            if target.is_some() {
+                return;
+            }
             ui.add_space(4.0);
             ui.separator();
             ui.horizontal(|ui| {
@@ -498,16 +559,18 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
         && ctx.input(|i| i.pointer.any_released())
     {
         DragAndDrop::clear_payload(ctx);
-        if let Err(e) = app.run("toolbar.removeTool", json!({ "tool": d.tool })) {
+        if let Err(e) = app.run("toolbar.removeTool", json!({ "tool": d.tool, "toolbar": target })) {
             app.ui.status = e;
         }
     }
-    if reset && let Err(e) = app.run("toolbar.reset", json!({})) {
+    if reset && let Err(e) = app.run("toolbar.reset", json!({ "toolbar": target })) {
         app.ui.status = e;
     }
-    // Basic or Advanced: the menu item of the other toolbar switches to it.
-    if advanced.is_some_and(|a| a != app.ui.toolbar_advanced)
-        && let Err(e) = app.run("window.toolbarAdvanced", json!({}))
+    if new_toolbar && let Err(e) = app.run("window.toolbar.new", json!({})) {
+        app.ui.status = e;
+    }
+    if let Some(advanced) = advanced
+        && let Err(e) = pick_layout(app, advanced)
     {
         app.ui.status = e;
     }
@@ -527,6 +590,19 @@ pub(crate) fn drawer(app: &mut VectorcraftApp, ctx: &egui::Context) {
     ghost(ctx, &t);
 }
 
+/// The drawer menu's Basic or Advanced: the menu item of the other toolbar switches the Tools panel
+/// to it, and the drawer goes on to edit the Tools panel's toolbar, also from a toolbar made with
+/// New Toolbar.
+fn pick_layout(app: &mut VectorcraftApp, advanced: bool) -> Result<(), String> {
+    if crate::custom_toolbars::drawer_target(&app.ui).is_some() {
+        app.run("window.editToolbar", json!({ "open": true }))?;
+    }
+    if advanced != app.ui.toolbar_advanced {
+        app.run("window.toolbarAdvanced", json!({}))?;
+    }
+    Ok(())
+}
+
 /// A Show section toggle: lit while its control shows under the toolbar's tools. Fill and Stroke
 /// and Coloring are drawn as small copies of those controls, the modes with their buttons' icons.
 fn show_button(ui: &mut Ui, t: &Tokens, name: &str, label: &str, icon: &str, on: bool) -> bool {
diff --git a/crates/ui-egui/src/edit_toolbar/tests.rs b/crates/ui-egui/src/edit_toolbar/tests.rs
--- a/crates/ui-egui/src/edit_toolbar/tests.rs
+++ b/crates/ui-egui/src/edit_toolbar/tests.rs
@@ -356,6 +356,141 @@ fn tools_drag_between_the_drawer_and_the_toolbar() {
     assert_eq!(slots(&app), before);
 }
 
+/// A toolbar made with New Toolbar floats blank; its ••• button opens the drawer beside it, and
+/// tools dragged from the drawer fill it, leaving the Tools panel's toolbar as it was.
+#[test]
+fn a_new_toolbar_fills_from_the_drawer_beside_it() {
+    let mut app = app();
+    let ctx = ctx();
+    let shipped = slots(&app);
+    app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
+    frame(&mut app, &ctx, 0.0, vec![]);
+    frame(&mut app, &ctx, 0.1, vec![]);
+    let area = || ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id("Mine"))).expect("the toolbar is shown");
+    let bar = area();
+    let edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().find(|r| bar.contains(r.center())).expect("its ••• button");
+    // Open beside the Tools panel's toolbar, the drawer moves to this one on its ••• click.
+    app.run("window.editToolbar", json!({})).unwrap();
+    frame(&mut app, &ctx, 0.5, vec![]);
+    click(&mut app, &ctx, 1.0, edit.center());
+    assert!(app.ui.tools_drawer && app.ui.drawer_toolbar.as_deref() == Some("Mine"));
+    assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({"toolbar": "Mine"})), Some(true));
+    assert_eq!(crate::menus::checked(&app, "window.editToolbar", &json!({})), Some(false));
+    let shown = frame(&mut app, &ctx, 1.2, vec![]);
+    let drawer = drawer_rect(&ctx).unwrap();
+    assert!((drawer.left() - bar.right() - GAP).abs() < 2.0, "beside the new toolbar: {drawer:?} {bar:?}");
+    // The Tools panel's tools aren't dimmed: they aren't in this toolbar. The Show section, for
+    // the controls under the Tools panel's tools, is left out.
+    assert!(!shown.iter().any(|(s, p, _)| s == "Show:" && drawer.contains(*p)));
+    let dim = Tokens::get(&ctx).text_disabled;
+    assert_ne!(cell_at(&shown, drawer, "Ellipse Tool").1, dim);
+    // Dragged onto the blank toolbar, a tool is added to it.
+    let (ellipse, _) = cell_at(&shown, drawer, "Ellipse Tool");
+    drag(&mut app, &ctx, 2.0, ellipse, bar.center());
+    assert_eq!(crate::custom_toolbars::find(&app.ui, "Mine").unwrap().slots, [["ellipse"]]);
+    assert_eq!(slots(&app), shipped, "the Tools panel's toolbar is untouched");
+    let shown = frame(&mut app, &ctx, 2.9, vec![]);
+    let drawer = drawer_rect(&ctx).unwrap();
+    assert_eq!(cell_at(&shown, drawer, "Ellipse Tool").1, dim, "now in the toolbar the drawer edits");
+    // A second one, below the first.
+    let tools: Vec<Rect> = crate::toolbar::tests::widget_rects(&ctx, BUTTON).into_iter().filter(|r| area().contains(r.center())).collect();
+    let (artboard, _) = cell_at(&shown, drawer, "Artboard Tool");
+    drag(&mut app, &ctx, 3.0, artboard, tools[0].center_bottom() - vec2(0.0, 3.0));
+    assert_eq!(crate::custom_toolbars::find(&app.ui, "Mine").unwrap().slots, [["ellipse"], ["artboard"]]);
+    // A click on its tool picks it; the drawer stays open.
+    let tools: Vec<Rect> = crate::toolbar::tests::widget_rects(&ctx, BUTTON).into_iter().filter(|r| area().contains(r.center())).collect();
+    click(&mut app, &ctx, 4.0, tools[1].center());
+    assert_eq!(app.session.tool_id(), "artboard");
+    assert!(app.ui.tools_drawer);
+    // Dragged from it into the drawer, a tool leaves it.
+    drag(&mut app, &ctx, 5.0, tools[0].center(), drawer.center());
+    assert_eq!(crate::custom_toolbars::find(&app.ui, "Mine").unwrap().slots, [["artboard"]]);
+    // The Tools panel's ••• button takes the drawer back beside its toolbar.
+    let main_edit = crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().rfind(|r| r.right() < 60.0).unwrap();
+    click(&mut app, &ctx, 5.5, main_edit.center());
+    assert!(app.ui.tools_drawer && app.ui.drawer_toolbar.is_none());
+    // The drawer closed, a drag on the grip bar moves the toolbar.
+    app.run("window.editToolbar", json!({"open": false})).unwrap();
+    frame(&mut app, &ctx, 6.0, vec![]);
+    let before = area();
+    drag(&mut app, &ctx, 7.0, before.center_top() + vec2(0.0, 5.0), before.center_top() + vec2(120.0, 205.0));
+    frame(&mut app, &ctx, 7.5, vec![]);
+    let after = area();
+    assert!((after.min - before.min - vec2(120.0, 200.0)).length() < 2.0, "{before:?} {after:?}");
+    // Hidden, it isn't drawn.
+    app.run("window.toolbar.custom", json!({"name": "Mine", "show": false})).unwrap();
+    frame(&mut app, &ctx, 8.0, vec![]);
+    frame(&mut app, &ctx, 8.1, vec![]);
+    assert!(!ctx.memory(|m| m.areas().visible_last_frame(&egui::LayerId::new(egui::Order::Middle, crate::custom_toolbars::area_id("Mine")))));
+}
+
+/// New Toolbar… in the drawer's menu opens the New Toolbar dialog, which asks for a name.
+#[test]
+fn new_toolbar_in_the_drawer_menu_asks_for_a_name() {
+    let mut app = app();
+    let ctx = ctx();
+    app.run("window.editToolbar", json!({"open": true})).unwrap();
+    frame(&mut app, &ctx, 0.0, vec![]);
+    frame(&mut app, &ctx, 0.1, vec![]);
+    let drawer = drawer_rect(&ctx).expect("the drawer is open");
+    let menu = crate::toolbar::tests::widget_rects(&ctx, vec2(18.0, 18.0))
+        .into_iter()
+        .find(|r| drawer.contains(r.center()))
+        .expect("the drawer menu button");
+    click(&mut app, &ctx, 1.0, menu.center());
+    // The menu is sized in its first frame and drawn from the second.
+    frame(&mut app, &ctx, 1.2, vec![]);
+    let shown = frame(&mut app, &ctx, 1.3, vec![]);
+    // A menu item's label is indented where a checked item has its ✓.
+    let item = shown.iter().find(|(s, ..)| s.trim() == "New Toolbar…").map(|(_, p, _)| *p).expect("New Toolbar… in the drawer menu");
+    assert!(app.ui.dialog.is_none());
+    click(&mut app, &ctx, 2.0, item);
+    let d = app.ui.dialog.clone().expect("the dialog asks for a name");
+    assert_eq!((d.kind.as_str(), d.str("name").as_str()), (crate::custom_toolbars::KIND, "Toolbar 1"));
+}
+
+/// A click on another toolbar's ••• button takes the drawer there, whichever was made first.
+#[test]
+fn the_drawer_moves_between_new_toolbars() {
+    let mut app = app();
+    let ctx = ctx();
+    for name in ["A", "B"] {
+        app.run("window.toolbar.new", json!({"name": name})).unwrap();
+    }
+    app.ui.custom_toolbars[1].pos = [600.0, 900.0];
+    frame(&mut app, &ctx, 0.0, vec![]);
+    frame(&mut app, &ctx, 0.1, vec![]);
+    let edit_of = |name: &str| {
+        let bar = ctx.memory(|m| m.area_rect(crate::custom_toolbars::area_id(name))).unwrap();
+        crate::toolbar::tests::widget_rects(&ctx, vec2(26.0, 26.0)).into_iter().find(|r| bar.contains(r.center())).unwrap()
+    };
+    let (a, b) = (edit_of("A"), edit_of("B"));
+    for (k, (button, name)) in [(b, "B"), (a, "A"), (b, "B")].into_iter().enumerate() {
+        click(&mut app, &ctx, 1.0 + k as f64, button.center());
+        frame(&mut app, &ctx, 1.5 + k as f64, vec![]);
+        assert_eq!(crate::custom_toolbars::drawer_target(&app.ui), Some(name), "after the click on {name}'s ••• button");
+    }
+}
+
+/// Basic or Advanced in the drawer's menu, picked while the drawer edits a toolbar made with New
+/// Toolbar, switches the Tools panel and takes the drawer to it, so Reset and drops edit it.
+#[test]
+fn basic_or_advanced_takes_the_drawer_to_the_tools_panel() {
+    let mut app = app();
+    app.run("window.toolbar.new", json!({"name": "Mine"})).unwrap();
+    app.run("toolbar.addTool", json!({"tool": "pen", "toolbar": "Mine"})).unwrap();
+    // With the Tools panel hidden too: it shows.
+    app.run("window.toolbar", json!({})).unwrap();
+    for advanced in [false, true] {
+        app.run("window.editToolbar", json!({"toolbar": "Mine", "open": true})).unwrap();
+        pick_layout(&mut app, advanced).unwrap();
+        assert!(app.ui.tools_drawer && crate::custom_toolbars::drawer_target(&app.ui).is_none(), "{advanced}");
+        assert_eq!(app.ui.toolbar_advanced, advanced);
+        assert!(app.ui.toolbar, "the Tools panel shows beside the drawer");
+    }
+    assert_eq!(crate::custom_toolbars::find(&app.ui, "Mine").unwrap().slots, [["pen"]], "Mine as it was");
+}
+
 #[test]
 fn drop_targets_follow_the_buttons_edges() {
     let r = |y: f32| Rect::from_min_size(pos2(6.0, y), BUTTON);
diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv
--- a/crates/ui-egui/src/i18n/bg.tsv
+++ b/crates/ui-egui/src/i18n/bg.tsv
@@ -1982,6 +1982,10 @@ font-technology	Variable	Променливи
 	New Without Dialog	Нов без диалогов прозорец
 	New Workspace	Ново работно пространство
 	New Workspace…	Ново работно пространство…
+	New Toolbar…	Нова лента с инструменти…
+	New Toolbar	Нова лента с инструменти
+	Show Toolbar	Показване на лента с инструменти
+	List Toolbars	Списък на лентите с инструменти
 	New {kind} Style…	Нов стил ({kind})…
 	New…	Нов…
 	Next	Следващ
diff --git a/crates/ui-egui/src/i18n/cs.tsv b/crates/ui-egui/src/i18n/cs.tsv
--- a/crates/ui-egui/src/i18n/cs.tsv
+++ b/crates/ui-egui/src/i18n/cs.tsv
@@ -632,6 +632,10 @@
 	Reset Typography	Obnovit Typography
 	Reset Web	Obnovit Web
 	New Workspace…	Nový pracovní prostor…
+	New Toolbar…	Nový panel nástrojů…
+	New Toolbar	Nový panel nástrojů
+	Show Toolbar	Zobrazit panel nástrojů
+	List Toolbars	Seznam panelů nástrojů
 	Manage Workspaces…	Spravovat pracovní prostory…
 	Control	Ovládací panel
 	Contextual Task Bar	Kontextový panel úloh
diff --git a/crates/ui-egui/src/i18n/da.tsv b/crates/ui-egui/src/i18n/da.tsv
--- a/crates/ui-egui/src/i18n/da.tsv
+++ b/crates/ui-egui/src/i18n/da.tsv
@@ -585,6 +585,10 @@
 	Reset Typography	Nulstil Typography
 	Reset Web	Nulstil Web
 	New Workspace…	Nyt arbejdsområde…
+	New Toolbar…	Ny værktøjslinje…
+	New Toolbar	Ny værktøjslinje
+	Show Toolbar	Vis værktøjslinje
+	List Toolbars	Liste over værktøjslinjer
 	Manage Workspaces…	Administrér arbejdsområder…
 	Control	Kontrol
 	Contextual Task Bar	Kontekstuel opgavelinje
diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv
--- a/crates/ui-egui/src/i18n/de.tsv
+++ b/crates/ui-egui/src/i18n/de.tsv
@@ -1996,6 +1996,10 @@ file-filter	Templates	Vorlagen
 	New Without Dialog	Neu ohne Dialogfeld
 	New Workspace	Neuer Arbeitsbereich
 	New Workspace…	Neuer Arbeitsbereich…
+	New Toolbar…	Neue Werkzeugleiste…
+	New Toolbar	Neue Werkzeugleiste
+	Show Toolbar	Werkzeugleiste einblenden
+	List Toolbars	Werkzeugleisten auflisten
 	New {kind} Style…	Neues {kind}format…
 	New…	Neu…
 	Next	Weiter
diff --git a/crates/ui-egui/src/i18n/el.tsv b/crates/ui-egui/src/i18n/el.tsv
--- a/crates/ui-egui/src/i18n/el.tsv
+++ b/crates/ui-egui/src/i18n/el.tsv
@@ -1669,6 +1669,10 @@ font-technology	Variable	Μεταβλητές
 	New Without Dialog	Νέο χωρίς παράθυρο διαλόγου
 	New Workspace	Νέος χώρος εργασίας
 	New Workspace…	Νέος χώρος εργασίας…
+	New Toolbar…	Νέα γραμμή εργαλείων…
+	New Toolbar	Νέα γραμμή εργαλείων
+	Show Toolbar	Εμφάνιση γραμμής εργαλείων
+	List Toolbars	Λίστα γραμμών εργαλείων
 	New…	Νέο…
 	Next	Επόμενο
 	No document	Δεν υπάρχει έγγραφο
diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv
--- a/crates/ui-egui/src/i18n/es.tsv
+++ b/crates/ui-egui/src/i18n/es.tsv
@@ -2034,6 +2034,10 @@ font-technology	Variable	Variables
 	New Without Dialog	Nuevo sin cuadro de diálogo
 	New Workspace	Nuevo espacio de trabajo
 	New Workspace…	Nuevo espacio de trabajo…
+	New Toolbar…	Nueva barra de herramientas…
+	New Toolbar	Nueva barra de herramientas
+	Show Toolbar	Mostrar barra de herramientas
+	List Toolbars	Lista de barras de herramientas
 	New {kind} Style…	Nuevo estilo de {kind}…
 	New…	Nuevo…
 	Next	Siguiente
diff --git a/crates/ui-egui/src/i18n/fi.tsv b/crates/ui-egui/src/i18n/fi.tsv
--- a/crates/ui-egui/src/i18n/fi.tsv
+++ b/crates/ui-egui/src/i18n/fi.tsv
@@ -623,6 +623,10 @@
 	Reset Typography	Palauta Typography
 	Reset Web	Palauta Web
 	New Workspace…	Uusi työtila…
+	New Toolbar…	Uusi työkalurivi…
+	New Toolbar	Uusi työkalurivi
+	Show Toolbar	Näytä työkalurivi
+	List Toolbars	Luettele työkalurivit
 	Manage Workspaces…	Hallitse työtiloja…
 	Control	Ohjaus
 	Contextual Task Bar	Kontekstuaalinen tehtäväpalkki
diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv
--- a/crates/ui-egui/src/i18n/fr.tsv
+++ b/crates/ui-egui/src/i18n/fr.tsv
@@ -2031,6 +2031,10 @@ font-technology	Variable	Variables
 	New Without Dialog	Nouveau sans boîte de dialogue
 	New Workspace	Nouvel espace de travail
 	New Workspace…	Nouvel espace de travail…
+	New Toolbar…	Nouvelle barre d’outils…
+	New Toolbar	Nouvelle barre d’outils
+	Show Toolbar	Afficher la barre d’outils
+	List Toolbars	Liste des barres d’outils
 	New {kind} Style…	Nouveau style de {kind}…
 	New…	Nouveau…
 	Next	Suivant
diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv
--- a/crates/ui-egui/src/i18n/hu.tsv
+++ b/crates/ui-egui/src/i18n/hu.tsv
@@ -2489,6 +2489,10 @@ file-filter	Templates	Sablonok
 	New Without Dialog	Új párbeszédablak nélkül
 	New Workspace	Új munkaterület
 	New Workspace…	Új munkaterület…
+	New Toolbar…	Új eszköztár…
+	New Toolbar	Új eszköztár
+	Show Toolbar	Eszköztár megjelenítése
+	List Toolbars	Eszköztárak listája
 	New {kind} Style…	Új stílus: {kind}…
 	New…	Új…
 	Next	Következő
diff --git a/crates/ui-egui/src/i18n/id.tsv b/crates/ui-egui/src/i18n/id.tsv
--- a/crates/ui-egui/src/i18n/id.tsv
+++ b/crates/ui-egui/src/i18n/id.tsv
@@ -585,6 +585,10 @@
 	Reset Typography	Atur ulang Typography
 	Reset Web	Atur ulang Web
 	New Workspace…	Ruang kerja baru…
+	New Toolbar…	Bilah alat baru…
+	New Toolbar	Bilah alat baru
+	Show Toolbar	Tampilkan bilah alat
+	List Toolbars	Daftar bilah alat
 	Manage Workspaces…	Kelola ruang kerja…
 	Control	Kontrol
 	Contextual Task Bar	Bilah tugas kontekstual
diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv
--- a/crates/ui-egui/src/i18n/it.tsv
+++ b/crates/ui-egui/src/i18n/it.tsv
@@ -2061,6 +2061,10 @@ file-filter	Templates	Modelli
 	New Without Dialog	Nuovo senza finestra di dialogo
 	New Workspace	Nuova area di lavoro
 	New Workspace…	Nuova area di lavoro…
+	New Toolbar…	Nuova barra degli strumenti…
+	New Toolbar	Nuova barra degli strumenti
+	Show Toolbar	Mostra barra degli strumenti
+	List Toolbars	Elenco barre degli strumenti
 	New {kind} Style…	Nuovo stile {kind}…
 	New…	Nuovo…
 	Next	Successivo
diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv
--- a/crates/ui-egui/src/i18n/ja.tsv
+++ b/crates/ui-egui/src/i18n/ja.tsv
@@ -607,6 +607,10 @@ font-technology	Variable	バリアブル
 	Workspace	ワークスペース
 	Reset Essentials	初期設定をリセット
 	New Workspace…	新規ワークスペース…
+	New Toolbar…	新規ツールバー…
+	New Toolbar	新規ツールバー
+	Show Toolbar	ツールバーを表示
+	List Toolbars	ツールバーの一覧
 	Manage Workspaces…	ワークスペースを管理…
 	Control	コントロール
 	Contextual Task Bar	コンテキストタスクバー
diff --git a/crates/ui-egui/src/i18n/ka.tsv b/crates/ui-egui/src/i18n/ka.tsv
--- a/crates/ui-egui/src/i18n/ka.tsv
+++ b/crates/ui-egui/src/i18n/ka.tsv
@@ -529,6 +529,10 @@
 	Workspace	სამუშაო სივრცე
 	Reset Essentials	Essentials-ის ჩამოყრა
 	New Workspace…	ახალი სამუშაო სივრცე…
+	New Toolbar…	ახალი ხელსაწყოთა ზოლი…
+	New Toolbar	ახალი ხელსაწყოთა ზოლი
+	Show Toolbar	ხელსაწყოთა ზოლის ჩვენება
+	List Toolbars	ხელსაწყოთა ზოლების სია
 	Manage Workspaces…	სამუშაო სივრცეების მართვა…
 	Control	მართვა
 	Contextual Task Bar	კონტექსტური ამოცანების ზოლი
diff --git a/crates/ui-egui/src/i18n/ko.tsv b/crates/ui-egui/src/i18n/ko.tsv
--- a/crates/ui-egui/src/i18n/ko.tsv
+++ b/crates/ui-egui/src/i18n/ko.tsv
@@ -605,6 +605,10 @@ font-technology	Variable	가변
 	Workspace	작업 영역
 	Reset Essentials	Essentials 재설정
 	New Workspace…	새 작업 영역…
+	New Toolbar…	새 도구 막대…
+	New Toolbar	새 도구 막대
+	Show Toolbar	도구 막대 표시
+	List Toolbars	도구 막대 목록
 	Manage Workspaces…	작업 영역 관리…
 	Control	컨트롤
 	Contextual Task Bar	상황별 작업 표시줄
diff --git a/crates/ui-egui/src/i18n/nl.tsv b/crates/ui-egui/src/i18n/nl.tsv
--- a/crates/ui-egui/src/i18n/nl.tsv
+++ b/crates/ui-egui/src/i18n/nl.tsv
@@ -599,6 +599,10 @@
 	Reset Typography	Typography herstellen
 	Reset Web	Web herstellen
 	New Workspace…	Nieuwe werkruimte…
+	New Toolbar…	Nieuwe werkbalk…
+	New Toolbar	Nieuwe werkbalk
+	Show Toolbar	Werkbalk tonen
+	List Toolbars	Lijst met werkbalken
 	Manage Workspaces…	Werkruimten beheren…
 	Control	Bediening
 	Contextual Task Bar	Contextuele taakbalk
diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv
--- a/crates/ui-egui/src/i18n/pl.tsv
+++ b/crates/ui-egui/src/i18n/pl.tsv
@@ -582,6 +582,10 @@
 	Workspace	Obszar roboczy
 	Reset Essentials	Resetuj Essentials
 	New Workspace…	Nowy obszar roboczy…
+	New Toolbar…	Nowy pasek narzędzi…
+	New Toolbar	Nowy pasek narzędzi
+	Show Toolbar	Pokaż pasek narzędzi
+	List Toolbars	Lista pasków narzędzi
 	Manage Workspaces…	Zarządzaj obszarami roboczymi…
 	Control	Sterowanie
 	Contextual Task Bar	Kontekstowy pasek zadań
diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv
--- a/crates/ui-egui/src/i18n/pt-br.tsv
+++ b/crates/ui-egui/src/i18n/pt-br.tsv
@@ -1651,6 +1651,10 @@
 	New Without Dialog	Novo sem Diálogo
 	New Workspace	Novo Espaço de Trabalho
 	New Workspace…	Novo Espaço de Trabalho…
+	New Toolbar…	Nova Barra de Ferramentas…
+	New Toolbar	Nova Barra de Ferramentas
+	Show Toolbar	Mostrar Barra de Ferramentas
+	List Toolbars	Listar Barras de Ferramentas
 	New file	Novo arquivo
 	New from Template	Novo a partir de Modelo
 	New from Template…	Novo a partir de Modelo…
diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv
--- a/crates/ui-egui/src/i18n/ru.tsv
+++ b/crates/ui-egui/src/i18n/ru.tsv
@@ -2057,6 +2057,10 @@ file-filter	Templates	Шаблоны
 	New Without Dialog	Создать без диалогового окна
 	New Workspace	Новая рабочая среда
 	New Workspace…	Новая рабочая среда…
+	New Toolbar…	Новая панель инструментов…
+	New Toolbar	Новая панель инструментов
+	Show Toolbar	Показать панель инструментов
+	List Toolbars	Список панелей инструментов
 	New {kind} Style…	Новый стиль {kind}…
 	New…	Создать…
 	Next	Далее
diff --git a/crates/ui-egui/src/i18n/sv.tsv b/crates/ui-egui/src/i18n/sv.tsv
--- a/crates/ui-egui/src/i18n/sv.tsv
+++ b/crates/ui-egui/src/i18n/sv.tsv
@@ -1982,6 +1982,10 @@ font-technology	Variable	Variabla
 	New Without Dialog	Ny utan dialogruta
 	New Workspace	Ny arbetslayout
 	New Workspace…	Ny arbetslayout…
+	New Toolbar…	Nytt verktygsfält…
+	New Toolbar	Nytt verktygsfält
+	Show Toolbar	Visa verktygsfält
+	List Toolbars	Lista verktygsfält
 	New {kind} Style…	Ny {kind}stil…
 	New…	Ny…
 	Next	Nästa
diff --git a/crates/ui-egui/src/i18n/tr.tsv b/crates/ui-egui/src/i18n/tr.tsv
--- a/crates/ui-egui/src/i18n/tr.tsv
+++ b/crates/ui-egui/src/i18n/tr.tsv
@@ -1858,6 +1858,10 @@ font-technology	Variable	Değişken
 	New Window	Yeni Pencere
 	New Workspace	Yeni Çalışma Alanı
 	New Workspace…	Yeni Çalışma Alanı…
+	New Toolbar…	Yeni Araç Çubuğu…
+	New Toolbar	Yeni Araç Çubuğu
+	Show Toolbar	Araç Çubuğunu Göster
+	List Toolbars	Araç Çubuklarını Listele
 	New {kind} Style…	Yeni {kind} Stili…
 	New…	Yeni…
 	Next	Sonraki
diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv
--- a/crates/ui-egui/src/i18n/uk.tsv
+++ b/crates/ui-egui/src/i18n/uk.tsv
@@ -2053,6 +2053,10 @@ file-filter	Templates	Шаблони
 	New Without Dialog	Створити без діалогового вікна
 	New Workspace	Нове робоче середовище
 	New Workspace…	Нове робоче середовище…
+	New Toolbar…	Нова панель інструментів…
+	New Toolbar	Нова панель інструментів
+	Show Toolbar	Показати панель інструментів
+	List Toolbars	Список панелей інструментів
 	New {kind} Style…	Новий стиль {kind}…
 	New…	Створити…
 	Next	Далі
diff --git a/crates/ui-egui/src/i18n/vi.tsv b/crates/ui-egui/src/i18n/vi.tsv
--- a/crates/ui-egui/src/i18n/vi.tsv
+++ b/crates/ui-egui/src/i18n/vi.tsv
@@ -2297,6 +2297,10 @@ file-filter	Templates	Mẫu
 	New Without Dialog	Mới không qua hộp thoại
 	New Workspace	Không gian làm việc mới
 	New Workspace…	Không gian làm việc mới…
+	New Toolbar…	Thanh công cụ mới…
+	New Toolbar	Thanh công cụ mới
+	Show Toolbar	Hiện thanh công cụ
+	List Toolbars	Danh sách thanh công cụ
 	New {kind} Style…	Kiểu {kind} mới…
 	New…	Mới…
 	Next	Tiếp theo
diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv
--- a/crates/ui-egui/src/i18n/zh-hans.tsv
+++ b/crates/ui-egui/src/i18n/zh-hans.tsv
@@ -1932,6 +1932,10 @@ file-filter	Templates	模板
 	New Without Dialog	无对话框新建
 	New Workspace	新建工作区
 	New Workspace…	新建工作区…
+	New Toolbar…	新建工具栏…
+	New Toolbar	新建工具栏
+	Show Toolbar	显示工具栏
+	List Toolbars	列出工具栏
 	New {kind} Style…	新建{kind}样式…
 	New…	新建…
 	Next	下一个
diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv
--- a/crates/ui-egui/src/i18n/zh-hant.tsv
+++ b/crates/ui-egui/src/i18n/zh-hant.tsv
@@ -2831,6 +2831,10 @@ file-filter	Templates	範本
 	New Without Dialog	新增但不開啟對話方塊
 	New Workspace	新增工作區
 	New Workspace…	新增工作區…
+	New Toolbar…	新增工具列…
+	New Toolbar	新增工具列
+	Show Toolbar	顯示工具列
+	List Toolbars	列出工具列
 	New {kind} Style…	新增{kind}樣式…
 	New…	新增…
 	Next	下一個
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
@@ -22,6 +22,7 @@ pub mod community;
 pub mod control;
 pub mod credits;
 pub mod cursors;
+mod custom_toolbars;
 pub mod dialogs;
 pub mod dock;
 mod edit_toolbar;
diff --git a/crates/ui-egui/src/menus/labels.rs b/crates/ui-egui/src/menus/labels.rs
--- a/crates/ui-egui/src/menus/labels.rs
+++ b/crates/ui-egui/src/menus/labels.rs
@@ -103,7 +103,7 @@ pub(super) fn shows_a_name(id: &str, label: &str) -> bool {
     ];
     SLOTS.iter().any(|s| id.starts_with(s))
         || saved_selection_slot(id).is_some()
-        || matches!(id, "text.setStyle" | "plugin.dialog")
+        || matches!(id, "text.setStyle" | "plugin.dialog" | "window.toolbar.custom")
         || (id == "window.workspace" && !crate::workspaces::is_builtin(label))
         || (matches!(id, "effect.apply" | "effect.dialog") && vectorcraft_effects::plugin_effects().iter().any(|e| e.label == label))
 }
diff --git a/crates/ui-egui/src/menus/run.rs b/crates/ui-egui/src/menus/run.rs
--- a/crates/ui-egui/src/menus/run.rs
+++ b/crates/ui-egui/src/menus/run.rs
@@ -71,6 +71,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
     if let Some(r) = crate::shortcut_editor::run_command(app, id, p)
         .or_else(|| crate::workspaces::run_command(app, id, p))
         .or_else(|| crate::edit_toolbar::command(app, id, p))
+        .or_else(|| crate::custom_toolbars::command(app, id, p))
     {
         return Some(r);
     }
@@ -351,11 +352,21 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
         }
         "window.control" => flag(&mut app.ui.control_bar),
         "window.toolbar" => flag(&mut app.ui.toolbar),
-        "window.toolbarColumns" => opt_bool(p, "double").map(|on| {
-            let on = on.unwrap_or(!app.ui.toolbar_double);
-            app.ui.toolbar_double = on;
-            json!(on)
-        }),
+        "window.toolbarColumns" => {
+            let toolbar = match crate::custom_toolbars::target(&app.ui, p) {
+                Ok(t) => t,
+                Err(e) => return Some(Err(e)),
+            };
+            let double = match toolbar.as_deref().and_then(|name| crate::custom_toolbars::find_mut(&mut app.ui, name)) {
+                Some(b) => &mut b.double,
+                None => &mut app.ui.toolbar_double,
+            };
+            opt_bool(p, "double").map(|on| {
+                let on = on.unwrap_or(!*double);
+                *double = on;
+                json!(on)
+            })
+        }
         "window.toolbarAdvanced" => flag(&mut app.ui.toolbar_advanced),
         "imageTrace.preview" => opt_bool(p, "on").map(|on| {
             let on = on.unwrap_or(!app.ui.image_trace_preview);
@@ -368,7 +379,11 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
                 Err(e) => return Some(Err(e)),
             };
             let Some(tool) = s("tool") else { return Some(Err("tool (a tool id) is required".into())) };
-            crate::toolbar::float_group(app, &tool, floating).map(Value::Bool)
+            let toolbar = match crate::custom_toolbars::target(&app.ui, p) {
+                Ok(t) => t,
+                Err(e) => return Some(Err(e)),
+            };
+            crate::toolbar::float_group(app, &tool, floating, toolbar.as_deref()).map(Value::Bool)
         }
         "window.taskBar" => flag(&mut app.ui.task_bar),
         "window.taskBar.pin" => {
diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs
--- a/crates/ui-egui/src/menus/state.rs
+++ b/crates/ui-egui/src/menus/state.rs
@@ -61,11 +61,22 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option<bool> {
         "window.control" => app.ui.control_bar,
         "window.toolbar" => app.ui.toolbar,
         "window.toolbarAdvanced" => app.ui.toolbar_advanced,
-        "window.toolbarColumns" => app.ui.toolbar_double,
-        "window.editToolbar" => app.ui.tools_drawer,
+        "window.toolbarColumns" => match p.get("toolbar").and_then(Value::as_str) {
+            None => app.ui.toolbar_double,
+            Some(name) => crate::custom_toolbars::find(&app.ui, name).is_some_and(|b| b.double),
+        },
+        "window.editToolbar" => {
+            app.ui.tools_drawer
+                && crate::custom_toolbars::drawer_target(&app.ui).map(crate::custom_toolbars::key)
+                    == p.get("toolbar").and_then(Value::as_str).map(crate::custom_toolbars::key)
+        }
+        "window.toolbar.custom" => {
+            p.get("name").and_then(Value::as_str).and_then(|name| crate::custom_toolbars::find(&app.ui, name)).is_some_and(|b| b.shown)
+        }
         "window.floatTools" => {
             let tool = p.get("tool").and_then(Value::as_str).unwrap_or("");
-            app.ui.floating_flyouts.iter().any(|f| f.tools.iter().any(|id| id == tool))
+            let toolbar = p.get("toolbar").and_then(Value::as_str);
+            crate::toolbar::strips(&app.ui, toolbar).iter().any(|f| f.tools.iter().any(|id| id == tool))
         }
         "window.taskBar" => app.ui.task_bar,
         "window.taskBar.pin" => app.ui.task_bar_place.pinned,
diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs
--- a/crates/ui-egui/src/menus/tree.rs
+++ b/crates/ui-egui/src/menus/tree.rs
@@ -607,7 +607,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
                 c("Control", "window.control"),
                 c("Contextual Task Bar", "window.taskBar"),
                 c("Tools", "window.toolbar"),
-                sub("Toolbars", vec![c("Advanced", "window.toolbarAdvanced"), c("Double Column", "window.toolbarColumns")]),
+                sub("Toolbars", crate::custom_toolbars::menu_items()),
                 Sep,
                 panel("Actions", "actions"),
                 panel("Align", "align"),
diff --git a/crates/ui-egui/src/menus/ui_commands.rs b/crates/ui-egui/src/menus/ui_commands.rs
--- a/crates/ui-egui/src/menus/ui_commands.rs
+++ b/crates/ui-egui/src/menus/ui_commands.rs
@@ -373,14 +373,14 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
         "window.toolbarColumns",
         "Toolbar: Single/Double Column",
         "",
-        "{double?: bool} show the toolbar's tools in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state",
+        "{double?: bool, toolbar?: name} show the toolbar's tools (with `toolbar`, that toolbar made with New Toolbar's) in two columns (true), one (false) or toggle (omitted), as the double arrow at the top of the toolbar does; returns the new state",
     ),
     ("window.toolbarAdvanced", "Toolbar: Advanced / Basic", "", "{}"),
     (
         "window.floatTools",
         "Float Tool Group",
         "",
-        "{tool: id, floating?: bool} float the toolbar group holding `tool` (in the current Basic or Advanced layout) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state",
+        "{tool: id, floating?: bool, toolbar?: name} float the toolbar group holding `tool` (in the current Basic or Advanced layout; with `toolbar`, in that toolbar made with New Toolbar) as its own strip of tool buttons (true), put it back in the toolbar (false) or toggle (omitted), as dragging or clicking a flyout's tear-off bar and the strip's × do; returns the new state",
     ),
     (
         "toolbar.show",
@@ -392,26 +392,39 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
         "window.editToolbar",
         "Edit Toolbar",
         "",
-        "{open?: bool} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar, as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state",
+        "{open?: bool, toolbar?: name} open (true), close (false) or toggle (omitted) the All Tools drawer beside the toolbar (with `toolbar`, beside that toolbar made with New Toolbar, which shows), as the toolbar's ••• button does; while it is open, tools drag from it onto the toolbar and from the toolbar into it (Escape or a click outside closes it); returns the new state",
     ),
     (
         "toolbar.addTool",
         "Toolbar: Add Tool",
         "",
-        "{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\"} put `tool` in the current (Basic or Advanced) toolbar, moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)",
+        "{tool: id, slot?: n, place?: \"before\"|\"after\"|\"group\", toolbar?: name} put `tool` in the current (Basic or Advanced) toolbar (with `toolbar`, in that toolbar made with New Toolbar), moving it if it is there already: before (default) or after slot `slot` (0 = the first; omitted: at the end) in its own slot, or into that slot's group (its flyout), as dropping a tool from the Edit Toolbar drawer does; saved with the preferences; returns the toolbar's slots (lists of tool ids)",
     ),
     (
         "toolbar.removeTool",
         "Toolbar: Remove Tool",
         "",
-        "{tool: id, group?: bool} take `tool` (with `group`, its whole group) out of the current toolbar (one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots",
+        "{tool: id, group?: bool, toolbar?: name} take `tool` (with `group`, its whole group) out of the current toolbar (with `toolbar`, out of that toolbar made with New Toolbar; one not in it stays out), as dragging it into the Edit Toolbar drawer does; returns the toolbar's slots",
     ),
     (
         "toolbar.reset",
         "Toolbar: Reset",
         "",
-        "{} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes; returns its slots",
+        "{toolbar?: name} put the current (Basic or Advanced) toolbar back as it ships, undoing Edit Toolbar's changes (with `toolbar`, make that toolbar made with New Toolbar blank again); returns its slots",
     ),
+    (
+        "window.toolbar.new",
+        "New Toolbar…",
+        "",
+        "{name?: string} make a blank toolbar called `name` (omitted: a dialog asks for the name), floating beside the Tools panel and listed under Window › Toolbars; its ••• button opens the All Tools drawer to fill it (window.editToolbar with `toolbar`); it is kept with the current workspace; returns {toolbar: name}",
+    ),
+    (
+        "window.toolbar.custom",
+        "Show Toolbar",
+        "",
+        "{name: string, show?: bool} show (true), hide (false) or toggle (omitted) the toolbar made with New Toolbar called `name`, as its item under Window › Toolbars does (its title bar's × hides it); returns the new state",
+    ),
+    ("window.toolbar.list", "List Toolbars", "", "{} the toolbars made with New Toolbar in the current workspace: [{name, shown, double, slots}]"),
     ("window.taskBar", "Contextual Task Bar", "", "{}"),
     (
         "window.taskBar.pin",
diff --git a/crates/ui-egui/src/shortcut_editor.rs b/crates/ui-egui/src/shortcut_editor.rs
--- a/crates/ui-egui/src/shortcut_editor.rs
+++ b/crates/ui-egui/src/shortcut_editor.rs
@@ -124,6 +124,7 @@ pub fn sync(ui: &UiState) {
         GENERATION.fetch_add(1, Ordering::Relaxed);
     }
     crate::workspaces::sync(ui);
+    crate::custom_toolbars::sync(ui);
 }
 
 /// Registry default shortcut of a command (engine or UI command).
diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs
--- a/crates/ui-egui/src/state.rs
+++ b/crates/ui-egui/src/state.rs
@@ -368,6 +368,13 @@ pub struct UiState {
     /// Edit Toolbar's All Tools drawer is open beside the toolbar (`window.editToolbar`).
     #[serde(skip)]
     pub tools_drawer: bool,
+    /// The toolbars made with New Toolbar, in the order they were made.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub custom_toolbars: Vec<CustomToolbar>,
+    /// The toolbar made with New Toolbar that the open All Tools drawer edits (none: the Tools
+    /// panel's toolbar).
+    #[serde(skip)]
+    pub drawer_toolbar: Option<String>,
     /// Floating panel groups as files from before the canvas joined [`Self::docking`] saved
     /// them. Read only to convert those files ([`crate::layout_migration`]), never written.
     #[serde(default, skip_serializing)]
@@ -610,6 +617,44 @@ pub struct FloatingFlyout {
     pub pos: [f32; 2],
 }
 
+impl FloatingFlyout {
+    /// Keep strips of known tools, one per group (a hand-edited preferences file).
+    pub fn sanitize(strips: &mut Vec<FloatingFlyout>) {
+        let mut seen = std::collections::BTreeSet::new();
+        strips.retain_mut(|f| {
+            f.tools.retain(|id| vectorcraft_tools::tool_info(id).is_some());
+            f.tools.first().is_some_and(|k| seen.insert(k.clone()))
+        });
+    }
+}
+
+/// A toolbar made with Window › Toolbars › New Toolbar (`window.toolbar.new`): it floats on its
+/// own, filled with tools from the All Tools drawer, and is kept with the workspace it was made in.
+/// The groups torn off it are kept with it (`floating`), so the Tools panel's strips
+/// (`UiState::floating_flyouts`) hold none of them.
+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
+pub struct CustomToolbar {
+    /// The name it is listed under in Window › Toolbars.
+    pub name: String,
+    /// Its slots, each a tool group (the first tool is the slot's default).
+    #[serde(default)]
+    pub slots: Vec<Vec<String>>,
+    /// It shows (Window › Toolbars › its name toggles it).
+    #[serde(default = "yes")]
+    pub shown: bool,
+    /// Top-left corner in screen points.
+    pub pos: [f32; 2],
+    /// The tool each slot of several tools shows, chosen last (keyed by the slot's first tool).
+    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
+    pub slot_tool: std::collections::BTreeMap<String, String>,
+    /// Its tools show in two columns (the double arrow on its title bar toggles it).
+    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
+    pub double: bool,
+    /// The groups torn off it, each floating as its own strip; they show while it does.
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub floating: Vec<FloatingFlyout>,
+}
+
 /// A category of an edited toolbar: its name (one of the Basic toolbar's; none in the Advanced
 /// toolbar) and its slots, each a tool group (the first tool is the slot's default).
 #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
@@ -749,6 +794,8 @@ impl UiState {
         self.dialog = None;
         self.flyout = None;
         self.tools_drawer = false;
+        self.drawer_toolbar = None;
+        crate::custom_toolbars::sanitize(&mut self.custom_toolbars);
         self.palette_open = false;
         self.status.clear();
         self.about = false;
@@ -756,12 +803,7 @@ impl UiState {
         if self.group_tool.len() != vectorcraft_tools::TOOL_GROUPS.len() {
             self.group_tool = UiState::default().group_tool;
         }
-        // One strip per group, of known tools (a hand-edited preferences file).
-        let mut seen = std::collections::BTreeSet::new();
-        self.floating_flyouts.retain_mut(|f| {
-            f.tools.retain(|id| vectorcraft_tools::tool_info(id).is_some());
-            f.tools.first().is_some_and(|k| seen.insert(k.clone()))
-        });
+        FloatingFlyout::sanitize(&mut self.floating_flyouts);
         sanitize_toolbar_pos(&mut self.toolbar_pos);
         crate::edit_toolbar::sanitize(&mut self.toolbar_edits);
         // `read_preferences` converted the file's older layouts already; this covers a state read
@@ -815,6 +857,8 @@ impl Default for UiState {
             toolbar_edits: ToolbarEdits::default(),
             toolbar_show: ToolbarShow::default(),
             tools_drawer: false,
+            custom_toolbars: vec![],
+            drawer_toolbar: None,
             floating_panels: vec![],
             docking: None,
             docking_hidden: Default::default(),
diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs
--- a/crates/ui-egui/src/toolbar.rs
+++ b/crates/ui-egui/src/toolbar.rs
@@ -15,7 +15,7 @@ use vectorcraft_tools::{Mods, TOOL_GROUPS, ToolInfo, ToolKey, tool_info};
 use crate::theme::{self, Tokens};
 use crate::{VectorcraftApp, icons, widgets};
 
-const PITCH: f32 = 30.0;
+pub(crate) const PITCH: f32 = 30.0;
 const WIDTH: f32 = 48.0;
 /// Seconds a press on a tool group's button is held before its flyout opens.
 const LONG_PRESS: f64 = 0.35;
@@ -92,6 +92,7 @@ pub fn remember(app: &mut VectorcraftApp, id: &str) {
             app.ui.slot_tool.insert(s[0].to_string(), id.to_string());
         }
     }
+    crate::custom_toolbars::remember(&mut app.ui, id);
 }
 
 pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
@@ -130,19 +131,30 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
     }
     // Before the flyout: one torn off this frame floats from the next (its bar mustn't be in two
     // layers in one frame).
-    floating(app, ui.ctx());
+    floating(app, ui.ctx(), false);
     crate::dialogs::dimension::floating(app, ui.ctx());
+    crate::custom_toolbars::show(app, ui.ctx());
     flyout(app, ui.ctx());
     crate::edit_toolbar::drawer(app, ui.ctx());
 }
 
+/// The toolbars made with New Toolbar while the Tools panel is hidden (Window › Tools off): their
+/// flyouts and the groups torn off them, and the All Tools drawer beside the one it edits.
+pub fn show_custom(app: &mut VectorcraftApp, ctx: &egui::Context) {
+    floating(app, ctx, true);
+    crate::custom_toolbars::show(app, ctx);
+    flyout(app, ctx);
+    if crate::custom_toolbars::drawer_target(&app.ui).is_some() {
+        crate::edit_toolbar::drawer(app, ctx);
+    }
+}
+
 /// The Tools panel's contents, docked or floating at `floating`.
 fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egui::Pos2>) {
     let t = Tokens::get(ui.ctx());
     let all = slots(app);
     let bounds = ui.max_rect();
-    // The drawer opens beside it; a click in it leaves the drawer open.
-    ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::toolbar_id(), bounds));
+    let editing = app.ui.tools_drawer && app.ui.drawer_toolbar.is_none();
     ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
     // Title bar with » and a grip strip: a drag on either floats the panel or moves it.
     let (hdr, hresp) = ui.allocate_exact_size(vec2(ui.available_width(), 14.0), Sense::click_and_drag());
@@ -180,9 +192,8 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
             ui.data_mut(|d| d.remove::<egui::Id>(held_id));
         }
         let held: Option<egui::Id> = ui.data(|d| d.get_temp(held_id));
-        // While Edit Toolbar's drawer is open, the tools drag (to new places or into the drawer)
-        // instead of opening their flyouts.
-        let editing = app.ui.tools_drawer;
+        // While Edit Toolbar's drawer edits this toolbar, the tools drag (to new places or into the
+        // drawer) instead of opening their flyouts.
         let mut buttons: Vec<(usize, egui::Rect)> = vec![];
         let mut open_flyout: Option<(Vec<&'static str>, egui::Rect)> = None;
         // A group torn off into a floating flyout is raised instead of opening its flyout.
@@ -243,7 +254,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
                     let press = if slot.len() > 1 && !editing { flyout_press(ui, &resp, rect) } else { None };
                     let alt = ui.input(|inp| inp.modifiers.alt);
                     if press.is_some() {
-                        if is_floating(app, slot[0]) {
+                        if is_floating(app, None, slot[0]) {
                             raise = Some(slot[0]);
                         } else {
                             open_flyout = Some((slot.clone(), rect));
@@ -269,26 +280,32 @@ fn body(app: &mut VectorcraftApp, ui: &mut Ui, cols: usize, floating: Option<egu
             i += row.len();
         }
         if editing {
-            crate::edit_toolbar::toolbar_drop(app, ui, &buttons, cols, bounds);
+            crate::edit_toolbar::toolbar_drop(app, ui, &buttons, cols, bounds, None);
         }
         if let Some(key) = raise {
-            raise_floating(ui.ctx(), key);
+            raise_floating(ui.ctx(), None, key);
         }
         if let Some((slot, rect)) = open_flyout {
             app.ui.flyout = Some(0);
             ui.data_mut(|d| {
                 d.insert_temp(egui::Id::unique("flyout-anchor"), rect);
                 d.insert_temp(egui::Id::unique("flyout-tools"), slot.iter().map(|s| s.to_string()).collect::<Vec<String>>());
+                d.insert_temp(flyout_toolbar_id(), None::<String>);
             });
         }
         ui.add_space(8.0);
         bottom_controls(app, ui, &t, cols);
     });
+    // While the drawer edits this toolbar (also from the ••• click just made), it opens beside it; a
+    // click in it leaves the drawer open.
+    if app.ui.tools_drawer && app.ui.drawer_toolbar.is_none() {
+        ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::toolbar_id(), bounds));
+    }
 }
 
 /// How a press on a tool group's button opens its flyout.
 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
-enum FlyoutPress {
+pub(crate) enum FlyoutPress {
     /// A right press (it opens at once, before the release).
     Secondary,
     /// A primary press on the corner triangle, or one held [`LONG_PRESS`] seconds: its release
@@ -299,7 +316,7 @@ enum FlyoutPress {
 /// Whether this frame's pointer state on a tool button opens its group's flyout. A primary press
 /// still short of a long one asks for the frame where it becomes long: egui draws only on input,
 /// so a still mouse would otherwise never get there.
-fn flyout_press(ui: &Ui, resp: &egui::Response, rect: egui::Rect) -> Option<FlyoutPress> {
+pub(crate) fn flyout_press(ui: &Ui, resp: &egui::Response, rect: egui::Rect) -> Option<FlyoutPress> {
     if resp.hovered() && ui.input(|inp| inp.pointer.button_pressed(egui::PointerButton::Secondary)) {
         return Some(FlyoutPress::Secondary);
     }
@@ -531,11 +548,15 @@ fn bottom_controls(app: &mut VectorcraftApp, ui: &mut Ui, t: &Tokens, cols: usiz
             app.run("view.screenMode", json!({})).ok();
         }
         // Edit Toolbar: the All Tools drawer opens beside the toolbar, by this button.
-        let edit = widgets::icon_button(ui, "ellipsis", "Edit Toolbar", app.ui.tools_drawer, 26.0);
-        ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::button_id(), edit.rect));
+        let editing = |app: &VectorcraftApp| app.ui.tools_drawer && app.ui.drawer_toolbar.is_none();
+        let edit = widgets::icon_button(ui, "ellipsis", "Edit Toolbar", editing(app), 26.0);
         if edit.clicked() {
             app.run("window.editToolbar", json!({})).ok();
         }
+        // After the click, which may have opened the drawer here.
+        if editing(app) {
+            ui.data_mut(|d| d.insert_temp(crate::edit_toolbar::button_id(), edit.rect));
+        }
     });
     let _ = t;
 }
@@ -546,38 +567,72 @@ const FLOAT_AT: [f32; 2] = [90.0, 100.0];
 
 /// Float the toolbar group (of the current layout) holding `tool` as a strip of tool buttons, put
 /// it back in the toolbar, or toggle (`floating` omitted), as a flyout's tear-off bar and the
-/// strip's × do (`window.floatTools`). Returns the new state.
-pub fn float_group(app: &mut VectorcraftApp, tool: &str, floating: Option<bool>) -> Result<bool, String> {
-    let Some((_, group)) = slots(app).into_iter().find(|(_, s)| s.len() > 1 && s.contains(&tool)) else {
+/// strip's × do (`window.floatTools`; `toolbar`: the group of the toolbar made with New Toolbar
+/// called that). Returns the new state.
+pub fn float_group(app: &mut VectorcraftApp, tool: &str, floating: Option<bool>, toolbar: Option<&str>) -> Result<bool, String> {
+    let groups: Vec<Vec<&'static str>> = match toolbar {
+        None => slots(app).into_iter().map(|(_, s)| s).collect(),
+        Some(name) => crate::custom_toolbars::find(&app.ui, name)
+            .map(|b| b.slots.iter().map(|s| s.iter().filter_map(|id| tool_info(id).map(|t| t.id)).collect()).collect())
+            .unwrap_or_default(),
+    };
+    let Some(group) = groups.into_iter().find(|s| s.len() > 1 && s.contains(&tool)) else {
         return Err(format!("{tool} isn't in a toolbar group of several tools"));
     };
     let key = group.first().copied().unwrap_or_default();
-    let was = is_floating(app, key);
+    let was = is_floating(app, toolbar, key);
     let on = floating.unwrap_or(!was);
+    let Some(strips) = strips_mut(&mut app.ui, toolbar) else { return Err(format!("no toolbar named `{}`", toolbar.unwrap_or_default())) };
     if on && !was {
-        let n = app.ui.floating_flyouts.len() as f32;
+        let n = strips.len() as f32;
         let tools = group.iter().map(|id| id.to_string()).collect();
-        app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [FLOAT_AT[0], FLOAT_AT[1] + n * (PITCH + 12.0)] });
+        strips.push(crate::state::FloatingFlyout { tools, pos: [FLOAT_AT[0], FLOAT_AT[1] + n * (PITCH + 12.0)] });
         app.ui.flyout = None;
     } else if !on {
-        app.ui.floating_flyouts.retain(|f| f.tools.first().is_none_or(|k| k != key));
+        strips.retain(|f| f.tools.first().is_none_or(|k| k != key));
     }
     Ok(on)
 }
 
-/// Whether the group whose first tool is `key` floats as its own panel.
-fn is_floating(app: &VectorcraftApp, key: &str) -> bool {
-    app.ui.floating_flyouts.iter().any(|f| f.tools.first().is_some_and(|k| k == key))
+/// The groups torn off the Tools panel's toolbar (`toolbar` none) or off the toolbar made with New
+/// Toolbar called `toolbar` (none when there is no such toolbar).
+pub(crate) fn strips<'a>(ui: &'a crate::state::UiState, toolbar: Option<&str>) -> &'a [crate::state::FloatingFlyout] {
+    match toolbar {
+        None => &ui.floating_flyouts,
+        Some(name) => crate::custom_toolbars::find(ui, name).map_or(&[], |b| b.floating.as_slice()),
+    }
+}
+
+fn strips_mut<'a>(ui: &'a mut crate::state::UiState, toolbar: Option<&str>) -> Option<&'a mut Vec<crate::state::FloatingFlyout>> {
+    match toolbar {
+        None => Some(&mut ui.floating_flyouts),
+        Some(name) => crate::custom_toolbars::find_mut(ui, name).map(|b| &mut b.floating),
+    }
+}
+
+/// Whether the group whose first tool is `key`, of the Tools panel's toolbar (`toolbar` none) or of
+/// the toolbar made with New Toolbar called `toolbar`, floats as its own panel.
+pub(crate) fn is_floating(app: &VectorcraftApp, toolbar: Option<&str>, key: &str) -> bool {
+    strips(&app.ui, toolbar).iter().any(|f| f.tools.first().is_some_and(|k| k == key))
 }
 
-/// The area of the floating flyout of the group whose first tool is `key`.
+/// The area of the floating flyout of the Tools panel's group whose first tool is `key`.
 fn floating_area(key: &str) -> egui::Id {
     egui::Id::unique(("tool-floating", key))
 }
 
+/// The area of the floating flyout of the group whose first tool is `key`, torn off the toolbar
+/// made with New Toolbar called `toolbar` (none: the Tools panel's).
+fn strip_area(toolbar: Option<&str>, key: &str) -> egui::Id {
+    match toolbar {
+        None => floating_area(key),
+        Some(name) => floating_area(key).with(crate::custom_toolbars::key(name)),
+    }
+}
+
 /// Bring a floating flyout to the front and flash its border.
-fn raise_floating(ctx: &egui::Context, key: &str) {
-    let id = floating_area(key);
+pub(crate) fn raise_floating(ctx: &egui::Context, toolbar: Option<&str>, key: &str) {
+    let id = strip_area(toolbar, key);
     ctx.move_to_top(egui::LayerId::new(egui::Order::Middle, id));
     let now = ctx.input(|i| i.time);
     ctx.data_mut(|d| d.insert_temp(id, now));
@@ -604,10 +659,12 @@ struct FlyoutInput {
 
 /// A flyout's tool rows (a floating flyout's strip of tool buttons) with the grab bar down their
 /// right side; a floating flyout's bar has a × at its top. The bar's id is the group's, so a drag
-/// that tears a flyout off carries on moving the floating flyout it becomes.
-fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool) -> FlyoutInput {
+/// that tears a flyout off carries on moving the floating flyout it becomes (`toolbar`: the toolbar
+/// made with New Toolbar the group is in, none for the Tools panel's).
+fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating: bool, toolbar: Option<&str>) -> FlyoutInput {
     let mut out = FlyoutInput::default();
     let key = tools.first().map_or("", String::as_str);
+    let bar_id = bar_id(toolbar, key);
     ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
     ui.horizontal(|ui| {
         let rows = if floating { ui.horizontal(|ui| tool_strip(ui, t, active, tools)) } else { ui.vertical(|ui| tool_rows(ui, t, active, tools)) };
@@ -616,7 +673,7 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating
         ui.painter().rect_filled(bar, 0.0, t.tab_strip);
         let grip = if floating {
             let x = egui::Rect::from_min_size(bar.min, vec2(BAR, CLOSE));
-            let xr = ui.interact(x, egui::Id::unique(("tool-flyout-close", key)), Sense::click());
+            let xr = ui.interact(x, bar_id.with("close"), Sense::click());
             icons::paint(ui, "x", x.shrink2(vec2(1.0, 3.0)), if xr.hovered() { t.text_strong } else { t.text });
             xr.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Put back in the toolbar")));
             out.close = xr.on_hover_text(tl!("Put back in the toolbar")).clicked();
@@ -624,7 +681,7 @@ fn flyout_body(ui: &mut Ui, t: &Tokens, active: &str, tools: &[String], floating
         } else {
             bar
         };
-        let resp = ui.interact(grip, bar_id(key), Sense::drag());
+        let resp = ui.interact(grip, bar_id, Sense::drag());
         let half = (grip.height() / 2.0 - 3.0).clamp(0.0, 9.0);
         for k in 0..3 {
             let x = grip.center().x - 2.0 + k as f32 * 2.0;
@@ -697,14 +754,19 @@ fn rows_width<'a>(rows: impl Iterator<Item = &'a RowText>) -> f32 {
 }
 
 /// The grab bar of the group whose first tool is `key` (one id, flyout or floating, so a drag
-/// that tears the flyout off carries on moving it).
-fn bar_id(key: &str) -> egui::Id {
-    egui::Id::unique(("tool-flyout-bar", key))
+/// that tears the flyout off carries on moving it), in the toolbar made with New Toolbar called
+/// `toolbar` (none: the Tools panel's).
+fn bar_id(toolbar: Option<&str>, key: &str) -> egui::Id {
+    let id = egui::Id::unique(("tool-flyout-bar", key));
+    match toolbar {
+        None => id,
+        Some(name) => id.with(crate::custom_toolbars::key(name)),
+    }
 }
 
 /// Where the pointer holds a floating flyout's bar, from the flyout's corner, while it's dragged.
-fn grab_id(key: &str) -> egui::Id {
-    bar_id(key).with("grab")
+fn grab_id(toolbar: Option<&str>, key: &str) -> egui::Id {
+    bar_id(toolbar, key).with("grab")
 }
 
 /// One icon button per tool, the active one lit, with its name and shortcut on hover. Returns the
@@ -752,11 +814,12 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
         ctx.data(|d| d.get_temp(egui::Id::unique("flyout-anchor"))).unwrap_or(egui::Rect::from_min_size(pos2(40.0, 100.0), vec2(32.0, 32.0)));
     let t = Tokens::get(ctx);
     let active = app.session.tool_id();
+    let toolbar = ctx.data(|d| d.get_temp::<Option<String>>(flyout_toolbar_id())).flatten();
     let mut input = FlyoutInput::default();
     let resp = egui::Area::new(egui::Id::unique("tool-flyout")).order(egui::Order::Foreground).fixed_pos(anchor.right_top() + vec2(8.0, -1.0)).show(
         ctx,
         |ui| {
-            flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false));
+            flyout_frame(&t, false).show(ui, |ui| input = flyout_body(ui, &t, active, &tools, false, toolbar.as_deref()));
         },
     );
     // Dragging the bar tears the flyout off, and so does releasing a press over it (a click, or the
@@ -771,9 +834,12 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
         let grab = vec2(1.0 + strip_width(&tools) - BAR / 2.0, 1.0 + CLOSE + 4.0);
         let p = at - grab;
         if let Some(key) = tools.first() {
-            ctx.data_mut(|m| m.insert_temp(grab_id(key), grab));
+            ctx.data_mut(|m| m.insert_temp(grab_id(toolbar.as_deref(), key), grab));
+        }
+        // A toolbar made with New Toolbar that has gone since its flyout opened keeps no strip.
+        if let Some(strips) = strips_mut(&mut app.ui, toolbar.as_deref()) {
+            strips.push(crate::state::FloatingFlyout { tools, pos: [p.x, p.y] });
         }
-        app.ui.floating_flyouts.push(crate::state::FloatingFlyout { tools, pos: [p.x, p.y] });
         app.ui.flyout = None;
         return;
     }
@@ -787,20 +853,38 @@ fn flyout(app: &mut VectorcraftApp, ctx: &egui::Context) {
     let _ = theme::semibold;
 }
 
+/// The toolbar made with New Toolbar whose group the open flyout shows (none: the Tools panel's).
+pub(crate) fn flyout_toolbar_id() -> egui::Id {
+    egui::Id::unique("flyout-toolbar")
+}
+
 /// The flyouts torn off the toolbar, each floating where its bar was dragged; its × puts it back.
-fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
-    if app.ui.floating_flyouts.is_empty() {
-        return;
+/// `custom_only`: the Tools panel is hidden, and only those torn off toolbars made with New Toolbar
+/// show.
+fn floating(app: &mut VectorcraftApp, ctx: &egui::Context, custom_only: bool) {
+    // A group torn off a toolbar made with New Toolbar shows while that toolbar does.
+    let owners: Vec<Option<String>> = (!custom_only)
+        .then_some(None)
+        .into_iter()
+        .chain(app.ui.custom_toolbars.iter().filter(|b| b.shown && !b.floating.is_empty()).map(|b| Some(b.name.clone())))
+        .collect();
+    for owner in owners {
+        floating_strips(app, ctx, owner.as_deref());
     }
+}
+
+/// The strips torn off one toolbar (`toolbar` none: the Tools panel's).
+fn floating_strips(app: &mut VectorcraftApp, ctx: &egui::Context, toolbar: Option<&str>) {
     let t = Tokens::get(ctx);
     let screen = ctx.content_rect();
     let now = ctx.input(|i| i.time);
     let active = app.session.tool_id();
     let mut chosen = None;
     let mut closed = None;
-    for (k, f) in app.ui.floating_flyouts.iter_mut().enumerate() {
+    let Some(strips) = strips_mut(&mut app.ui, toolbar) else { return };
+    for (k, f) in strips.iter_mut().enumerate() {
         let Some(key) = f.tools.first() else { continue };
-        let id = floating_area(key);
+        let id = strip_area(toolbar, key);
         let since = ctx.data(|d| d.get_temp::<f64>(id)).map(|t0| now - t0);
         let flash = since.is_some_and(|s| (0.0..FLASH).contains(&s));
         if let Some(s) = since.filter(|_| flash) {
@@ -811,12 +895,12 @@ fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
         let pos = crate::tools_float::clamp(f.pos, size, screen);
         let mut input = FlyoutInput::default();
         egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos).show(ctx, |ui| {
-            flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true));
+            flyout_frame(&t, flash).show(ui, |ui| input = flyout_body(ui, &t, active, &f.tools, true, toolbar));
         });
         // Dragging the bar keeps the flyout where the pointer holds it (by an offset rather than
         // each frame's motion, which a just torn-off flyout's first, unseen frame would drop).
-        let grab = grab_id(key);
-        if ctx.is_being_dragged(bar_id(key))
+        let grab = grab_id(toolbar, key);
+        if ctx.is_being_dragged(bar_id(toolbar, key))
             && let Some(at) = ctx.input(|i| i.pointer.interact_pos())
         {
             let off = ctx.data_mut(|m| *m.get_temp_mut_or_insert_with(grab, || at - pos));
@@ -832,8 +916,8 @@ fn floating(app: &mut VectorcraftApp, ctx: &egui::Context) {
         }
         chosen = chosen.or(input.chosen);
     }
-    if let Some(k) = closed.filter(|k| *k < app.ui.floating_flyouts.len()) {
-        app.ui.floating_flyouts.remove(k);
+    if let Some(k) = closed.filter(|k| *k < strips.len()) {
+        strips.remove(k);
     }
     if let Some(id) = chosen {
         app.select_tool(id);
diff --git a/crates/ui-egui/src/workspaces.rs b/crates/ui-egui/src/workspaces.rs
--- a/crates/ui-egui/src/workspaces.rs
+++ b/crates/ui-egui/src/workspaces.rs
@@ -12,7 +12,7 @@ use std::sync::atomic::Ordering;
 use serde::{Deserialize, Serialize};
 use serde_json::{Value, json};
 
-use crate::state::{Dialog, DockTab, FloatingFlyout, FloatingPanels, UiState};
+use crate::state::{CustomToolbar, Dialog, DockTab, FloatingFlyout, FloatingPanels, UiState};
 use crate::theme::Tokens;
 use crate::{VectorcraftApp, widgets};
 
@@ -54,6 +54,9 @@ pub struct Workspace {
     /// Main dock column width in screen points while `docking` is none (#1753).
     #[serde(default, skip_serializing_if = "Option::is_none")]
     pub dock_width: Option<f32>,
+    /// The toolbars made with New Toolbar in this workspace (built-in workspaces: none).
+    #[serde(default, skip_serializing_if = "Vec::is_empty")]
+    pub toolbars: Vec<CustomToolbar>,
 }
 
 impl Default for Workspace {
@@ -139,7 +142,8 @@ pub fn builtins() -> Vec<Workspace> {
 
 /// Preferences › Devices › Enable Touch Workspace (#394): on, a touch screen switches to the Touch
 /// workspace; off, or when no touch screen is reported, the workspace before that switch is
-/// restored if Touch was entered automatically.
+/// restored if Touch was entered automatically. Each switch keeps the layout it leaves and brings
+/// back the one it returns to as it was left, as Window › Workspace does.
 pub fn sync_touch_workspace(app: &mut VectorcraftApp, touch_screen: bool) {
     let auto = app.session.prefs.touch_workspace && touch_screen;
     if auto {
@@ -147,15 +151,13 @@ pub fn sync_touch_workspace(app: &mut VectorcraftApp, touch_screen: bool) {
             if app.ui.touch_workspace_restore.is_none() {
                 app.ui.touch_workspace_restore = Some(app.ui.workspace.clone());
             }
-            if let Some(w) = find(&app.ui, TOUCH) {
-                apply(&mut app.ui, &w);
-            }
+            // Touch is built in, so the switch finds it.
+            let _ = switch(&mut app.ui, TOUCH);
         }
     } else if app.ui.touch_workspace_restore.is_some() && app.ui.workspace == TOUCH {
         let back = app.ui.touch_workspace_restore.take().filter(|n| find(&app.ui, n).is_some()).unwrap_or_else(|| ESSENTIALS.to_string());
-        if let Some(w) = find(&app.ui, &back) {
-            apply(&mut app.ui, &w);
-        }
+        // `back` names a workspace that exists (or Essentials), so the switch finds it.
+        let _ = switch(&mut app.ui, &back);
     } else if !auto {
         app.ui.touch_workspace_restore = None;
     }
@@ -188,6 +190,7 @@ pub fn capture(ui: &UiState, name: &str) -> Workspace {
         docking_collapsed: Default::default(),
         toolbar_pos: ui.toolbar_pos,
         dock_width: ui.dock_width,
+        toolbars: ui.custom_toolbars.clone(),
     }
 }
 
@@ -212,6 +215,12 @@ pub fn apply(ui: &mut UiState, w: &Workspace) {
     ui.toolbar_pos = w.toolbar_pos;
     ui.dock_width = w.dock_width;
     crate::state::sanitize_toolbar_pos(&mut ui.toolbar_pos);
+    ui.custom_toolbars = w.toolbars.clone();
+    crate::custom_toolbars::sanitize(&mut ui.custom_toolbars);
+    // The drawer edits a toolbar of the workspace left: it closes.
+    if ui.drawer_toolbar.take().is_some() {
+        ui.tools_drawer = false;
+    }
     ui.flyout = None;
     ui.workspace = w.name.clone();
 }
@@ -241,10 +250,26 @@ pub fn switch(ui: &mut UiState, name: &str) -> Result<String, String> {
 pub fn reset(ui: &mut UiState) -> String {
     let w = find_saved(ui, &ui.workspace.clone()).unwrap_or_default();
     ui.workspace_changes.retain(|x| !x.name.eq_ignore_ascii_case(&w.name));
+    // The toolbars made with New Toolbar stay in the workspace they were made in.
+    let toolbars = std::mem::take(&mut ui.custom_toolbars);
     apply(ui, &w);
+    ui.custom_toolbars = toolbars;
     w.name
 }
 
+/// Keep the toolbars made with New Toolbar in the current workspace's changes, and nothing else of
+/// its layout that its changes do not already hold.
+fn keep_toolbars(ui: &mut UiState) {
+    let Some(saved) = find_saved(ui, &ui.workspace.clone()) else { return };
+    if let Some(w) = ui.workspace_changes.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&saved.name)) {
+        w.toolbars = ui.custom_toolbars.clone();
+    } else if saved.toolbars != ui.custom_toolbars {
+        ui.workspace_changes.push(Workspace { toolbars: ui.custom_toolbars.clone(), ..saved });
+        let over = ui.workspace_changes.len().saturating_sub(64);
+        ui.workspace_changes.drain(..over);
+    }
+}
+
 /// A workspace by name as saved (user workspaces shadow nothing: names are unique across both lists).
 pub fn find_saved(ui: &UiState, name: &str) -> Option<Workspace> {
     ui.custom_workspaces
@@ -274,7 +299,11 @@ pub fn save_as(ui: &mut UiState, name: &str) -> Result<(), String> {
         return Err(format!("`{name}` is a built-in workspace; choose another name"));
     }
     // The changes go into the workspace saved, which starts with none; the one left keeps the
-    // changes it had (none of these).
+    // changes it had (none of these), and the toolbars made with New Toolbar, which the workspace
+    // saved starts with a copy of.
+    if !ui.workspace.eq_ignore_ascii_case(name) {
+        keep_toolbars(ui);
+    }
     // A user workspace of that name in another case is replaced, as `find_saved` would find it.
     let w = capture(ui, name);
     match ui.custom_workspaces.iter_mut().find(|x| x.name.eq_ignore_ascii_case(name)) {
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -349,6 +349,20 @@ of `fillStroke`, `coloring`, `drawMode`, `screenMode`) shows or hides the contro
 saved with the preferences (`ui.toolbar_show`). The toolbar names its groups only with Preferences › User
 Interface › Show Tool Group Labels, off as it ships (#1736).
 
+New Toolbar: Window › Toolbars › New Toolbar… (or New Toolbar… in the drawer's ☰ menu) asks for a name, and
+`window.toolbar.new {name}` makes the toolbar at once: a blank toolbar that floats on its own, moved by its title
+bar; its tools scroll when the window is too short for them. The title bar's × hides it, and its double arrow
+(`window.toolbarColumns {toolbar, double?}`) shows its tools in one column or two. Window › Toolbars lists each
+toolbar by its name, and its item (`window.toolbar.custom {name, show?}`) shows or hides it. Window › Tools hides
+the Tools panel alone, and Tab hides these toolbars with the other panels. A toolbar's ••• button
+(`window.editToolbar {toolbar}`) opens the All Tools drawer beside it, and the drawer's drags and
+`toolbar.addTool`, `toolbar.removeTool` and `toolbar.reset` with `toolbar` edit it; the drawer has no Show section
+there. `window.floatTools {tool, toolbar}` floats one of its groups, and a group torn off it shows and hides with
+it. `window.toolbar.list` returns the toolbars (`ui.custom_toolbars` in `ui.inspect`: name, slots, shown flag,
+two-column flag, top-left corner, the groups torn off it). They are kept with the workspace they were made in:
+switching workspaces shows that workspace's toolbars, Reset Workspace keeps them, and New Workspace starts the new
+workspace with a copy of them.
+
 The Contextual Task Bar: its handle (the grip at its left end) drags it anywhere on the canvas, which always holds
 it whole, also when the window shrinks. Unpinned, the bar keeps that offset as it follows the selection, until
 another document becomes active. Its More Options (…) menu has Hide Bar (`window.taskBar`); Pin Bar Position
diff --git a/docs/gaps.md b/docs/gaps.md
--- a/docs/gaps.md
+++ b/docs/gaps.md
@@ -519,8 +519,9 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
 - **Done:** custom document ruler origin (#1212): corner drag with preview, double-click reset to the active artboard, relative X/Y inputs and native save/reopen.
 - **Done:** View › Rulers › Change to Global / Artboard Rulers (`view.globalRulers`, Cmd+Alt+R): artboard rulers, a new document's, count from the active artboard, each artboard with its own zero point; rulers, X/Y fields and guide readouts follow the mode, which is saved with the document. Estimate unchanged.
 - **Done (#2849):** View › Rulers › Show Video Rulers (`view.videoRulers`): the rulers show and count the active artboard in video pixels from its top-left corner, a pixel as many points wide as the artboard's Video Ruler Pixel Aspect Ratio and one point tall ([public source](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/organize-manage-artboards/set-video-display-options-for-artboards.html)); Hide Video Rulers returns them to the General unit and the ruler origin. Shift-snapping a new guide to ruler ticks still uses the General unit's ticks. Estimate unchanged.
-- **Missing (noted for reporters, 2026-10-14):** custom toolbars (Window › Toolbars › New Toolbar, #1759);
-  panel widths kept with a workspace (#1753). Inside the estimate below.
+- **Partial:** custom toolbars (#1759): Window › Toolbars › New Toolbar makes them, kept with the workspace they
+  were made in; Manage Toolbars (rename, copy, delete) is missing. Inside the estimate below.
+- **Done (#1753):** a saved workspace keeps the width of the dock column.
 - **Done (#4135):** floating panels open in OS windows of their own on the desktop, so they can sit on another monitor; on the web and on Wayland they float inside the window.
 - **Done:** View › New View… and Edit Views… ([public source](https://helpx.adobe.com/illustrator/using/viewing-artwork.html)): a document keeps up to 25 named views (zoom, centre and rotation), saved with the file; every one is listed by name at the bottom of the View menu (`view.goto1`–`view.goto25`), and choosing one changes the current window to it. Edit Views… lists them to rename or delete, applied together on OK as one undo step (`view.saved.edit {edits}`); a file's views are checked when it opens. Estimate unchanged.
 - **Missing:** multiple windows and Window › Arrange (Cascade, Tile, Float in Window, Consolidate All Windows).
diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md
--- a/docs/target-app-parity.md
+++ b/docs/target-app-parity.md
@@ -560,6 +560,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
   - Screen readers get the value of the color and channel sliders that panels and dialogs draw (the Color panel, the Color Picker, Swatch Options, the Navigator's Zoom, the Character panel's variable font axes, the Perspective Grid and flattener options, and the sliders of the effect, tool option and transform dialogs): the number and text of the field beside each one, such as 128 for G or 50% for an opacity, with the slider's range, which extends to include a number past the slider's end. The harmony wheel's Brightness slider, which has no field, reports its brightness in percent.
   - Panels undock (#495): a dock tab, a panel icon or a popped-out panel's title dragged out of the dock floats that panel (in an OS window of its own on the desktop, #4135), and the strip right of the dock's tabs floats the whole tabbed group. A floating group moves by its title bar, a tab dragged out of it floats on its own, and dropped on another group's title bar or tabs it stacks with that group; dropped on a dock group's tabs, which light up under the pointer, its panels join that group, and on the group's upper or lower half they make a new group above or below it (`window.panel.dock {anchor, zone}`); dropped on the icon column they become icons there, the tabbed group's panels too (`window.panel.dock {icon: true}`); dropped anywhere else on the dock, which lights up, they go back where they live (the tabbed group or the icon column), as its × and the panel menus' Float Panel / Dock Panel do (`window.panel.float`, `window.panel.dock`). The Tools panel floats by its title bar and docks again on the window's left edge. Floating groups stack into sets: dropped on the upper or lower half of another group (a line shows where), a group joins it right above or below it, and the set's groups stack top to bottom, each showing one panel, moving together by the top group's title bar, whose × docks them all; a group dragged by its tab strip leaves the set (`window.panel.float {below, above}`). A double-click on a tab collapses a floating group to its tabs (`collapsed`), and a dock group too, a group below it moving up into the room; a second double-click, or a click on another of its tabs, expands it (`window.panel.collapse`, kept with the preferences and in saved workspaces). Sets dropped on the left edge of the icon column or of another dock column dock as dock columns (a multi-column dock, `window.panel.dock {column}`), whose groups stack, tab and tear out as a floating set's do, and whose top strip floats the column again. Floating panels, their sets and the Tools panel's position are kept with the preferences and in user workspaces, and clamped into a smaller window. Not done: panels in separate OS windows, reordering tabs by dragging, collapsing a set or dock column to icons, dock columns right of the default dock, resizing floating panels and the groups of a set.
   - The dock's double arrow collapses Properties, Layers and Libraries to icons at the top of the icon column, popping out like the other icon panels, and expands them again (`window.collapseDock`, kept with the preferences and in saved workspaces). The icon column's own arrow (expanding the icon panels into a full column) is not done.
+  - Window › Toolbars › New Toolbar… and New Toolbar… in the All Tools drawer's menu ask for a name and make a blank toolbar that floats on its own, moved by its title bar and listed under Window › Toolbars, whose item shows or hides it. The title bar's × hides it, and its double arrow shows its tools in one column or two. Its ••• button opens the All Tools drawer beside it, and tools dragged from the drawer fill it. Each workspace keeps the toolbars made in it (<https://helpx.adobe.com/illustrator/using/tools.html>; `window.toolbar.new`, `window.toolbar.custom`, the `toolbar` param of `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset`; `tools_added_to_a_new_toolbar_survive_a_workspace_reload`). Manage Toolbars is not done.
   - Czech render-site labels: desktop/web file filters, diagnostics, suggested preset names and contributor tooltips/plurals now use catalog lookups; gradients have Czech adjective agreement and printer marks have a separate context (PDF/Print and Summary). Search, Home, toolbar/panel tooltips and Preferences already look up their labels. Every complete catalog has rows for these labels and for the Roman and Japanese printer mark styles. Evidence: `i18n::tests::czech_render_site_follow_up`, `i18n::tests::complete_catalogs_translate_render_site_labels`, `dialogs::tests_modal::printer_mark_labels_are_distinct_from_language_names`; see [localization-parity.md](localization-parity.md).
   - The History panel, Edit › Undo and Edit › Redo translate the names of the steps that apply or redefine a character or paragraph style and of the step that redefines a graphic style: each such step keeps its template (`Apply {name}`, `Redefine {name}`, `Redefine Graphic Style “{name}”`) and the style's name, every complete catalog has a row for each template, and the panel and the menu show the style's name as the document holds it, except the Normal styles' names, which they show in the interface language (`steps_named_after_a_style_list_the_template_around_the_name`, `undo_and_redo_name_a_style_step_around_the_style_name`).
   - Localised UI: menus (in-window and native), panels, dialogs, toolbar and Preferences read from per-language catalogs (`crates/ui-egui/src/i18n`, see `docs/development.md`); Traditional Chinese (Taiwan), Simplified Chinese (mainland), Japanese, Korean, Vietnamese, German, Spanish, French, Italian, Polish, Brazilian Portuguese, Russian, Ukrainian and Georgian ship complete, and Czech covers every menu label (and more), with system-locale detection on macOS, Windows, Linux and the web (the browser's languages, or `?lang=`), a Language preference and, on macOS, a VectorCraft ▸ Language menu. Names are never translated (fonts and their styles, layers, artboards, saved presets, user libraries, custom workspaces, recent files, plug-ins).
diff --git a/docs/ui-parity.md b/docs/ui-parity.md
--- a/docs/ui-parity.md
+++ b/docs/ui-parity.md
@@ -276,7 +276,8 @@ empty), without opening the dialog.
 | Panels dragged onto a dock group's tabs join it; onto its upper or lower half they make a new group above or below it (a top/bottom split, resizable); onto the icon column they become icons, the tabbed group's panels too; a dock tab's context menu closes it (Window menu reopens it) | done | `window.panel.dock {anchor, zone}`, `window.panel.dock {icon: true}`, `window.panel.close` |
 | A double-click on a dock group's tab collapses the group to its tabs (a group below it moves up into the room) and a second one expands it; a click on another of its tabs expands it showing that panel ([helpx: Workspace basics](https://helpx.adobe.com/illustrator/using/workspace-basics.html), panel groups) | done | `window.panel.collapse {panel, collapsed?}`; in dock groups and floating windows; a group beside another (a left/right split) doesn't collapse |
 | Panels outside the app window (another monitor) | done | floating groups in OS windows of their own on the desktop (#4135) |
-| Edit Toolbar (•••): the All Tools drawer opens beside the toolbar by its button (Escape or a click outside closes it), dims the tools already in the toolbar; while it is open, tools drag from it onto the toolbar (between two tools, or onto one to join its group), from the toolbar into it, and within the toolbar; each toolbar keeps its edits | done | #1018; `window.editToolbar`, `toolbar.addTool`/`removeTool`/`reset`; the drawer's Reset (Illustrator has it in a drawer menu); dragging a toolbar button moves the tool it shows, not its whole group; New/Manage Toolbars missing; tool flyouts are as wide as their longest translated name and shortcut need (#1616) |
+| Edit Toolbar (•••): the All Tools drawer opens beside the toolbar by its button (Escape or a click outside closes it), dims the tools already in the toolbar; while it is open, tools drag from it onto the toolbar (between two tools, or onto one to join its group), from the toolbar into it, and within the toolbar; each toolbar keeps its edits | done | #1018; `window.editToolbar`, `toolbar.addTool`/`removeTool`/`reset`; the drawer's Reset (Illustrator has it in a drawer menu); dragging a toolbar button moves the tool it shows, not its whole group; Manage Toolbars missing; tool flyouts are as wide as their longest translated name and shortcut need (#1616) |
+| Window › Toolbars › New Toolbar… (also in the All Tools drawer's menu): a name and OK make a blank floating toolbar, listed under Window › Toolbars (its item shows or hides it); its title bar moves it, its × hides it and its double arrow shows its tools in one column or two; its ••• button opens the All Tools drawer beside it to fill it; kept with the workspace it was made in | done | #1759 in part; `window.toolbar.new`, `window.toolbar.custom`, `window.toolbar.list`, the `toolbar` param of `window.toolbarColumns`, `window.editToolbar` and `toolbar.addTool`/`removeTool`/`reset` |
 | Quick Image Trace presets trace at once and leave the panel as it was; the Image Trace button (Control bar, Properties) opens the editable controls | done | Per the public documentation, choosing a preset traces the image with it straight away, and the panel is opened with the Image Trace button in the Control panel or in Properties' Image Trace section (<https://helpx.adobe.com/illustrator/using/image-trace-results-optimization.html>). The controls show Threshold, Paths, Corners and Noise with Advanced open; `panels/image_trace.rs` headless tests (`a_quick_trace_leaves_the_panel_as_it_was`, `sketch_quick_trace_reveals_all_adjustments_and_keeps_the_source` for the button's route). No algorithm parity claim. |
 | Image Trace panel: holding the eye button next to View down shows the selected Image Trace objects' source image on the canvas | done | Per the public documentation, the control next to View is pressed and held to compare the trace result with the source image (<https://web.archive.org/web/20250831090623/https://helpx.adobe.com/illustrator/using/image-trace.html>). On release the objects show their View again; the document, its View and the undo history are unchanged. `RenderOptions::trace_source`; `panels/image_trace.rs` headless test `holding_the_eye_button_shows_the_source_image_until_released`, render test `trace_source_draws_the_source_image_of_the_listed_objects`. The Control bar and Properties have no such button |
 | Live trace result bounds and selection highlights exclude the retained bitmap; source-image views retain it | done | All TraceView modes and rotated result bounds regression-covered; ordinary hidden group behavior retained |
