diff --git a/crates/doc/src/geometry.rs b/crates/doc/src/geometry.rs index 479ca439b..cc1a218e9 100644 --- a/crates/doc/src/geometry.rs +++ b/crates/doc/src/geometry.rs @@ -152,7 +152,7 @@ fn near_path(path: &BezPath, p: Point, dist: f64) -> bool { /// boolean groups are hit where their `result` is. pub fn hit_leaf(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option { let p = doc.node(page)?; - p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None).filter(|_| !masked_out(doc, p, *c, pt))) + p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, None, HitOpts::default()).filter(|_| !masked_out(doc, p, *c, pt))) } /// Whether child `c` of `parent` is masked at `pt`: a visible mask below it (the nearest mask @@ -175,7 +175,7 @@ fn covers(doc: &Document, id: NodeId, n: &Node, pt: Point, depth: usize) -> bool /// Like [`hit_leaf`], but only within layer `root` (it included). pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult) -> Option { - hit_rec(doc, root, pt, tol, 0, result, None) + hit_rec(doc, root, pt, tol, 0, result, None, HitOpts::default()) } /// [`hit_leaf`] (or [`hit_in`] for a `root` that isn't a page) in outline mode: shapes are hit @@ -185,8 +185,8 @@ pub fn hit_in(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolRes /// when it shows them, and every layer on the edge of its box when it shows object bounds. pub fn hit_outlines(doc: &Document, root: NodeId, pt: Point, tol: f64, result: BoolResult, also: OutlineHits) -> Option { match doc.node(root) { - Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also))), - _ => hit_rec(doc, root, pt, tol, 0, result, Some(also)), + Some(p) if p.is_page() => p.children.iter().rev().find_map(|c| hit_rec(doc, *c, pt, tol, 0, result, Some(also), HitOpts::default())), + _ => hit_rec(doc, root, pt, tol, 0, result, Some(also), HitOpts::default()), } } @@ -204,13 +204,30 @@ fn on_box_edge(doc: &Document, id: NodeId, n: &Node, pt: Point, tol: f64) -> boo r.inflate(tol, tol).contains(lp) && !(r.width() > 2.0 * tol && r.height() > 2.0 * tol && r.inflate(-tol, -tol).contains(lp)) } -fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, also: Option) -> Option { +/// Options for the hit tests that the plain pick never sets. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct HitOpts { + /// Reach locked layers (and what a locked frame holds) too. A click, hover or marquee never + /// does; the Select layer list does. + pub locked: bool, +} + +fn hit_rec( + doc: &Document, + id: NodeId, + pt: Point, + tol: f64, + depth: usize, + result: BoolResult, + also: Option, + opts: HitOpts, +) -> Option { if depth > 512 { return None; } let n = doc.node(id)?; let outline = also.is_some(); - if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked { + if (!n.visible && !also.is_some_and(|a| a.hidden)) || n.locked && !opts.locked { return None; } if let NodeKind::Section { contents_hidden: true } = n.kind { @@ -228,7 +245,7 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result return None; } for c in n.paint_order().rev() { - if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also).filter(|_| !masked_out(doc, n, c, pt)) { + if let Some(h) = hit_rec(doc, c, pt, tol, depth + 1, result, also, opts).filter(|_| !masked_out(doc, n, c, pt)) { return Some(h); } } @@ -239,14 +256,20 @@ fn hit_rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result None } -/// Every visible, unlocked layer under `pt` (page coordinates) with its depth below the page, in -/// layer-tree order (each layer before what it contains, the front-most first): what -/// "Select layer" in the context menu lists. Clip content is honoured; boolean groups (hit where -/// their `result` is) and sections with hidden contents count as one layer. +/// Every visible, unlocked layer under `pt`: [`layers_at_opts`] with the default options. pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult) -> Vec<(NodeId, usize)> { - fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, out: &mut Vec<(NodeId, usize)>) { + layers_at_opts(doc, page, pt, tol, result, HitOpts::default()) +} + +/// Every visible layer under `pt` (page coordinates) with its depth below the page, in +/// layer-tree order (each layer before what it contains, the front-most first): what +/// "Select layer" in the context menu lists. Locked layers are left out unless `opts.locked`. +/// Clip content is honoured; boolean groups (hit where their `result` is) and sections with +/// hidden contents count as one layer. +pub fn layers_at_opts(doc: &Document, page: NodeId, pt: Point, tol: f64, result: BoolResult, opts: HitOpts) -> Vec<(NodeId, usize)> { + fn rec(doc: &Document, id: NodeId, pt: Point, tol: f64, depth: usize, result: BoolResult, opts: HitOpts, out: &mut Vec<(NodeId, usize)>) { let Some(n) = doc.node(id) else { return }; - if !n.visible || n.locked || depth > 512 { + if !n.visible || n.locked && !opts.locked || depth > 512 { return; } let clips = n.clip && n.kind.is_frame_like(); @@ -256,14 +279,14 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool let at = out.len(); let whole = matches!(n.kind, NodeKind::BooleanOp { .. } | NodeKind::Section { contents_hidden: true }); if whole { - if hit_rec(doc, id, pt, tol, depth, result, None).is_some() { + if hit_rec(doc, id, pt, tol, depth, result, None, opts).is_some() { out.push((id, depth)); } return; } for c in n.paint_order().rev() { let before = out.len(); - rec(doc, c, pt, tol, depth + 1, result, out); + rec(doc, c, pt, tol, depth + 1, result, opts, out); if out.len() > before && masked_out(doc, n, c, pt) { out.truncate(before); } @@ -276,7 +299,7 @@ pub fn layers_at(doc: &Document, page: NodeId, pt: Point, tol: f64, result: Bool if let Some(p) = doc.node(page) { for c in p.children.iter().rev() { let before = out.len(); - rec(doc, *c, pt, tol, 0, result, &mut out); + rec(doc, *c, pt, tol, 0, result, opts, &mut out); if out.len() > before && masked_out(doc, p, *c, pt) { out.truncate(before); } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index c03721af4..29b0f63a8 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -207,7 +207,7 @@ pub fn specs() -> Vec { spec!( "select.layersAt", "Layers at a point", - "{x, y (page coordinates)} → {layers: [{id, name, type, depth}]} (every layer under the point, front-most first; what the context menu's Select layer lists)", + "{x, y (page coordinates)} → {layers: [{id, name, type, depth, locked}]} (every layer under the point, locked ones too, front-most first; locked is true when the layer or an ancestor is locked; what the context menu's Select layer lists)", false, layers_at ), diff --git a/crates/engine/src/cmd/edit/select.rs b/crates/engine/src/cmd/edit/select.rs index 27eaf7d83..17f80af6b 100644 --- a/crates/engine/src/cmd/edit/select.rs +++ b/crates/engine/src/cmd/edit/select.rs @@ -8,9 +8,13 @@ pub(super) fn layers_at(s: &mut Session, p: &Value) -> Result { let zoom = s.ds()?.view.zoom.max(1e-6); let sel = s.selection(); let result = crate::booleans::result_outline(doc, &sel); - let layers: Vec = figcraft_doc::geometry::layers_at(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result)) + let opts = figcraft_doc::geometry::HitOpts { locked: true }; + let layers: Vec = figcraft_doc::geometry::layers_at_opts(doc, s.page()?, figcraft_geom::Point::new(x, y), 2.0 / zoom, Some(&result), opts) .into_iter() - .filter_map(|(id, depth)| doc.node(id).map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth}))) + .filter_map(|(id, depth)| { + doc.node(id) + .map(|n| json!({"id": id, "name": n.name, "type": n.kind.type_name(), "depth": depth, "locked": crate::tools::locked_in(doc, id)})) + }) .collect(); Ok(json!({"layers": layers})) } diff --git a/crates/engine/src/selection_depth_tests.rs b/crates/engine/src/selection_depth_tests.rs index a754e7343..8e04a5a66 100644 --- a/crates/engine/src/selection_depth_tests.rs +++ b/crates/engine/src/selection_depth_tests.rs @@ -118,8 +118,33 @@ fn layers_at_a_point_list_the_stack_front_most_first() { let got: Vec<(NodeId, u64)> = out["layers"].as_array().unwrap().iter().map(|l| (id_of(&l["id"]).unwrap(), l["depth"].as_u64().unwrap())).collect(); assert_eq!(got, vec![(f, 0), (over, 1), (g, 1), (a, 2)]); + assert!(out["layers"].as_array().unwrap().iter().all(|l| l["locked"] == json!(false)), "none is locked"); assert_eq!(out["layers"][0]["type"], json!("FRAME")); // Nothing under empty canvas; junk is refused. assert_eq!(ex(&mut s, "select.layersAt", json!({"x": 2000, "y": 2000}))["layers"], json!([])); assert!(s.execute("select.layersAt", &json!({"x": "left"})).is_err()); } + +/// Measured in Figma's web app, 2026-11-10 (Select layer lists a locked layer, and picking it selects it). +#[test] +fn layers_at_a_point_list_locked_layers_with_a_flag_and_select_set_takes_them() { + let mut s = Session::new(); + let (f, g, [a, ..], _) = scene(&mut s); + let over = rect_in(&mut s, 10.0, 10.0, Some(f)); + let lk = rect_in(&mut s, 10.0, 10.0, Some(f)); + ex(&mut s, "object.toggleLock", json!({"ids": [lk], "locked": true})); + let out = ex(&mut s, "select.layersAt", json!({"x": 30, "y": 30})); + let got: Vec<(NodeId, u64, bool)> = out["layers"] + .as_array() + .unwrap() + .iter() + .map(|l| (id_of(&l["id"]).unwrap(), l["depth"].as_u64().unwrap(), l["locked"].as_bool().unwrap())) + .collect(); + assert_eq!(got, vec![(f, 0, false), (lk, 1, true), (over, 1, false), (g, 1, false), (a, 2, false)]); + ex(&mut s, "select.set", json!({"ids": [lk]})); + assert_eq!(s.selection(), vec![lk]); + ex(&mut s, "select.none", json!({})); + s.pointer(&ev(PointerKind::Down, 30.0, 30.0, false, false)).unwrap(); + s.pointer(&ev(PointerKind::Up, 30.0, 30.0, false, false)).unwrap(); + assert_eq!(s.selection(), vec![over], "a click still skips the locked layer"); +} diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index d0e34d6fe..d09cb5165 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -995,6 +995,18 @@ pub fn disabled(resp: &Response) { node(resp, "", |n| n.set_disabled()); } +/// `resp`'s node also says `text` (a menu row's state, e.g. "locked"), after any description +/// already set (joined with ", "). +pub fn describe(resp: &Response, text: &str) { + node(resp, "", |n| { + let d = match n.description() { + Some(old) if !old.is_empty() => format!("{old}, {text}"), + _ => text.to_string(), + }; + n.set_description(d); + }); +} + /// What a layer row says besides its name. pub struct RowInfo<'a> { /// The layer's type ("Frame", "Text"). diff --git a/crates/ui-egui/src/context_menu_tests.rs b/crates/ui-egui/src/context_menu_tests.rs index 41c2e0be3..ab480e225 100644 --- a/crates/ui-egui/src/context_menu_tests.rs +++ b/crates/ui-egui/src/context_menu_tests.rs @@ -127,3 +127,102 @@ fn copying_a_link_from_a_frames_name_label_links_that_frame() { crate::menus::invoke(&mut u.app, "edit.copyLink"); assert_eq!(copied.lock().unwrap().last().cloned(), Some(format!("figcraft://node/{}", a.0))); } + +/// Two overlapping rectangles, the upper one locked; the menu opens on the overlap with nothing +/// selected. Returns the app's UI and the ids (lower, locked). +fn overlap_ui() -> (Ui, figcraft_doc::NodeId, figcraft_doc::NodeId) { + let mut s = Session::new(); + let lower = id(&s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 80, "height": 80, "name": "Lower"})).unwrap()); + let upper = id(&s.execute("shape.rectangle", &json!({"x": 120, "y": 120, "width": 80, "height": 80, "name": "Upper"})).unwrap()); + s.execute("object.toggleLock", &json!({"ids": [upper], "locked": true})).unwrap(); + let mut u = Ui::new(s); + u.frame(vec![]); + (u, lower, upper) +} + +/// Measured in Figma's web app, 2026-11-10 (Lock and unlock layers: locked layers are listed in Select layer). +#[test] +fn select_layer_marks_locked_layers_with_a_padlock_and_picks_them() { + let (mut u, lower, upper) = overlap_ui(); + let at = u.app.to_screen(150.0, 150.0); + crate::canvas::open_context_menu(&mut u.app, at, false); + u.app.session.set_selection(vec![]); + crate::menus::open_select_layer(&u.ctx, true); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + // The Layers panel lists the same names; the menu's rows are the right-most copies. + let menu_row = |name: &str| { + let (_, pos, size) = crate::live_tests::texts(&shapes) + .into_iter() + .filter(|(t, _, _)| t == name) + .max_by(|a, b| a.1.x.total_cmp(&b.1.x)) + .unwrap_or_else(|| panic!("no row {name:?}")); + (pos, size) + }; + let (lower_at, upper_at) = (menu_row("Lower"), menu_row("Upper")); + assert!(lower_at.0.x > 300.0 && upper_at.0.x > 300.0, "both layers are listed in the menu"); + let in_leading = |(pos, size): (Pos2, egui::Vec2)| { + let c = pos + size / 2.0; + shapes + .iter() + .filter(|s| !matches!(s, egui::Shape::Text(_))) + .filter(|s| { + let r = s.visual_bounding_rect(); + r.is_positive() + && (r.center().y - c.y).abs() < 8.0 + && r.max.x < c.x - size.x / 2.0 - 4.0 + && r.min.x > c.x - size.x / 2.0 - 40.0 + && r.width() < 20.0 + }) + .count() + }; + assert!(in_leading(upper_at) > 0, "the locked row has a padlock in its leading column"); + assert_eq!(in_leading(lower_at), 0, "the other row has nothing there"); + let p = upper_at.0 + upper_at.1 / 2.0; + for pressed in [true, false] { + u.frame(vec![ + egui::Event::PointerMoved(p), + egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ]); + } + assert_eq!(u.app.session.selection(), vec![upper], "picking the locked row selects it"); + let _ = lower; +} + +/// The padlock is only paint: the locked row says "locked" to assistive technology too. +#[test] +fn select_layer_locked_row_is_described_as_locked() { + let (mut u, _, _) = overlap_ui(); + let at = u.app.to_screen(150.0, 150.0); + crate::canvas::open_context_menu(&mut u.app, at, false); + u.app.session.set_selection(vec![]); + crate::menus::open_select_layer(&u.ctx, true); + u.ctx.enable_accesskit(); + u.frame(vec![]); + let nodes: Vec = + u.frame_output(vec![]).platform_output.accesskit_update.expect("an AccessKit update").nodes.into_iter().map(|(_, n)| n).collect(); + let row = |name: &str| { + nodes + .iter() + .find(|n| n.role() == egui::accesskit::Role::MenuItem && n.label() == Some(name)) + .unwrap_or_else(|| panic!("no menu row {name:?}")) + }; + assert!(row("Upper").description().is_some_and(|d| d.contains("locked")), "the locked row says locked"); + assert!(!row("Lower").description().is_some_and(|d| d.contains("locked")), "the other row doesn't"); +} + +/// Measured on a single locked layer only (Figma's web app, 2026-11-10): the least certain rule. +#[test] +fn select_layer_needs_more_than_one_layer_under_the_pointer() { + let (mut u, _, _) = overlap_ui(); + let one = u.app.to_screen(105.0, 105.0); + crate::canvas::open_context_menu(&mut u.app, one, false); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(!has_text(&shapes, "Select layer"), "one layer under the pointer: no Select layer"); + let two = u.app.to_screen(150.0, 150.0); + crate::canvas::open_context_menu(&mut u.app, two, false); + u.frame(vec![]); + let shapes = flat(&u.frame_output(vec![]).shapes); + assert!(has_text(&shapes, "Select layer"), "two layers: it shows"); +} diff --git a/crates/ui-egui/src/menus/select_layer.rs b/crates/ui-egui/src/menus/select_layer.rs index 73571f38e..0628dfa3b 100644 --- a/crates/ui-egui/src/menus/select_layer.rs +++ b/crates/ui-egui/src/menus/select_layer.rs @@ -15,7 +15,8 @@ pub fn open_select_layer(ctx: &egui::Context, open: bool) { } /// The context menu's "Select layer" submenu (right-click on the canvas): every layer under the -/// click, nested ones indented, the selected ones checked. Returns whether one was picked. +/// click (locked ones with a padlock), nested ones indented, the selected ones checked; shown +/// only with more than one layer under the pointer. Returns whether one was picked. pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, t: &Tokens) -> bool { if !app.canvas.rect.contains(pos) { return false; @@ -23,15 +24,21 @@ pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, let p = app.to_page(pos); let Ok(ds) = app.session.ds() else { return false }; let tol = 2.0 / ds.view.zoom.max(1e-6); - let layers: Vec<(figcraft_doc::NodeId, usize, String)> = - figcraft_doc::geometry::layers_at(&ds.doc, ds.page, p, tol, Some(&figcraft_engine::booleans::result_outline(&ds.doc, &ds.selection))) + let opts = figcraft_doc::geometry::HitOpts { locked: true }; + let layers: Vec<(figcraft_doc::NodeId, usize, String, bool)> = { + let result = figcraft_engine::booleans::result_outline(&ds.doc, &ds.selection); + figcraft_doc::geometry::layers_at_opts(&ds.doc, ds.page, p, tol, Some(&result), opts) .into_iter() .take(40) .filter_map(|(id, depth)| { - ds.doc.node(id).map(|n| (id, depth, if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() })) + ds.doc.node(id).map(|n| { + let name = if n.name.is_empty() { n.kind.type_name().to_lowercase() } else { n.name.clone() }; + (id, depth, name, figcraft_engine::tools::locked_in(&ds.doc, id)) + }) }) - .collect(); - if layers.is_empty() { + .collect() + }; + if layers.len() < 2 { return false; } let sel = ds.selection.clone(); @@ -40,8 +47,8 @@ pub(super) fn select_layer_menu(app: &mut FigApp, ui: &mut Ui, pos: egui::Pos2, SubmenuOptions { area: Id::unique("ctx_select_layer"), key: SELECT_LAYER, label: tr("Select layer"), gap: 4.0, constrain: true, tokens: t }, |ui| { let mut picked = None; - for (id, depth, name) in &layers { - if indented_item(ui, name, "", true, sel.contains(id), 12.0 * (*depth).min(8) as f32, t) { + for (id, depth, name, locked) in &layers { + if indented_item(ui, name, "", true, sel.contains(id), *locked, 12.0 * (*depth).min(8) as f32, t) { picked = Some(*id); } } diff --git a/crates/ui-egui/src/menus/tree.rs b/crates/ui-egui/src/menus/tree.rs index ac4e7059d..7443ffc57 100644 --- a/crates/ui-egui/src/menus/tree.rs +++ b/crates/ui-egui/src/menus/tree.rs @@ -395,17 +395,22 @@ fn need_width(ui: &Ui, w: f32) { } pub(crate) fn item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, t: &Tokens) -> bool { - indented_item(ui, label, shortcut, enabled, checked, 0.0, t) + indented_item(ui, label, shortcut, enabled, checked, false, 0.0, t) } -/// A menu row whose label starts `indent` pixels further in (nested layers). -pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, indent: f32, t: &Tokens) -> bool { +/// A menu row whose label starts `indent` pixels further in (nested layers); `locked` paints a +/// padlock in the leading column (a checked row shows the check instead). +#[allow(clippy::too_many_arguments)] +pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: bool, checked: bool, locked: bool, indent: f32, t: &Tokens) -> bool { let w = ui.available_width(); let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click()); a11y::menu_item(&resp, label, shortcut, checked.then_some(true)); if !enabled { a11y::disabled(&resp); } + if locked { + a11y::describe(&resp, i18n::tr("locked")); + } // The keyboard's item is highlighted like the hovered one. a11y::own_focus(&resp); let hov = plain_row_hover(ui, &resp) && enabled; @@ -415,6 +420,8 @@ pub(super) fn indented_item(ui: &mut Ui, label: &str, shortcut: &str, enabled: b let col = if enabled { t.menu_text } else { t.menu_dim }; if checked { icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Check, col); + } else if locked { + icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(12.0, 12.0)), Icon::Lock, col); } let galley = ui.painter().layout_no_wrap(label.to_string(), regular(12.0), col); let keys = (!shortcut.is_empty()) diff --git a/docs/mcp.md b/docs/mcp.md index 8d03540ba..9b44a0c55 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2084,8 +2084,8 @@ turned off 0°. `node.set {pointCount}` keeps stars to 3–60 points; `miterAngle` (degrees, the Design panel's Miter angle) sets the miter limit (`limit = 1 / sin(angle / 2)`); `get_node` reports a join other than miter (`strokeJoin`) or a miter angle other than the default ~28.96° (`miterAngle`). -- **Select**: `select.layersAt {x, y}` lists every layer under a page point, front-most first with its - depth (the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter +- **Select**: `select.layersAt {x, y}` lists every layer under a page point, locked ones too, front-most first with its + depth and a `locked` flag (true when the layer or an ancestor is locked; the context menu's Select layer); `select.set`, `select.children` / `select.parent` (Enter / Shift+Enter), `select.next` / `select.previous` (Tab / Shift+Tab). With `pointer`, `cmd: true` clicks select the nested layer, `cmd` drags from empty canvas select the nested layers they touch, and `shift` drags toggle what they touch. A press on the background of a top-level frame with diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6a9760362..d21e9c658 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Align is disabled for a layer in an auto layout frame's flow, as measured in Figma's web app on 2026-11-12, with a tip saying the frame places it; M8: Scale tool handle drags pivot on the opposite handle whatever the origin, and the origin starts at the centre (measured in Figma's web app, 2026-11-09); M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (Align is disabled for a layer in an auto layout frame's flow, as measured in Figma's web app on 2026-11-12, with a tip saying the frame places it; Select layer lists locked layers with a padlock and selects them, and shows only with more than one layer under the pointer (#1560); M8: Scale tool handle drags pivot on the opposite handle whatever the origin, and the origin starts at the centre (measured in Figma's web app, 2026-11-09); M8: a selected polygon or star has a Count handle on the canvas, on its first point clockwise from the top; Zoom to selection leaves a 40 pt margin and clears the bottom toolbar, measured in Figma's web app; M1: Paste over selection puts the pasted layers just above the selected layer; drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis, Alt copies, Shift keeps a resize's ratio, Alt keeps its centre, all read afresh on every move, and FigCraft matches (Ctrl/Cmd not measurable); a mirror mode (angle, or angle and length) on a sharp point turns it smooth, as in Figma (#2110); M8: Tab while renaming the last layer wraps to the first row, measured in Figma's web app on 2026-11-12; the text caret takes the text's solid fill colour (#2156); the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); the eyedropper picks a magnified image's pixel under the pointer, not a blend with its neighbours (#2189); Selection colors shows from one distinct colour when a selected layer has children, and from two otherwise, measured in Figma's web app on 2026-11-09 on a frame, a group and plain rectangles; the crosshair of the shape and Text tools is drawn dark with a white edge, so it shows on a white frame whatever the system's pointer style (#2198); a boolean group takes a corner radius (and smoothing) that rounds every sharp corner of its result (#2222); the Point section's X and Y in vector edit mode show Mixed and set every selected point of the layer being edited, as in Figma; Preferences ▸ Invert zoom direction reverses wheel zoom, pinch unchanged (#2145); Shift while rotating lands on absolute multiples of 15°, measured in Figma; Flatten bakes a vector's point corner radius into its path (#2195); a click on a fill, stroke or effect row's grip or background highlights it, Cmd/Ctrl+C copies that paint or effect and Cmd/Ctrl+V pastes it onto the selected layers, as the help center's Copy and paste properties between layers says (#1494); wrap style per paragraph and in text styles (#1827); the Annotation tool (Shift+T, `tool.annotation {id?}`): with a layer selected (or an `id`) it asks the UI for that layer's note at once, and in the tool a press on a layer selects it and asks for its note, a press on empty canvas does nothing; the marquee follows Figma's measured rules: a partly covered frame or section gives the layers in it the marquee touches, a rotated shape is touched by its outline, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole) (`marquee_behaviour_tests`); copy, cut and paste in vector edit mode act on the selected points and segments (#1875); M8, #2146: K focuses the Scale tool's factor field with its text selected, so K, 2, Enter scales the selection by two about the panel's origin (FigCraft's starts at the top left; Figma's grid starts at the centre, left for a follow-up because the origin also sets where a handle drag pivots, which is not measured) in one undo step, after which the tool returns to Move with the selection kept; Escape in the field closes it with the tool back on Move and nothing changed (measured in Figma's web app, 2026-11-09; `pressing_k_focuses_the_scale_field_with_its_text_selected`, `k_two_enter_scales_the_selection_by_two`, `escape_in_the_scale_field_closes_it_and_changes_nothing`); the main menu and its submenus stay inside the window, moving up to fit and scrolling when taller (#2217); locked layers ignore the arrow keys and a locked parent's children can't be locked on their own (#1560); the left rail's and tab strip's icons are larger, 20 pt (#553); pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); scrubbing a field faster or slower by the pointer's height, with a notice of the speed, as the help center's Scrub fields says (#1837); component rows in the Layers panel at AA contrast in the theme's component tint, with the rows inside a selected component in a tone of its row (#1903); Cmd/Ctrl-click on a padding field sets all four sides with the value typed, a documented auto layout shortcut; a fill per region of a vector: with regions selected in edit mode, the Fill section and the fill commands edit those regions' own fills, as Edit vector layers says (#1875); the Fill section of a text whose characters were coloured differently while editing shows mixed content, characters that all share fills the layer doesn't have show those, and a layer-level fill edit reaches every character (#1856); a number field scrubs when dragged with Alt held, as the help center's scrub section says, not only by its label; reuse visible layer selection roots (M8); ; with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it, and Enter places there, as Accessibility at Figma says (#1839); text direction per paragraph: Text ▸ Text direction forces a paragraph left to right or right to left, as Add right-to-left text says (#1830), with `text.direction`, `node.set textDirection`, the Type settings control once a right-to-left script is in the text, inspect, the CSS `direction` and the `.fig` lines' `sourceDirectionality`; the boolean operation menu in the Design panel leads each entry with an original icon drawn in code, a filled preview of the result (#2003); a press on the open gradient's handles or stops on the canvas keeps the colour picker open through the drag, as its own control (#1852); a click with the Bend tool on a sharp point curves it, its handles along the line between its neighbours, as Edit vector layers says (#1875); wrap style: Auto, Balance and Pretty on a text layer decide where its lines break, as Explore text properties says (#1827), with the Type settings control, `text.wrapStyle`, `node.set wrapStyle`, inspect and the CSS `text-wrap`; Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -61,7 +61,7 @@ interaction feel only partly compared: the interactions measured so far, case by |---|---|---| | Click, Shift-click, marquee, Shift-marquee toggle | done | Shift-drag on a selected layer moves the selection, kept to the dominant axis with no 45° snap (#498; `shift_drag_moves_the_selection`; measured in Figma's web app, 2026-11-08); a marquee selects the top-level layers it touches, by the box and a rotated shape by its outline (rectangle and ellipse measured, the other shapes by the same code); a partly covered top-level frame or section gives the layers in it the marquee touches, an enclosed one is selected itself, an empty frame on touch; a drag from a frame's background selects that frame only when the marquee also takes another top-level frame (then whole), and also takes what it touches outside it; selecting a frame whole keeps the marquee at the top level (a partly covered frame then comes whole too); locked and hidden layers are skipped (`marquee_behaviour_tests`, `selection_behaviour_tests`; measured in Figma's web app, 2026-11-11; Shift and Cmd/Ctrl marquees not measured) (a locked layer selected from the panel ignores the arrow keys and the nudge commands; its properties still edit; measured in Figma's web app, 2026-11-10) | | Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | | -| Select layer menu (every layer under the click) | done | | +| Select layer menu (every layer under the click) | done | shown only with more than one layer under the pointer; locked layers are listed with a padlock in the leading column and are selectable there, as Lock and unlock layers' note says (measured in Figma's web app, 2026-11-10; the "more than one layer" gate was measured on a single locked layer only) (`select_layer_marks_locked_layers_with_a_padlock_and_picks_them`, `select_layer_needs_more_than_one_layer_under_the_pointer`) | | Escape with layers selected | done | clears the selection at once, whatever the nesting; it never selects the parent. Measured in the Figma web app (2026-11-08) with a rectangle in a group in a frame, selected from the canvas (a double-click into the group), from the Layers panel, and after Tab moved the selection to its sibling: one Escape selects nothing, a second leaves nothing selected. Escape still leaves text and vector editing, ends gestures and the pen first, and ends the focus view once nothing or the focused frame is selected (`selection_behaviour_tests`, `devfocus_tests`) | | Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming | | Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | Alt shrinks too; past an edge flips (several layers mirror); groups and mixed rotations scale each layer along its own axes, keeping rotation and flip (no skew: children turned off a right angle are approximate); Cmd/Ctrl ignores a frame's constraints (`transform_behaviour_tests`); Shift keeps the ratio (a corner by the axis dragged most, an edge scaling the other axis about the opposite edge's middle), Alt keeps the centre, Shift+Alt does both, each read afresh on every move (`handle_drags_resize_by_the_documented_rules`; measured in Figma's web app, 2026-11-08; Ctrl/Cmd while dragging not measurable) | @@ -216,6 +216,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | M8: Align is unavailable for the one selected layer of an auto layout frame that takes part in its flow, as in Figma (measured in Figma's web app on 2026-11-12: the Design panel's Align buttons are greyed out, Alt+A and Alt+S do nothing and the frame's alignment doesn't change). `align_target` returns none for it, so `can_align` is false and the `align.*` commands leave the layout alone; the disabled tip reads "A layer in an auto layout frame is placed by its frame" (`align_blocked_by_auto_layout`). An absolutely positioned child still aligns to its frame and multi-layer selections are unchanged (neither measured). Single and multi-layer Align and Distribute otherwise matched Figma; Tidy up differs (Figma keeps the order and sizes and evens one gap per axis) and is not yet changed. Tests: `align_is_unavailable_for_a_child_in_an_auto_layout_flow`, `an_absolute_child_of_an_auto_layout_frame_still_aligns_to_it`, `the_align_buttons_are_off_for_a_layer_in_an_auto_layout_flow` | +| 2026-11-12 | patch | M5, #1560: the context menu's Select layer lists locked layers with a padlock in the leading column (the Layers panel's padlock icon; a row both selected and locked shows the check) and picking one selects it, as Lock and unlock layers' note says and as measured in Figma's web app, 2026-11-10; the submenu shows only with more than one layer under the pointer (measured on a single locked layer only: the least certain rule). Left clicks, hover and marquee still never reach a locked layer. `select.layersAt` lists locked layers too and its rows gain `locked`. Tests: `layers_at_a_point_list_locked_layers_with_a_flag_and_select_set_takes_them`, `select_layer_marks_locked_layers_with_a_padlock_and_picks_them`, `select_layer_needs_more_than_one_layer_under_the_pointer`. Counts unchanged | | 2026-11-09 | patch | Zoom to selection: the selection is fitted with a 40 pt margin into the canvas above the floating bottom toolbar (`ViewState::bottom_inset`, set by the UI each frame, never saved; `view.set` takes `bottomInset`) and centred in that area, as measured in Figma's web app on 2026-11-09. Zoom to fit is unchanged (margin not measured yet). Numbers unchanged. | | 2026-11-13 | patch | M1: Shift+Cmd/Ctrl+V (Paste over selection) puts the pasted layers just above the selected layer, in its parent and in the copied order, at its position; they went to the top of the parent's layers, so in an auto layout frame they ended the flow instead of following the selected layer. | | 2026-11-12 | patch | Drag modifiers compared in Figma's web app (2026-11-08): Shift locks a move to the dominant axis with no 45° snap, Alt copies, Shift keeps a resize's ratio (an edge scales about the opposite edge's middle), Alt keeps the centre, Shift+Alt does both, and a modifier changed mid-drag is read fresh on each move; FigCraft matches in all six, Ctrl/Cmd while dragging could not be measured; no code change | this page |