diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index dc58926fe..3a8bc553b 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -283,25 +283,34 @@ pub fn specs() -> Vec { spec!("view.zoomSelection", "Zoom to selection", "{}", false, |s, _| { let ds = s.ds_mut()?; if let Some(b) = ds.doc.bounds_of(&ds.selection) { - ds.view.fit(b, 256.0); + ds.view.fit_selection(b, 256.0); } Ok(json!({"zoom": ds.view.zoom})) }), - spec!("view.set", "Set view", "{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size)}", false, |s, p| { - let v = view(s)?; - if let Some(z) = num(p, "zoom") { - v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM); + spec!( + "view.set", + "Set view", + "{zoom?, cx?, cy? (page coordinate at the centre), vw?, vh? (viewport size), bottomInset? (height of the strip the bottom toolbar covers)}", + false, + |s, p| { + let v = view(s)?; + if let Some(z) = num(p, "zoom") { + v.zoom = z.clamp(ViewState::MIN_ZOOM, ViewState::MAX_ZOOM); + } + v.cx = num_or(p, "cx", v.cx); + v.cy = num_or(p, "cy", v.cy); + if let Some(w) = num(p, "vw") { + v.vw = w.clamp(10.0, 20000.0); + } + if let Some(h) = num(p, "vh") { + v.vh = h.clamp(10.0, 20000.0); + } + if let Some(i) = num(p, "bottomInset") { + v.bottom_inset = i.clamp(0.0, 20000.0); + } + Ok(serde_json::to_value(&*v).unwrap_or(Value::Null)) } - v.cx = num_or(p, "cx", v.cx); - v.cy = num_or(p, "cy", v.cy); - if let Some(w) = num(p, "vw") { - v.vw = w.clamp(10.0, 20000.0); - } - if let Some(h) = num(p, "vh") { - v.vh = h.clamp(10.0, 20000.0); - } - Ok(serde_json::to_value(&*v).unwrap_or(Value::Null)) - }), + ), spec!( "view.zoomAt", "Zoom tool", diff --git a/crates/engine/src/view_behaviour_tests.rs b/crates/engine/src/view_behaviour_tests.rs index 7f9e5a844..c470afe18 100644 --- a/crates/engine/src/view_behaviour_tests.rs +++ b/crates/engine/src/view_behaviour_tests.rs @@ -131,7 +131,7 @@ fn zoom_to_fit_selection_and_frames() { // (command, selection before, zoom, centre, selection after, derivation) let rows: Vec<(&str, Vec, f64, (f64, f64), Vec, &str)> = vec![ ("view.zoomFit", vec![], fit(content), (470.0, 300.0), vec![], "Shift+1 fits everything on the page into the view"), - ("view.zoomSelection", vec![r], fit(Rect::new(0.0, 0.0, 40.0, 40.0)), (920.0, 20.0), vec![r], "Shift+2 zooms to the selection"), + ("view.zoomSelection", vec![r], (800.0 - 80.0) / 40.0, (920.0, 20.0), vec![r], "Shift+2 zooms to the selection, with a 40-point margin"), ( "view.nextFrame", vec![], @@ -225,7 +225,7 @@ fn view_shortcuts_follow_the_documented_keys() { (Platform::Other, "-", m(true, false, false, false), "view.zoomOut", "Shift and - zooms out"), (Platform::Mac, "0", m(true, false, false, false), "view.zoom100", "Shift+0 zooms to 100%"), (Platform::Other, "1", m(true, false, false, false), "view.zoomFit", "Shift+1 zooms to fit"), - (Platform::Mac, "2", m(true, false, false, false), "view.zoomSelection", "Shift+2 zooms to the selection"), + (Platform::Mac, "2", m(true, false, false, false), "view.zoomSelection", "Shift+2 zooms to the selection, with a 40-point margin"), (Platform::Mac, "N", m(false, false, false, false), "view.nextFrame", "N zooms to the next frame"), (Platform::Other, "N", m(true, false, false, false), "view.prevFrame", "Shift+N zooms to the previous frame"), (Platform::Mac, "R", m(true, false, false, false), "view.rulers", "Shift+R shows and hides the rulers"), @@ -771,3 +771,151 @@ fn view_toggles_are_kept_for_the_next_launch() { } assert!(!view.outlines && !view.outlines_selection && !view.proof_srgb, "{view:?}"); } + +/// Select a fresh rectangle at the origin, set the view and run `cmd`; the rect's screen box is +/// returned as (left, top, right, bottom). +fn zoom_on(cmd: &str, inset: f64, w: f64, h: f64) -> (ViewState, (f64, f64, f64, f64)) { + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, w, h, None); + ex(&mut s, "view.set", json!({"vw": 1389, "vh": 903, "bottomInset": inset})); + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, cmd, json!({})); + let v = view(&s); + let (l, t) = v.page_to_screen(0.0, 0.0); + let (rr, b) = v.page_to_screen(w, h); + (v, (l, t, rr, b)) +} + +#[test] +fn zoom_to_selection_leaves_forty_points_and_clears_the_toolbar() { + // Measured in Figma's web app, 2026-11-09: 40 points on every side of the canvas above the toolbar. + let (v, (l, t, r, b)) = zoom_on("view.zoomSelection", 58.0, 100.0, 100.0); + assert!((v.zoom - 7.65).abs() < 0.01, "{}", v.zoom); + assert!((t - 40.0).abs() < 0.5 && (b - (845.0 - 40.0)).abs() < 0.5, "top {t}, bottom {b}"); + assert!((l - (1389.0 - r)).abs() < 0.5, "left {l}, right {r}"); +} + +#[test] +fn zoom_to_selection_on_a_wide_layer_is_limited_by_the_width() { + let (v, (l, _, r, _)) = zoom_on("view.zoomSelection", 58.0, 1000.0, 200.0); + assert!((v.zoom - (1389.0 - 80.0) / 1000.0).abs() < 0.001, "{}", v.zoom); + assert!((l - 40.0).abs() < 0.5 && ((1389.0 - r) - 40.0).abs() < 0.5, "left {l}, right {r}"); +} + +#[test] +fn zoom_to_selection_without_a_toolbar_inset_uses_the_whole_height() { + let (v, _) = zoom_on("view.zoomSelection", 0.0, 100.0, 100.0); + assert!((v.zoom - (903.0 - 80.0) / 100.0).abs() < 1e-6, "{}", v.zoom); + let (_, top) = v.page_to_screen(0.0, 0.0); + let (_, bottom) = v.page_to_screen(100.0, 100.0); + assert!((top - 40.0).abs() < 1e-6 && (bottom - 863.0).abs() < 1e-6, "top {top}, bottom {bottom}"); +} + +#[test] +fn zoom_to_selection_in_a_short_canvas_still_ends_above_the_toolbar() { + // A canvas 130 points high with the 60-point toolbar strip leaves 70 above it: too short for + // 40 points a side, so the margin shrinks and the selection stays clear of the toolbar. + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None); + ex(&mut s, "view.set", json!({"vw": 1389, "vh": 130, "bottomInset": 60})); + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, "view.zoomSelection", json!({})); + let v = view(&s); + let (_, top) = v.page_to_screen(0.0, 0.0); + let (_, bottom) = v.page_to_screen(100.0, 100.0); + assert!(top >= 0.0 && bottom <= 70.0 + 1e-6, "top {top}, bottom {bottom}"); + assert!((top - (70.0 - bottom)).abs() < 1e-6, "centred above the toolbar: top {top}, bottom {bottom}"); + // The margin shrinks to a quarter of the 70 points (17.5) and the zoom is the exact fit, not the floor. + assert!((v.zoom - 0.35).abs() < 1e-9, "{}", v.zoom); + assert!((top - 17.5).abs() < 1e-6 && (bottom - 52.5).abs() < 1e-6, "top {top}, bottom {bottom}"); +} + +/// A canvas narrower than 160 points keeps a quarter of its width on each side. +#[test] +fn zoom_to_selection_in_a_narrow_canvas_shrinks_the_side_margins() { + let mut s = Session::new(); + let r = rect(&mut s, 0.0, 0.0, 100.0, 100.0, None); + ex(&mut s, "view.set", json!({"vw": 100, "vh": 903, "bottomInset": 0})); + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, "view.zoomSelection", json!({})); + let v = view(&s); + let (left, _) = v.page_to_screen(0.0, 0.0); + let (right, _) = v.page_to_screen(100.0, 100.0); + assert!((v.zoom - 0.5).abs() < 1e-9, "{}", v.zoom); + assert!((left - 25.0).abs() < 1e-6 && (right - 75.0).abs() < 1e-6, "left {left}, right {right}"); +} + +/// A view 1000 by 800 with a 100-point toolbar strip, for the edge cases of the fit. +fn strip_view() -> ViewState { + ViewState { vw: 1000.0, vh: 800.0, bottom_inset: 100.0, ..ViewState::default() } +} + +#[test] +fn fitting_a_selection_survives_points_lines_extremes_and_junk() { + // A point: the zoom stays, and it lands in the middle of the area above the toolbar (y = 350). + let mut v = strip_view(); + v.zoom = 2.0; + v.fit_selection(Rect::new(10.0, 20.0, 10.0, 20.0), 32.0); + assert_eq!(v.zoom, 2.0); + let (x, y) = v.page_to_screen(10.0, 20.0); + assert!((x - 500.0).abs() < 1e-9 && (y - 350.0).abs() < 1e-9, "{x}, {y}"); + // A zero-width line is limited by its height, a zero-height one by its width. + let mut v = strip_view(); + v.fit_selection(Rect::new(0.0, 0.0, 0.0, 100.0), 32.0); + assert!((v.zoom - (700.0 - 80.0) / 100.0).abs() < 1e-9, "{}", v.zoom); + let mut v = strip_view(); + v.fit_selection(Rect::new(0.0, 0.0, 100.0, 0.0), 32.0); + assert!((v.zoom - (1000.0 - 80.0) / 100.0).abs() < 1e-9, "{}", v.zoom); + // A tiny one stops at the maximum zoom, a huge one at the minimum. + let mut v = strip_view(); + v.fit_selection(Rect::new(0.0, 0.0, 0.001, 0.001), 32.0); + assert_eq!(v.zoom, 32.0); + v.fit_selection(Rect::new(0.0, 0.0, 1e12, 1e12), 32.0); + assert_eq!(v.zoom, ViewState::MIN_ZOOM); + // A rectangle with any coordinate that is not a finite number changes nothing. + for bad in [ + Rect::new(0.0, f64::NAN, 10.0, 10.0), + Rect::new(0.0, 0.0, f64::INFINITY, 10.0), + Rect::new(0.0, 0.0, 10.0, f64::NAN), + Rect::new(f64::NAN, 0.0, 10.0, 10.0), + ] { + let mut v = strip_view(); + let before = (v.zoom, v.cx, v.cy); + v.fit_selection(bad, 32.0); + assert_eq!((v.zoom, v.cx, v.cy), before, "{bad:?}"); + } +} + +#[test] +fn the_bottom_inset_is_clamped_and_never_saved() { + let mut s = Session::new(); + ex(&mut s, "view.set", json!({"bottomInset": -5})); + assert_eq!(view(&s).bottom_inset, 0.0); + ex(&mut s, "view.set", json!({"bottomInset": 1e12})); + assert_eq!(view(&s).bottom_inset, 20000.0); + // Larger than the viewport: the fit still leaves a sliver of canvas and a finite centre. + ex(&mut s, "view.set", json!({"vw": 400, "vh": 300, "bottomInset": 5000})); + let r = rect(&mut s, 0.0, 0.0, 10.0, 10.0, None); + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, "view.zoomSelection", json!({})); + let v = view(&s); + assert!(v.zoom.is_finite() && v.cx.is_finite() && v.cy.is_finite(), "{v:?}"); + // It is not part of what the view saves. + let saved = serde_json::to_string(&v).unwrap(); + assert!(!saved.to_lowercase().contains("inset"), "{saved}"); + let back: ViewState = serde_json::from_str(&saved).unwrap(); + assert_eq!(back.bottom_inset, 0.0); +} + +#[test] +fn zoom_to_fit_is_unchanged() { + // Left alone on purpose: its 64-point margin is not measured against Figma yet. + let mut s = Session::new(); + rect(&mut s, 0.0, 0.0, 100.0, 100.0, None); + ex(&mut s, "view.set", json!({"vw": 1389, "vh": 903, "bottomInset": 58})); + ex(&mut s, "view.zoomFit", json!({})); + let v = view(&s); + assert!((v.zoom - (903.0 - 128.0) / 100.0).abs() < 1e-6); + // No toolbar offset: the rectangle's own centre stays in the middle of the whole viewport. + assert_eq!((v.cx, v.cy), (50.0, 50.0)); +} diff --git a/crates/engine/src/view_state.rs b/crates/engine/src/view_state.rs index fd70b4b11..8a8cc8a01 100644 --- a/crates/engine/src/view_state.rs +++ b/crates/engine/src/view_state.rs @@ -12,6 +12,10 @@ pub struct ViewState { /// Viewport size in logical pixels (set by the UI; used by zoom-to-fit). pub vw: f64, pub vh: f64, + /// Height in screen points of the strip the floating bottom toolbar covers (from its top edge to + /// the canvas's bottom edge); set by the UI every frame, 0 when it's hidden. Never saved. + #[serde(skip)] + pub bottom_inset: f64, pub pixel_grid: bool, pub layout_grids: bool, pub rulers: bool, @@ -51,6 +55,7 @@ impl Default for ViewState { zoom: 1.0, vw: 1200.0, vh: 800.0, + bottom_inset: 0.0, pixel_grid: true, layout_grids: true, rulers: false, @@ -105,6 +110,32 @@ impl ViewState { self.cx = r.center().x; self.cy = r.center().y; } + /// Fit a page rectangle for Zoom to selection: a 40 point margin on all four sides of the area + /// above the bottom toolbar (`vw` by `vh - bottom_inset`), centred in that area. + pub fn fit_selection(&mut self, r: figcraft_geom::Rect, max_zoom: f64) { + // A rectangle that isn't all finite numbers leaves the view as it is. + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + return; + } + let inset = if self.bottom_inset.is_finite() { self.bottom_inset.clamp(0.0, (self.vh - 1.0).max(0.0)) } else { 0.0 }; + let area_h = (self.vh - inset).max(1.0); + // A point (nothing to fit) is centred in the area above the toolbar at the zoom it has. + if !(r.width() > 0.0 || r.height() > 0.0) { + self.cx = r.center().x; + self.cy = r.center().y + (self.vh - area_h) / 2.0 / self.zoom.max(1e-6); + return; + } + // 40 points as measured; a canvas too small for that keeps a quarter of its size on each + // side instead, so the selection still ends above the toolbar. + let mx = (self.vw / 4.0).clamp(0.0, 40.0); + let my = (area_h / 4.0).clamp(0.0, 40.0); + let zx = (self.vw - mx * 2.0).max(1.0) / r.width().max(1e-6); + let zy = (area_h - my * 2.0).max(1.0) / r.height().max(1e-6); + self.zoom = zx.min(zy).clamp(Self::MIN_ZOOM, max_zoom); + // The rect's centre lands at screen y = area_h / 2, the page centre `(vh - area_h) / 2` below it. + self.cx = r.center().x; + self.cy = r.center().y + (self.vh - area_h) / 2.0 / self.zoom; + } /// The zoom from which the pixel grid shows (400%). pub const PIXEL_GRID_ZOOM: f64 = 4.0; /// Whether the pixel grid draws: it's on and the canvas is zoomed in to 400% or more. diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index 537dea1e6..10a82ed5c 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -379,6 +379,12 @@ impl FigApp { let worker = self.worker.take(); let mut worker = worker.unwrap_or_else(|| render_worker::Worker::new(ctx.clone())); canvas::show(self, ui, &mut worker); + // The strip the floating bottom toolbar covers, for Zoom to selection (0 when it's hidden). + let inset = + if !hidden && mobile.is_none() { f64::from(self.canvas.rect.max.y - toolbar::pill_rect(self.canvas.rect, 0.0).min.y) } else { 0.0 }; + if let Ok(ds) = self.session.ds_mut() { + ds.view.bottom_inset = inset; + } self.worker = Some(worker); if !hidden && mobile.is_none() { ui.scope_builder(egui::UiBuilder::new().id_salt("toolbar"), |ui| { diff --git a/crates/ui-egui/src/shortcuts_tests.rs b/crates/ui-egui/src/shortcuts_tests.rs index 4b223b211..fea6f21cd 100644 --- a/crates/ui-egui/src/shortcuts_tests.rs +++ b/crates/ui-egui/src/shortcuts_tests.rs @@ -90,7 +90,14 @@ fn shift_digits_zoom() { press(&mut u, Key::Num2, Modifiers::SHIFT); assert!(zoom(&u) > fit, "Shift+2 fits the smaller selection"); let v = &u.app.session.ds().unwrap().view; - assert_eq!((v.cx, v.cy), (110.0, 40.0), "centred on it"); + // Centred in the canvas above the bottom toolbar: the page centre sits half the toolbar's strip lower. + // The strip is the canvas's bottom edge less the pill's top, worked out here, not read back. + let strip = f64::from(u.app.canvas.rect.max.y - crate::toolbar::pill_rect(u.app.canvas.rect, 0.0).min.y); + assert!(strip > 20.0 && strip < 200.0, "the pill is a toolbar's height: {strip}"); + assert!((v.bottom_inset - strip).abs() < 1e-3, "the app gives the engine the pill's strip: {} vs {strip}", v.bottom_inset); + let expect_cy = 40.0 + strip / 2.0 / v.zoom; + assert_eq!(v.cx, 110.0, "centred on it"); + assert!((v.cy - expect_cy).abs() < 1e-9, "centred above the toolbar: {} vs {expect_cy}", v.cy); // `+` and `-` step the zoom. let z = zoom(&u); press(&mut u, Key::Minus, Modifiers::NONE); @@ -201,3 +208,16 @@ fn shortcuts_panel_does_not_show_on_the_home_screen() { u.frame(vec![]); assert!(!u.app.ui.shortcuts_panel, "the panel closes on the home screen"); } + +/// The strip the bottom toolbar covers is given to the engine while the toolbar shows and is zero +/// when the interface is hidden. +#[test] +fn the_toolbar_strip_is_zero_when_the_interface_is_hidden() { + let (mut u, _, _) = setup(); + u.frame(vec![]); + u.frame(vec![]); + assert!(u.app.session.ds().unwrap().view.bottom_inset > 0.0, "shown"); + u.app.session.ds_mut().unwrap().view.ui_hidden = true; + u.frame(vec![]); + assert_eq!(u.app.session.ds().unwrap().view.bottom_inset, 0.0, "hidden"); +} diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 8abaf420b..cba57a690 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-12 · **Change:** minor (Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, rounds the gap half up, tidies overlapping rows and packs a grid along its longer axis, and is greyed out for fewer than three layers, a diagonal and an irregular layout (M8); 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-12 · **Change:** minor (Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12, rounds the gap half up, tidies overlapping rows and packs a grid along its longer axis, and is greyed out for fewer than three layers, a diagonal and an irregular layout (M8); 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: @@ -119,8 +119,7 @@ interaction feel only partly compared: the interactions measured so far, case by | Item | Status | Notes | |---|---|---| -| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); Preferences ▸ Invert zoom direction (Figma's Preferences menu has it, checked 2026-11-09; #2145) reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in, by the same factor, while a trackpad pinch and panning are unchanged (only the setting's existence was measured, so the pinch is left alone); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | -| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); the arrows pan the view with nothing selected by 64 px, 320 px (five steps) with Shift (`view.pan`; measured in Figma's web app at 100% zoom: one Right press 62–64 px in two trials, Shift+Right about 320 px, reading offsets from screenshots, about ±3 px); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`) | +| Pan/zoom: wheel, pinch, Space-drag, Shift+0/1/2, Cmd+0, `+`/`-` | done | Shift-wheel pans sideways, pinch and the zoom tool keep the point under the pointer; Preferences ▸ Scroll wheel zoom makes a mouse wheel zoom about the pointer and Cmd/Ctrl+wheel scroll (a wheel told from a trackpad by the device macOS reports, elsewhere by line versus pixel steps; #1390); the arrows pan the view with nothing selected by 64 px, 320 px (five steps) with Shift (`view.pan`; measured in Figma's web app at 100% zoom: one Right press 62–64 px in two trials, Shift+Right about 320 px, reading offsets from screenshots, about ±3 px); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_testsPreferences ▸ Invert zoom direction (Figma's Preferences menu has it, checked 2026-11-09; #2145) reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in, by the same factor, while a trackpad pinch and panning are unchanged (only the setting's existence was measured, so the pinch is left alone); zoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_testszoom steps, limits (2%–25,600%) and frame order pinned where the documentation is silent (`view_behaviour_tests`); Zoom to selection (Shift+2) fits the selection into the canvas above the floating bottom toolbar with a 40 pt margin on all four sides, centred in that area, at a continuous zoom (measured in Figma's web app, 2026-11-09; `zoom_to_selection_leaves_forty_points_and_clears_the_toolbar`, `zoom_to_selection_on_a_wide_layer_is_limited_by_the_width`, `zoom_to_selection_without_a_toolbar_inset_uses_the_whole_height`); Zoom to fit (Shift+1) keeps its 64 pt margin, margin not measured yet (`zoom_to_fit_is_unchanged`) | | Rulers and guides (drag out, move, remove; rotated frames; frame guides) | done | guides belong to the page or a top-level frame (a frame moved into another keeps its guides but they stop applying); a click selects one for Delete, the arrows or the context menu's Remove guide; Alt-drag drops a copy; Alt while dragging measures to the layers in the frame it crosses; ruler numbering checked by eye only | | Frame labels (rotated, right-to-left), size badge | done | | | Outline mode | done | Outlines, outlines for the selection, mask outlines, frame outlines; shapes are clicked on their outlines, reaching layers behind; View ▸ Outlines ▸ Include hidden layers (`view.outlinesHiddenLayers`) outlines hidden layers too and Include object bounds (`view.outlinesObjectBounds`) draws the box of every layer whose outline isn't its box (groups, text, ellipses, vectors); what either draws can be clicked there (a hidden layer on its outline, a group on its box) and nothing else can; both are view settings like the other outline flags (per file, off in a new one, checked in the menu); the lines are light on a dark page (`outline_options_tests`). The main menu now shows a check mark beside every View toggle that is on | @@ -217,6 +216,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged | Date | Change | Summary | |---|---|---| | 2026-11-12 | minor | M8: Tidy up keeps the order and sizes and evens the gaps, as measured in Figma's web app on 2026-11-12. A row or column evens one gap (the most common one, the smaller of a tie, else the mean rounded half up) and keeps the other axis, layers of a row may overlap along X (also in a chain, and a negative gap is raised so no layer reaches the one two before it, measured in Figma's web app on 2026-11-13), and a grid is packed along the axis with more groups; fewer than three layers, layers that all overlap, a diagonal and an irregular layout grey the row out and move nothing. Measured: those rules and the unavailable cases; FigCraft's choices: a 2 x 2 grid as columns, auto layout flow children, the 2,000 layer cap, rotated layers by their box, `spacing` with two layers, and no rounding with pixel snapping off. Tests: tidy_tests (a_row_keeps_its_order_sizes_and_y_and_evens_the_gaps, gaps_that_all_differ_take_their_mean_taken_down, a_mean_is_rounded_half_up, two_equally_common_gaps_give_the_smaller, an_overlapping_pair_in_a_row_is_tidied_and_its_negative_gap_counts, a_grid_is_tidied_by_columns, a_transposed_grid_is_tidied_by_rows, layers_on_a_diagonal_are_unavailable, an_irregular_selection_is_unavailable and the rest). | +| 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 | | 2026-11-08 | patch | Smart guides: the object snap threshold is 4.5 screen pixels, measured against Figma (a dragged edge snapped at a gap of 4.4 screen px and not at 4.6, at 100% and at 200% zoom, Snap to pixel grid on); FigCraft used 5. Equal spacing shares the move's threshold so it follows; ruler-guide and rotation-origin snapping keep 5, unmeasured (`transform_behaviour_tests`) |