diff --git a/ROADMAP.md b/ROADMAP.md
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -380,6 +380,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
 
 Newest first.
 
+- **2026-10-24** · Locked objects and objects on locked layers can't be selected: the Selection and Direct Selection tools pass over a locked object (a click reaches what is behind it, and its content can't be grabbed), and neither an object's row nor a locked layer's selection square in the Layers panel selects it; locking or hiding a layer deselects its objects. Before, they could be selected (even their content) but not edited. No readiness percentage change.
+- **2026-10-23** · Locked layers: their objects can't be selected from the Layers panel either (an object's row or the layer's selection square), as they can't on the canvas, and locking or hiding a layer deselects its objects. Before, they could be selected but not edited. No readiness percentage change.
 - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change.
 - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change.
 - **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change.
diff --git a/crates/doc/src/edit.rs b/crates/doc/src/edit.rs
--- a/crates/doc/src/edit.rs
+++ b/crates/doc/src/edit.rs
@@ -617,15 +617,21 @@ impl Document {
     /// Hit test: the frontmost visible, unlocked-layer item on spread `si` containing `p` (spread space).
     /// Frames hit by area if they have a fill or are text/graphic frames; otherwise near the outline.
     pub fn hit_item(&self, si: usize, p: Point, tol: f64) -> Option<ItemId> {
+        self.hit_item_where(si, p, tol, |_| true)
+    }
+
+    /// [`Self::hit_item`] among the objects `take` accepts: the frontmost of those under `p`,
+    /// whatever lies above it (a click passes through locked objects to select what is behind).
+    pub fn hit_item_where(&self, si: usize, p: Point, tol: f64, take: impl Fn(&Item) -> bool) -> Option<ItemId> {
         let sp = self.spreads.get(si)?;
         let mut items: Vec<&Arc<Item>> = sp.items.iter().collect();
         items.sort_by_key(|i| self.layer_rank(i.layer));
         for it in items.into_iter().rev() {
             let layer = self.layer(it.layer);
-            if it.hidden || layer.is_some_and(|l| !l.visible || l.locked) {
+            if it.hidden || layer.is_some_and(|l| !l.visible || l.locked) || !take(it) {
                 continue;
             }
-            if item_hit(it, p, tol) {
+            if item_hit_where(it, p, tol, &take) {
                 return Some(it.id);
             }
         }
@@ -868,9 +874,15 @@ fn story_has_hit(st: &Story, depth: usize, hit: &impl Fn(&Item) -> bool) -> bool
 
 /// Does `p` (in the item's parent space) hit `it`?
 pub fn item_hit(it: &Item, p: Point, tol: f64) -> bool {
+    item_hit_where(it, p, tol, &|_| true)
+}
+
+/// [`item_hit`] counting only the parts of a group that `take` accepts: a group whose locked
+/// child lies under `p` is not hit there, so the click reaches what is behind it.
+pub fn item_hit_where(it: &Item, p: Point, tol: f64, take: &dyn Fn(&Item) -> bool) -> bool {
     if let Content::Group { items } = &it.content {
         let inner = it.xf.inverse() * p;
-        return items.iter().any(|c| item_hit(c, inner, tol));
+        return items.iter().any(|c| take(c) && item_hit_where(c, inner, tol, take));
     }
     let inner = it.xf.inverse() * p;
     let bp = it.path.to_bezpath();
diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs
--- a/crates/doc/src/lib.rs
+++ b/crates/doc/src/lib.rs
@@ -52,7 +52,7 @@ pub use datamerge::{
 };
 pub use designcraft_color as color;
 pub use designcraft_geom as geom;
-pub use edit::{ItemLoc, ItemPath, SpreadRef, hit_descendant, item_hit as edit_hit};
+pub use edit::{ItemLoc, ItemPath, SpreadRef, hit_descendant, item_hit as edit_hit, item_hit_where as edit_hit_where};
 pub use endnotes::{ENDNOTE_REF, EditorialNote, EndnoteOptions, NOTE_MARK};
 pub use file_info::FileInfo;
 pub use flex::{FlexAlign, FlexDirection, FlexJustify, FlexLayout, FlexSize};
diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs
--- a/crates/engine/src/cmd/layout.rs
+++ b/crates/engine/src/cmd/layout.rs
@@ -856,7 +856,7 @@ pub fn specs() -> Vec<CommandSpec> {
                 let id = LayerId(p.get("id").and_then(Value::as_u64).unwrap_or(0));
                 let f = |k: &str| p.get(k).and_then(Value::as_bool).unwrap_or(false);
                 let (hide, lock, show, unlock) = (f("hide"), f("lock"), f("show"), f("unlock"));
-                s.edit(|d, _| {
+                s.edit(|d, sel| {
                     for l in &mut d.layers {
                         if show {
                             l.visible = true;
@@ -873,6 +873,11 @@ pub fn specs() -> Vec<CommandSpec> {
                             }
                         }
                     }
+                    // As locking or hiding one layer does.
+                    if hide || lock {
+                        let others: Vec<LayerId> = d.layers.iter().map(|l| l.id).filter(|l| *l != id).collect();
+                        deselect_on_layers(d, sel, &others);
+                    }
                     ok()
                 })
             }
@@ -1331,7 +1336,7 @@ fn set_layer(s: &mut Session, p: &Value) -> Result<Value> {
     };
     let (visible, locked, printable) = (flag("visible")?, flag("locked")?, flag("printable")?);
     let (guides, wrap) = (flag("showGuides")?, flag("suppressWrapWhenHidden")?);
-    s.edit(|d, _| {
+    s.edit(|d, sel| {
         if let Some(n) = &name
             && d.layers.iter().any(|l| l.id != id && l.name == *n)
         {
@@ -1355,10 +1360,57 @@ fn set_layer(s: &mut Session, p: &Value) -> Result<Value> {
                 *slot = v;
             }
         }
+        // Locking or hiding a layer deselects its objects: they can't be selected or edited.
+        if locked == Some(true) || visible == Some(false) {
+            deselect_on_layers(d, sel, &[id]);
+        }
         ok()
     })
 }
 
+/// Drop the objects on `layers` (nested ones too) from the selection: those layers were just
+/// locked or hidden, and their objects can't be selected or edited.
+fn deselect_on_layers(d: &designcraft_doc::Document, sel: &mut designcraft_doc::Selection, layers: &[LayerId]) {
+    // An object is on such a layer by its own layer or a group's around it (a group may hold
+    // objects of other layers).
+    fn collect(
+        it: &designcraft_doc::Item,
+        layers: &[LayerId],
+        inside: bool,
+        out: &mut std::collections::HashSet<designcraft_doc::ItemId>,
+        depth: usize,
+    ) {
+        let on = inside || layers.contains(&it.layer);
+        if on {
+            out.insert(it.id);
+        }
+        if depth < 64 {
+            for c in it.children() {
+                collect(c, layers, on, out, depth + 1);
+            }
+        }
+    }
+    let mut on_layer = std::collections::HashSet::new();
+    for sp in d.spreads.iter().chain(d.parents.iter()) {
+        for it in &sp.items {
+            collect(it, layers, false, &mut on_layer, 0);
+        }
+    }
+    sel.items.retain(|i| !on_layer.contains(i));
+    sel.anchors.retain(|(i, _, _)| !on_layer.contains(i));
+    if sel.items.is_empty() {
+        sel.content = false;
+    }
+    // A caret or text, or table cells, in a frame on those layers: typing would still edit it.
+    let story_there = |sid: designcraft_doc::StoryId| d.story(sid).is_some_and(|st| st.frames.iter().any(|f| on_layer.contains(f)));
+    if sel.text.is_some_and(|t| t.frame.map_or_else(|| story_there(t.story), |f| on_layer.contains(&f))) {
+        sel.text = None;
+    }
+    if sel.cells.as_ref().is_some_and(|c| story_there(c.story)) {
+        sel.cells = None;
+    }
+}
+
 #[cfg(test)]
 mod setup_tests {
     use serde_json::json;
@@ -2111,6 +2163,127 @@ mod layer_options_tests {
         // Keeping its own name is fine.
         s.execute("layer.set", &json!({"id": id, "name": "Art", "color": [1, 2, 3]})).unwrap();
     }
+
+    /// Locking or hiding a layer deselects the objects on it, nested ones too.
+    #[test]
+    fn locking_or_hiding_a_layer_deselects_its_objects() {
+        let mut s = crate::Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        let first = s.doc().unwrap().doc.layers[0].id.0;
+        let a = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap();
+        let b = s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()["id"].as_u64().unwrap();
+        s.execute("object.group", &json!({"ids": [a, b]})).unwrap();
+        let other = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap();
+        let c = s.execute("frame.create", &json!({"rect": [40, 0, 50, 10]})).unwrap()["id"].as_u64().unwrap();
+        let sel = |s: &crate::Session| s.doc().unwrap().selection.items.iter().map(|i| i.0).collect::<Vec<_>>();
+        s.execute("selection.set", &json!({"ids": [a, c]})).unwrap();
+        s.execute("layer.set", &json!({"id": first, "locked": true})).unwrap();
+        assert_eq!(sel(&s), [c], "the group member on the locked layer is deselected");
+        s.execute("layer.set", &json!({"id": other, "visible": false})).unwrap();
+        assert!(sel(&s).is_empty(), "hiding deselects too");
+    }
+
+    /// Lock Others and Hide Others deselect what they lock or hide, and Object › Lock deselects
+    /// the object it locks.
+    #[test]
+    fn lock_others_hide_others_and_object_lock_deselect() {
+        let mut s = crate::Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        let first = s.doc().unwrap().doc.layers[0].id.0;
+        let a = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap();
+        let other = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap();
+        let b = s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()["id"].as_u64().unwrap();
+        let sel = |s: &crate::Session| s.doc().unwrap().selection.items.iter().map(|i| i.0).collect::<Vec<_>>();
+        for others in [json!({"id": other, "lock": true}), json!({"id": other, "hide": true})] {
+            s.execute("layer.others", &json!({"id": other, "show": true, "unlock": true})).unwrap();
+            s.execute("selection.set", &json!({"ids": [a, b]})).unwrap();
+            s.execute("layer.others", &others).unwrap();
+            assert_eq!(sel(&s), [b], "{others}: the object on the other layer is deselected");
+        }
+        s.execute("layer.others", &json!({"id": first, "show": true, "unlock": true})).unwrap();
+        s.execute("selection.set", &json!({"ids": [b]})).unwrap();
+        s.execute("object.lock", &json!({})).unwrap();
+        assert!(sel(&s).is_empty(), "Object › Lock deselects");
+    }
+
+    /// Locking or hiding a layer ends a caret or text selection in a text frame on it, so typing
+    /// no longer edits its story; Lock Others the same for the other layers.
+    #[test]
+    fn locking_or_hiding_a_layer_ends_typing_in_its_text_frames() {
+        let mut s = crate::Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        let layer = s.doc().unwrap().doc.layers[0].id.0;
+        let f = s.execute("frame.create", &json!({"rect": [0, 0, 200, 100], "content": "text", "text": "Hello"})).unwrap();
+        let caret = |s: &mut crate::Session| {
+            s.execute("text.select", &json!({"story": f["story"], "anchor": 2, "focus": 2})).unwrap();
+            assert!(s.doc().unwrap().selection.text.is_some());
+        };
+        for change in [json!({"id": layer, "locked": true}), json!({"id": layer, "visible": false})] {
+            s.execute("layer.set", &json!({"id": layer, "locked": false, "visible": true})).unwrap();
+            caret(&mut s);
+            s.execute("layer.set", &change).unwrap();
+            assert!(s.doc().unwrap().selection.text.is_none(), "{change}: the caret is gone");
+            assert!(s.execute("text.insert", &json!({"text": "x"})).is_err(), "{change}: nothing to type into");
+        }
+        s.execute("layer.set", &json!({"id": layer, "locked": false, "visible": true})).unwrap();
+        let other = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap();
+        caret(&mut s);
+        s.execute("layer.others", &json!({"id": other, "lock": true})).unwrap();
+        assert!(s.doc().unwrap().selection.text.is_none(), "Lock Others ends the caret on the first layer");
+    }
+
+    /// A group may hold objects of other layers: locking a layer deselects the objects on it by
+    /// their own layer or the group's around them.
+    #[test]
+    fn locking_a_layer_deselects_the_objects_of_a_group_on_it() {
+        let mut s = crate::Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        let first = s.doc().unwrap().doc.layers[0].id.0;
+        let a = s.execute("frame.create", &json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap();
+        let art = s.execute("layer.new", &json!({"name": "Art"})).unwrap()["id"].as_u64().unwrap();
+        let b = s.execute("frame.create", &json!({"rect": [20, 0, 30, 10]})).unwrap()["id"].as_u64().unwrap();
+        let g = s.execute("object.group", &json!({"ids": [a, b]})).unwrap()["id"].as_u64().unwrap();
+        let layer_of = |s: &crate::Session, id: u64| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().layer.0;
+        let sel = |s: &crate::Session| s.doc().unwrap().selection.items.iter().map(|i| i.0).collect::<Vec<_>>();
+        let unlock = |s: &mut crate::Session| {
+            for l in [first, art] {
+                s.execute("layer.set", &json!({"id": l, "locked": false})).unwrap();
+            }
+        };
+        // The objects keep their own layers inside the group.
+        assert_eq!((layer_of(&s, a), layer_of(&s, b)), (first, art));
+        // Locking an object's own layer deselects it, whatever the group's layer.
+        for (obj, layer) in [(a, first), (b, art)] {
+            unlock(&mut s);
+            s.execute("selection.set", &json!({"ids": [obj], "content": true})).unwrap();
+            s.execute("layer.set", &json!({"id": layer, "locked": true})).unwrap();
+            assert!(sel(&s).is_empty(), "{obj} on its locked layer stays selected");
+        }
+        // Locking the group's layer deselects every object in it, of any layer.
+        let group_layer = layer_of(&s, g);
+        unlock(&mut s);
+        s.execute("selection.set", &json!({"ids": [a, b], "content": true})).unwrap();
+        s.execute("layer.set", &json!({"id": group_layer, "locked": true})).unwrap();
+        assert!(sel(&s).is_empty(), "the group's layer locked: {:?}", sel(&s));
+    }
+
+    /// A click passes over a group's locked object to an unlocked one behind it: the group is not
+    /// hit where only its locked part lies.
+    #[test]
+    fn a_click_passes_over_a_groups_locked_object() {
+        let mut s = crate::Session::new();
+        s.execute("file.new", &json!({})).unwrap();
+        let behind = s.execute("frame.create", &json!({"rect": [0, 0, 100, 100]})).unwrap()["id"].as_u64().unwrap();
+        let locked = s.execute("frame.create", &json!({"rect": [20, 20, 80, 80]})).unwrap()["id"].as_u64().unwrap();
+        let other = s.execute("frame.create", &json!({"rect": [200, 0, 220, 20]})).unwrap()["id"].as_u64().unwrap();
+        s.execute("object.fill", &json!({"ids": [behind, locked, other], "swatch": "[Black]"})).unwrap();
+        let group = s.execute("object.group", &json!({"ids": [locked, other]})).unwrap()["id"].as_u64().unwrap();
+        s.execute("object.lock", &json!({"ids": [locked]})).unwrap();
+        let d = &s.doc().unwrap().doc;
+        let p = designcraft_geom::Point::new(50.0, 50.0);
+        assert_eq!(d.hit_item_where(0, p, 1.0, |it| !it.locked).map(|i| i.0), Some(behind), "the unlocked object behind");
+        assert_eq!(d.hit_item(0, p, 1.0).map(|i| i.0), Some(group), "without the filter the group is hit");
+    }
 }
 
 #[cfg(test)]
diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs
--- a/crates/tools/src/lib.rs
+++ b/crates/tools/src/lib.rs
@@ -248,6 +248,19 @@ impl ToolContext<'_> {
         self.doc.hit_item(si, sp, self.tol(4.0)).map(|id| (sr, id))
     }
 
+    /// The frontmost object under `p` that can be selected: locked objects are passed over, as
+    /// with "Prevent Selection of Locked Objects", and a click reaches what is behind them.
+    pub fn hit_unlocked(&self, p: Point) -> Option<(SpreadRef, ItemId)> {
+        let (sr, sp) = self.layout.spread_at(p)?;
+        let SpreadRef::Doc(si) = sr else {
+            // Editing a parent page: its objects pass the same lock checks.
+            let s = self.doc.spread(sr)?;
+            let open = |it: &designcraft_doc::Item| !it.locked && !it.hidden && !self.doc.layer(it.layer).is_some_and(|l| l.locked || !l.visible);
+            return s.items.iter().rev().find(|it| open(it) && designcraft_doc::edit_hit_where(it, sp, self.tol(4.0), &open)).map(|it| (sr, it.id));
+        };
+        self.doc.hit_item_where(si, sp, self.tol(4.0), |it| !it.locked).map(|id| (sr, id))
+    }
+
     /// The frontmost text frame under `p`, looking inside groups. [`Self::hit`] stops at the
     /// group, so a loaded text cursor over a text frame in a group found nothing to thread into
     /// (gaps 27); InDesign threads into it.
diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs
--- a/crates/tools/src/select.rs
+++ b/crates/tools/src/select.rs
@@ -344,7 +344,7 @@ pub fn grabbers(cx: &ToolContext, hover: Option<Point>) -> Vec<(ItemId, Point)>
     {
         ids.push(*one);
     }
-    if let Some((_, id)) = hover.and_then(|p| cx.hit(p)) {
+    if let Some((_, id)) = hover.and_then(|p| cx.hit_unlocked(p)) {
         let id = cx.doc.top_level_of(id).unwrap_or(id);
         if !ids.contains(&id) {
             ids.push(id);
@@ -364,7 +364,7 @@ pub fn grabber_at(cx: &ToolContext, p: Point) -> Option<ItemId> {
 /// hand over it.
 fn on_selected_content(cx: &ToolContext, p: Point) -> bool {
     cx.selection.content
-        && cx.hit(p).is_some_and(|(_, id)| {
+        && cx.hit_unlocked(p).is_some_and(|(_, id)| {
             let id = cx.doc.top_level_of(id).unwrap_or(id);
             cx.selection.contains(id) && cx.doc.item(id).is_some_and(holds_content)
         })
@@ -724,7 +724,7 @@ impl Tool for SelectionTool {
         if !self.direct
             && matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick)
             && ev.click_count() == 2
-            && let Some(id) = cx.hit(p).map(|(_, id)| cx.doc.top_level_of(id).unwrap_or(id))
+            && let Some(id) = cx.hit_unlocked(p).map(|(_, id)| cx.doc.top_level_of(id).unwrap_or(id))
             && cx.doc.item(id).is_some_and(holds_content)
         {
             self.drag = Drag::None;
@@ -810,13 +810,13 @@ impl Tool for SelectionTool {
                 // Cmd+Shift-click a parent item on a page: override it there and select the copy.
                 if ev.mods.cmd
                     && ev.mods.shift
-                    && cx.hit(p).is_none()
+                    && cx.hit_unlocked(p).is_none()
                     && let Some((page, id)) = cx.hit_parent_on_page(p)
                 {
                     self.drag = Drag::Pending { start: p, hit: true, content: false };
                     return vec![Action::Exec("layout.overrideParentItems".into(), json!({"page": page, "ids": [id.0]}))];
                 }
-                match cx.hit(p) {
+                match cx.hit_unlocked(p) {
                     Some((sr, id)) => {
                         let id = if self.direct { id } else { cx.doc.top_level_of(id).unwrap_or(id) };
                         let mut out = vec![];
@@ -1265,7 +1265,7 @@ impl Tool for SelectionTool {
         if self.direct {
             let over_content = matches!(self.drag, Drag::None)
                 && anchor_at(cx, p).is_none()
-                && cx.hit(p).is_some_and(|(_, id)| cx.doc.item(id).is_some_and(holds_content));
+                && cx.hit_unlocked(p).is_some_and(|(_, id)| cx.doc.item(id).is_some_and(holds_content));
             return if over_content { Cursor::Hand } else { Cursor::ArrowHollow };
         }
         if let Some(l) = self.loaded.and_then(|l| l.resolve(cx.doc)) {
@@ -1326,7 +1326,7 @@ fn handle_cursor(h: usize) -> Cursor {
 
 /// Switch to the Type tool with the caret at `p` when it is over a text frame.
 fn edit_text_at(cx: &ToolContext, p: Point) -> Option<Vec<Action>> {
-    let (_, id) = cx.hit(p)?;
+    let (_, id) = cx.hit_unlocked(p)?;
     if !cx.doc.item(id).is_some_and(|i| i.is_text_frame()) {
         return None;
     }
diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs
--- a/crates/tools/src/tests.rs
+++ b/crates/tools/src/tests.rs
@@ -1966,3 +1966,32 @@ fn loaded_text_cursor_threads_into_a_frame_inside_a_group() {
     assert_eq!(cx2.hit_text_frame(q).map(|(_, i)| i), None, "no text frame in that group");
     assert_eq!(t2.cursor(&cx2, q, Mods::default()), Cursor::NotAllowed);
 }
+
+/// Locked objects can't be selected with the selection tools: a click on one selects nothing, or
+/// passes through it to an object behind it; a marquee leaves it out.
+#[test]
+fn locked_objects_are_not_selected_and_clicks_pass_through_them() {
+    let mut d = Document::new(&NewDocument::default());
+    let behind = add_rect(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0));
+    let front = add_rect(&mut d, Rect::new(150.0, 150.0, 300.0, 300.0));
+    for id in [behind, front] {
+        d.item_mut(id).unwrap().fill = designcraft_doc::Fill::swatch("[Black]");
+    }
+    d.item_mut(front).unwrap().locked = true;
+    let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false));
+    let cx = ctx(&d, &s, &c, &l);
+    for tool in ["selection", "directSelection"] {
+        let mut t = create(tool);
+        let click = |t: &mut Box<dyn Tool>, x: f64, y: f64| {
+            let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y));
+            t.pointer(&cx, &PointerEvent::new(PointerKind::Up, x, y));
+            a
+        };
+        let ids = |a: &[Action]| match a.first() {
+            Some(Action::Exec(cmd, p)) if cmd == "selection.set" => p["ids"].clone(),
+            other => panic!("{tool}: {other:?}"),
+        };
+        assert_eq!(ids(&click(&mut t, 250.0, 250.0)), serde_json::json!([]), "{tool}: only the locked object there");
+        assert_eq!(ids(&click(&mut t, 160.0, 160.0)), serde_json::json!([behind.0]), "{tool}: through it to the object behind");
+    }
+}
diff --git a/crates/ui-egui/src/panels/layers.rs b/crates/ui-egui/src/panels/layers.rs
--- a/crates/ui-egui/src/panels/layers.rs
+++ b/crates/ui-egui/src/panels/layers.rs
@@ -331,7 +331,9 @@ fn object_rows(
                 // Its own name, or the label it shows without the brackets.
                 let current = if it.name.is_empty() { name.trim_start_matches('<').trim_end_matches('>').to_string() } else { it.name.clone() };
                 start_rename(ui, it.id.0, &current);
-            } else {
+            } else if !ls.layer_closed && !it.locked {
+                // Locked objects, and objects on a locked or hidden layer, can't be selected (they
+                // couldn't be edited).
                 cmds.push(("selection.set", json!({"ids": [it.id.0], "add": add})));
             }
         }
@@ -437,8 +439,9 @@ pub fn show(app: &mut DesignApp, ui: &mut Ui) {
             cmds.push(("layer.set", json!({"id": l.id.0, "locked": !l.locked})));
         } else if tri_r.clicked() {
             ui.data_mut(|d| d.insert_temp(open_id, !open));
-        } else if sq.clicked() {
-            let ids: Vec<u64> = spread_items.iter().filter(|i| i.layer == l.id).map(|i| i.id.0).collect();
+        } else if sq.clicked() && l.visible && !l.locked {
+            // Locked objects stay unselected (they can't be edited).
+            let ids: Vec<u64> = spread_items.iter().filter(|i| i.layer == l.id && !i.locked).map(|i| i.id.0).collect();
             cmds.push(("selection.set", json!({"ids": ids})));
         } else if resp.double_clicked() {
             // The first click of the pair activated the layer; the second opens its options.
@@ -727,6 +730,61 @@ mod tests {
         h.run_steps(3);
     }
 
+    /// Objects on a locked layer can't be selected from the panel either (they couldn't be edited),
+    /// and locking a layer deselects its objects.
+    #[test]
+    fn objects_on_a_locked_layer_are_not_selected_from_the_panel() {
+        let mut app = app();
+        let lid = app.session.active().unwrap().active_layer;
+        let a = app.run("frame.create", json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap();
+        app.run("selection.set", json!({"ids": [a]})).unwrap();
+        app.run("layer.set", json!({"id": lid.0, "locked": true})).unwrap();
+        let selected = |h: &egui_kittest::Harness<'static, DesignApp>| h.state().session.active().unwrap().selection.items.clone();
+        let mut h = panel(app);
+        assert!(selected(&h).is_empty(), "locking the layer deselected its object");
+        // Rows: Layer 1, a (the layer opened when a was selected, or open it).
+        if egui_kittest::kittest::Queryable::query_by_label(&h, "<rectangle>").is_none() {
+            click(&mut h, pos2(8.0 + 2.0 * COL_W + 9.0, row(0, 0.5, false).y));
+        }
+        click(&mut h, row(1, 0.5, false));
+        assert!(selected(&h).is_empty(), "the object's row selects nothing");
+        click(&mut h, row(0, 0.5, true));
+        assert!(selected(&h).is_empty(), "the layer's selection square selects nothing");
+        // Unlocked, both select again.
+        h.state_mut().run("layer.set", json!({"id": lid.0, "locked": false})).unwrap();
+        h.run_steps(2);
+        click(&mut h, row(0, 0.5, true));
+        assert_eq!(selected(&h), [ItemId(a)]);
+    }
+
+    /// An unlocked layer's selection square selects its objects but not the locked ones.
+    #[test]
+    fn a_layers_selection_square_leaves_its_locked_objects_out() {
+        let mut app = app();
+        let a = app.run("frame.create", json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap();
+        let b = app.run("frame.create", json!({"rect": [20, 0, 30, 10]})).unwrap()["id"].as_u64().unwrap();
+        app.run("object.lock", json!({"ids": [b]})).unwrap();
+        app.run("selection.set", json!({"ids": []})).unwrap();
+        let mut h = panel(app);
+        click(&mut h, row(0, 0.5, true));
+        assert_eq!(h.state().session.active().unwrap().selection.items, [ItemId(a)]);
+    }
+
+    /// A locked object's row selects nothing either.
+    #[test]
+    fn a_locked_objects_row_selects_nothing() {
+        let mut app = app();
+        let a = app.run("frame.create", json!({"rect": [0, 0, 10, 10]})).unwrap()["id"].as_u64().unwrap();
+        app.run("object.lock", json!({"ids": [a]})).unwrap();
+        app.run("selection.set", json!({"ids": []})).unwrap();
+        let mut h = panel(app);
+        if egui_kittest::kittest::Queryable::query_by_label(&h, "<rectangle>").is_none() {
+            click(&mut h, pos2(8.0 + 2.0 * COL_W + 9.0, row(0, 0.5, false).y));
+        }
+        click(&mut h, row(1, 0.5, false));
+        assert!(h.state().session.active().unwrap().selection.items.is_empty());
+    }
+
     #[test]
     fn selecting_an_object_opens_its_layer_in_the_panel() {
         use egui_kittest::Harness;
