diff --git a/crates/engine/src/a11y_keys_tests.rs b/crates/engine/src/a11y_keys_tests.rs index e4892bcae..6fc5f2396 100644 --- a/crates/engine/src/a11y_keys_tests.rs +++ b/crates/engine/src/a11y_keys_tests.rs @@ -103,3 +103,24 @@ fn a_settings_file_without_or_with_a_wrong_interface_size_reads_as_the_systems_s let p: crate::Prefs = serde_json::from_value(json!({"ui_scale": 99, "theme": "dark"})).unwrap(); assert_eq!((p.ui_scale, p.theme.as_str()), (crate::UI_SCALE_MAX, "dark")); } + +#[test] +fn the_invert_zoom_preference_toggles_and_is_saved() { + let mut s = Session::new(); + assert!(!s.prefs.invert_zoom, "off by default"); + assert_eq!(s.execute("pref.invertZoom", &json!({})).unwrap()["on"], true); + assert!(s.prefs.invert_zoom); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["invert_zoom"], true); + let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); + assert!(loaded.invert_zoom); + assert_eq!(s.execute("pref.invertZoom", &json!({"on": false})).unwrap()["on"], false); + assert!(!s.prefs.invert_zoom); + let saved = serde_json::to_value(&s.prefs).unwrap(); + assert_eq!(saved["invert_zoom"], false); + let loaded: crate::Prefs = serde_json::from_value(saved).unwrap(); + assert!(!loaded.invert_zoom, "off persists after being on"); + // Preferences saved before the setting existed read it as off. + let old: crate::Prefs = serde_json::from_value(json!({"theme": "dark"})).unwrap(); + assert!(!old.invert_zoom); +} diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 96be815d6..dc58926fe 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -434,6 +434,13 @@ pub fn specs() -> Vec { false, |s, p| pref(s, p, |x| &mut x.scroll_wheel_zoom) ), + spec!( + "pref.invertZoom", + "Invert zoom direction", + "{on?} reverses wheel zoom: Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom out where they would zoom in (a trackpad pinch and panning are unchanged) → {on}", + false, + |s, p| pref(s, p, |x| &mut x.invert_zoom) + ), spec!("pref.reduceMotion", "Reduce motion", "{on?}", false, |s, p| pref(s, p, |x| &mut x.reduce_motion)), spec!( "pref.uiScale", diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 48d68a901..4225963f5 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -36,6 +36,9 @@ pub struct Prefs { /// A mouse wheel zooms the canvas about the pointer, and Cmd/Ctrl+wheel scrolls it (off: the /// other way round). Trackpads are unchanged. pub scroll_wheel_zoom: bool, + /// Wheel zoom goes the other way: Cmd/Ctrl+wheel (and a plain mouse wheel with Scroll wheel + /// zoom on) zooms out where it would zoom in. A trackpad pinch is unchanged. + pub invert_zoom: bool, /// "system" by default; an explicit "light" or "dark" choice stays saved. pub theme: String, /// The widths of the main window's left and right panels (the interface keeps them within its @@ -136,6 +139,7 @@ impl Default for Prefs { number_keys_opacity: true, show_dimensions: true, scroll_wheel_zoom: false, + invert_zoom: false, theme: "system".into(), panel_widths: (240.0, 240.0), pages_height: None, diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 55d8cbe63..71e6ff010 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -886,6 +886,7 @@ name = "English" "Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}" "Remove these results from the file" = "Remove these results from the file" "Scroll wheel zoom" = "Scroll wheel zoom" +"Invert zoom direction" = "Invert zoom direction" "Shared by your team on {server}" = "Shared by your team on {server}" "Slot name" = "Slot name" "Mar" = "Mar" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index e5957172a..5a65c5524 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -36,6 +36,7 @@ status = "draft — needs native review" "Couldn't download the variables" = "No se pudieron descargar las variables" "Couldn't download {name}" = "No se pudo descargar {name}" "Create from color" = "Crear a partir del color" +"Invert zoom direction" = "Invertir la dirección del zoom" "New collection" = "Nueva colección" "Delete results" = "Eliminar resultados" "Pasted design layers" = "Capas de diseño pegadas" @@ -2216,6 +2217,7 @@ status = "draft — needs native review" "plugins.runLast" = "Ejecutar el último plugin" "pref.autoSave" = "Guardar archivos automáticamente" "pref.highlightHover" = "Resaltar capas al pasar el cursor" +"pref.invertZoom" = "Invertir la dirección del zoom" "pref.keepTool" = "Mantener la herramienta tras usarla" "pref.language" = "Idioma de la interfaz" "pref.nudge" = "Cantidad de desplazamiento" diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 5187fe866..b4cff10aa 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -81,10 +81,18 @@ fn wheel_zoom(app: &mut FigApp, ctx: &egui::Context, zoom: f32, shift: bool) -> if let Some(mouse) = latest { app.canvas.wheel_is_mouse = mouse; } + let invert = app.session.prefs.invert_zoom && !pinch; if !app.session.prefs.scroll_wheel_zoom || !app.canvas.wheel_is_mouse || pinch { - return (wheel, zoom); + return (wheel, if invert { invert_zoom_factor(zoom) } else { zoom }); } - swap_wheel(wheel, zoom, shift, speed) + let (scroll, zoom) = swap_wheel(wheel, zoom, shift, speed); + (scroll, if invert { invert_zoom_factor(zoom) } else { zoom }) +} + +/// The zoom factor for the opposite direction (Preferences ▸ Invert zoom direction): its +/// reciprocal. A factor that is zero, negative or not finite is left as it is. +pub(crate) fn invert_zoom_factor(zoom: f32) -> f32 { + if zoom.is_finite() && zoom > 0.0 { 1.0 / zoom } else { zoom } } /// Whether a scroll event came from a mouse wheel. Only macOS says which device scrolled; elsewhere diff --git a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs index 5b196a4d4..b5a36a7de 100644 --- a/crates/ui-egui/src/canvas/wheel_zoom_tests.rs +++ b/crates/ui-egui/src/canvas/wheel_zoom_tests.rs @@ -6,7 +6,7 @@ use egui::{Event, Modifiers, MouseWheelSource, MouseWheelUnit, Pos2, RawInput, R use figcraft_engine::Session; use serde_json::json; -use super::input::{is_mouse_wheel, swap_wheel}; +use super::input::{invert_zoom_factor, is_mouse_wheel, swap_wheel}; use crate::{FigApp, NoServices}; /// The view's zoom, cx and cy. @@ -25,8 +25,13 @@ fn wheel(unit: MouseWheelUnit, dy: f32, modifiers: Modifiers) -> Event { impl Canvas { fn new(scroll_wheel_zoom: bool) -> Self { + Self::with_invert(scroll_wheel_zoom, false) + } + + fn with_invert(scroll_wheel_zoom: bool, invert_zoom: bool) -> Self { let mut c = Self { ctx: egui::Context::default(), app: FigApp::new(Session::new(), Box::new(NoServices)), time: 0.0 }; c.app.run("pref.scrollWheelZoom", json!({"on": scroll_wheel_zoom})); + c.app.run("pref.invertZoom", json!({"on": invert_zoom})); for _ in 0..3 { c.frame(vec![], Modifiers::NONE); } @@ -126,3 +131,63 @@ fn the_swap_keeps_egui_rates() { assert_eq!(swap_wheel(vec2(40.0, 0.0), 1.0, true, speed), (vec2(40.0, 0.0), 1.0)); assert_eq!(swap_wheel(Vec2::ZERO, 1.0, false, speed), (Vec2::ZERO, 1.0)); } + +#[test] +fn invert_zoom_reverses_cmd_or_ctrl_wheel_by_the_same_factor() { + for mods in [Modifiers::COMMAND, Modifiers::CTRL] { + let (mut off, mut on) = (Canvas::with_invert(false, false), Canvas::with_invert(false, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, mods), mods); + assert!(b.0 > a.0 * 1.05, "{mods:?} off zooms in: {a:?} -> {b:?}"); + assert!(d.0 < c.0 / 1.05, "{mods:?} on zooms out: {c:?} -> {d:?}"); + assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "{mods:?} by the same factor"); + } +} + +#[test] +fn invert_zoom_reverses_a_plain_mouse_wheel_with_scroll_wheel_zoom_on() { + let (mut off, mut on) = (Canvas::with_invert(true, false), Canvas::with_invert(true, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, 3.0, Modifiers::NONE), Modifiers::NONE); + assert!(b.0 > a.0 * 1.05, "off zooms in: {a:?} -> {b:?}"); + assert!(d.0 < c.0 / 1.05, "on zooms out: {c:?} -> {d:?}"); + assert!(((b.0 / a.0) * (d.0 / c.0) - 1.0).abs() < 1e-3, "by the same factor"); +} + +/// Panning is never inverted: the scroll vector is the same with the preference on and off. +#[test] +fn invert_zoom_leaves_panning_unchanged() { + // (Scroll wheel zoom, modifiers): a plain wheel without it, Cmd/Ctrl+wheel with it, Shift+wheel. + for (swz, mods) in [(false, Modifiers::NONE), (true, Modifiers::COMMAND), (true, Modifiers::CTRL), (true, Modifiers::SHIFT)] { + let (mut off, mut on) = (Canvas::with_invert(swz, false), Canvas::with_invert(swz, true)); + let (a, b, _) = off.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods); + let (c, d, _) = on.gesture(wheel(MouseWheelUnit::Line, -3.0, mods), mods); + assert!((b.0 - a.0).abs() < 1e-9 && (d.0 - c.0).abs() < 1e-9, "{mods:?} no zoom"); + assert!(b.1 != a.1 || b.2 != a.2, "{mods:?} off pans: {a:?} -> {b:?}"); + assert!( + ((b.1 - a.1) - (d.1 - c.1)).abs() < 1e-9 && ((b.2 - a.2) - (d.2 - c.2)).abs() < 1e-9, + "{mods:?} same pan: {a:?} -> {b:?} vs {c:?} -> {d:?}" + ); + } +} + +#[test] +fn a_pinch_zooms_the_same_with_invert_zoom_on_and_off() { + for (swz, invert, factor) in + [(false, false, 1.5), (false, true, 1.5), (true, false, 1.5), (true, true, 1.5), (true, true, 0.5), (false, true, 0.5)] + { + let mut c = Canvas::with_invert(swz, invert); + let (a, b, _) = c.gesture(Event::Zoom(factor), Modifiers::NONE); + assert!((b.0 / a.0 - f64::from(factor)).abs() < 1e-3, "scroll wheel zoom {swz}, invert {invert}, x{factor}: {a:?} -> {b:?}"); + } +} + +#[test] +fn the_inverted_factor_is_the_reciprocal_and_guards_bad_values() { + assert!((invert_zoom_factor(2.0) - 0.5).abs() < 1e-6); + assert_eq!(invert_zoom_factor(1.0), 1.0); + for bad in [0.0, -1.0, f32::NAN, f32::INFINITY] { + let r = invert_zoom_factor(bad); + assert!(r == bad || (r.is_nan() && bad.is_nan()), "{bad} left alone"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 52170af44..e60928fd1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -246,6 +246,7 @@ pub fn show(app: &mut FigApp, ctx: &Context) { (tr("Show dimensions on objects"), prefs.show_dimensions, "pref.showDimensions"), (tr("Use number keys for opacity"), prefs.number_keys_opacity, "pref.numberKeysOpacity"), (tr("Scroll wheel zoom"), prefs.scroll_wheel_zoom, "pref.scrollWheelZoom"), + (tr("Invert zoom direction"), prefs.invert_zoom, "pref.invertZoom"), (tr("Reduce motion"), prefs.reduce_motion, "pref.reduceMotion"), ] { if crate::widgets::checkbox(ui, val, label) { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 05056eac5..07517375b 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 (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; 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 (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; 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: @@ -115,6 +115,7 @@ interaction feel not yet compared. | 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`) | | 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 | | @@ -212,6 +213,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged | Date | Change | Summary | |---|---|---| | 2026-11-09 | patch | M3, #2188: Selection colors shows for a selection holding a layer with children (a frame, group, component or instance) from one paint group, and for layers without children only from two paint groups (groups are counted by style, variable and paint value, as `collect` does, not by distinct colours); measured in Figma's web app on 2026-11-09 for the examples below only, and instance and component behaviour was not measured: a frame holding one red rectangle, a group of two red rectangles and a frame with a lone black-stroked rectangle show one row; one red rectangle, two red rectangles and a childless green frame show none; a red and a blue loose rectangle show two (two paint groups). A childless rectangle with a red fill and a blue stroke has two paint groups, so the engine shows the section (not measured). Tests: `selection_colors_tests`. Counts unchanged | +| 2026-11-09 | patch | Preferences ▸ Invert zoom direction (#2145): Cmd/Ctrl+wheel, and a plain mouse wheel with Scroll wheel zoom on, zoom the other way; pinch and panning unchanged. Numbers unchanged. | | 2026-11-07 | patch | Transform: Shift while rotating lands on absolute multiples of 15°, measured in Figma (a layer at 7° or -4.27° Shift-rotated by its corner ends at 15°); FigCraft rounded the turn since the drag began, so 7° landed on 7 + k*15. The gesture keeps the selection's shared rotation and rounds base + turn; a selection with different rotations keeps rounding the turn. Test: `shift_rotating_lands_on_multiples_of_15_not_steps_from_the_start` | | 2026-11-10 | patch | M1 review follow-up: strengthen canvas alpha, clipping/coverage, grid geometry/order and drawn-theme tests; use settled fresh sidebars at each width, check HEX visibility and opacity alignment, and assert picker toggling preserves page alpha. Production behavior unchanged. | | 2026-11-09 | patch | M1: canvas page transparency uses a checkerboard that follows light/dark themes and applies background alpha once, including partial or missing renders. The page control also fits both sidebar width limits. Tests: `canvas::background::tests`, `field_tests::page_background_colour_and_opacity_fit_a_resized_sidebar`. Numbers unchanged |