From 67c232eab21be3ed5335aafd1ec4f046b5d5dfe7 Mon Sep 17 00:00:00 2001 From: Carbocode Date: Tue, 10 Nov 2026 12:50:16 +0100 Subject: [PATCH] M1: strengthen page transparency and sidebar review tests --- crates/ui-egui/src/canvas/background.rs | 170 ++++++++++++++++++------ crates/ui-egui/src/field_tests.rs | 63 ++++++--- docs/ui-parity.md | 5 +- 3 files changed, 181 insertions(+), 57 deletions(-) diff --git a/crates/ui-egui/src/canvas/background.rs b/crates/ui-egui/src/canvas/background.rs index 8fe948802..f7eadd640 100644 --- a/crates/ui-egui/src/canvas/background.rs +++ b/crates/ui-egui/src/canvas/background.rs @@ -2,6 +2,7 @@ use egui::{Color32, CornerRadius, Id, Painter, Rect, TextureHandle, pos2}; +/// Reuse the checkerboard texture, updating its pixels when the active theme changes. fn grid_texture(ctx: &egui::Context) -> TextureHandle { let id = Id::unique("canvas_transparency_grid"); let tokens = crate::theme::Tokens::get(ctx); @@ -24,6 +25,7 @@ fn grid_texture(ctx: &egui::Context) -> TextureHandle { texture } +/// Draw the transparency grid and apply page colour only outside the rendered image. pub(super) fn paint(painter: &Painter, rect: Rect, background: Color32, rendered: Option) { if background.a() < 255 { // Repeat four pixels instead of allocating one shape per square on a large canvas. @@ -51,41 +53,127 @@ pub(super) fn paint(painter: &Painter, rect: Rect, background: Color32, rendered #[cfg(test)] mod tests { use super::*; - use egui::vec2; + use egui::{Mesh, TextureId, vec2}; + /// Paint a small canvas and consume its texture uploads as the UI harness does. fn shapes(alpha: u8, rendered: Option) -> egui::FullOutput { let ctx = egui::Context::default(); let mut out = ctx.run_ui(Default::default(), |ui| { - paint( - ui.painter(), - Rect::from_min_size(egui::Pos2::ZERO, vec2(100.0, 100.0)), - Color32::from_rgba_unmultiplied(255, 0, 0, alpha), - rendered, - ); + paint(ui.painter(), canvas(), Color32::from_rgba_unmultiplied(255, 0, 0, alpha), rendered); }); out.textures_delta.clear(); out } + /// Fixed canvas bounds make clipping and uncovered-area checks independent of UI layout. + fn canvas() -> Rect { + Rect::from_min_size(egui::Pos2::ZERO, vec2(100.0, 100.0)) + } + + /// Check the actual checkerboard quad, including clipping and its screen-sized UV mapping. + fn assert_grid(out: &egui::FullOutput, tile: TextureId, rect: Rect) -> usize { + let meshes: Vec<_> = out + .shapes + .iter() + .enumerate() + .filter_map(|(index, shape)| match &shape.shape { + egui::Shape::Mesh(mesh) if mesh.texture_id == tile => Some((index, shape.clip_rect, mesh)), + _ => None, + }) + .collect(); + assert_eq!(meshes.len(), 1, "one checkerboard covers the canvas"); + let (index, clip, mesh) = meshes[0]; + assert_eq!(mesh.calc_bounds(), rect); + assert!(clip.contains_rect(rect), "the whole checkerboard is visible"); + assert_eq!(mesh.vertices.len(), 4); + for vertex in &mesh.vertices { + let expected = (vertex.pos - rect.min) / 16.0; + assert!((vertex.uv.to_vec2() - expected).length() < 0.001, "UVs keep the squares at a fixed screen size"); + } + index + } + + /// Assert strips have the correct colour and partition exactly the area outside the render. + fn assert_partition(alpha: u8, rendered: Option) { + let out = shapes(alpha, rendered); + let fills: Vec<_> = out + .shapes + .iter() + .enumerate() + .filter_map(|(index, s)| match &s.shape { + egui::Shape::Rect(r) => Some((index, r)), + _ => None, + }) + .collect(); + let covered = rendered.map(|r| r.intersect(canvas())).filter(|r| r.is_positive()); + let background = Color32::from_rgba_unmultiplied(255, 0, 0, alpha); + for (i, (_, strip)) in fills.iter().enumerate() { + assert_eq!(strip.fill.a(), alpha, "each fallback strip applies alpha exactly once"); + assert_eq!(strip.fill, background, "the fallback keeps the page colour too"); + assert!(strip.rect.is_positive() && canvas().contains_rect(strip.rect)); + if let Some(covered) = covered { + assert!(!strip.rect.intersect(covered).is_positive(), "no fallback beneath rendered pixels"); + } + for (_, other) in fills.iter().skip(i + 1) { + assert!(!strip.rect.intersect(other.rect).is_positive(), "fallback strips do not overlap"); + } + } + let area: f32 = fills.iter().map(|(_, strip)| strip.rect.area()).sum(); + assert_eq!(area, canvas().area() - covered.map_or(0.0, |r| r.area()), "no uncovered gaps"); + if covered == Some(canvas()) { + assert!(fills.is_empty(), "full coverage needs no fallback fills"); + } + let meshes: Vec<&Mesh> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Mesh(mesh) => Some(mesh.as_ref()), + _ => None, + }) + .collect(); + if alpha < 255 { + assert_eq!(meshes.len(), 1); + let index = assert_grid(&out, meshes[0].texture_id, canvas()); + assert!(fills.iter().all(|(fill_index, _)| *fill_index > index), "the grid is behind every fallback strip"); + } else { + assert!(meshes.is_empty(), "opaque pages need no checkerboard"); + } + } + + /// Exercise the real canvas with a render present, checking the grid covers it and draws first. #[test] fn the_canvas_draws_the_transparency_grid_for_a_transparent_page() { let mut ui = crate::layers_drag_tests::Ui::new(figcraft_engine::Session::new()); ui.app.run("page.setBackground", serde_json::json!({"color": "#FF000000"})); + let ds = ui.app.session.ds().unwrap(); + let shown = (ds.uid, ds.page); + // Seed an image so this test does not depend on when the asynchronous worker finishes. + ui.app.canvas.texture = + Some(ui.ctx.load_texture("test-page-render", egui::ColorImage::filled([16, 16], Color32::TRANSPARENT), egui::TextureOptions::NEAREST)); + ui.app.canvas.tex_view = Some(figcraft_render::View { origin: (0.0, 0.0), scale: 1.0, width: 16, height: 16 }); + ui.app.canvas.tex_of = Some(shown); let out = ui.frame_output(vec![]); let (tile, _) = ui.ctx.data(|d| d.get_temp::<(TextureHandle, [Color32; 2])>(Id::unique("canvas_transparency_grid"))).expect("the canvas checkerboard"); - assert!(out.shapes.iter().any(|s| matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id == tile.id()))); + let grid_index = assert_grid(&out, tile.id(), ui.app.canvas.rect); + let rendered = ui.app.canvas.texture.as_ref().unwrap().id(); + let render_index = + out.shapes.iter().position(|s| matches!(&s.shape, egui::Shape::Mesh(m) if m.texture_id == rendered)).expect("the rendered page"); + assert!(grid_index < render_index, "the checkerboard is behind the rendered page"); } + /// Theme changes must reach drawing, update the pixels used by its mesh and reuse the texture. #[test] fn the_grid_updates_its_colours_when_the_theme_changes() { let ctx = egui::Context::default(); let mut previous_id = None; for tokens in [crate::theme::Tokens::light(), crate::theme::Tokens::dark(), crate::theme::Tokens::light()] { crate::theme::apply(&ctx, tokens); - let mut tile = None; - let mut out = ctx.run_ui(Default::default(), |_ui| tile = Some(grid_texture(&ctx))); - let tile = tile.unwrap(); + let mut out = ctx.run_ui(Default::default(), |ui| paint(ui.painter(), canvas(), Color32::TRANSPARENT, None)); + let (tile, _) = + ctx.data(|d| d.get_temp::<(TextureHandle, [Color32; 2])>(Id::unique("canvas_transparency_grid"))).expect("drawn checkerboard"); + let grid_index = assert_grid(&out, tile.id(), canvas()); + assert!(out.shapes.iter().enumerate().all(|(index, s)| !matches!(&s.shape, egui::Shape::Rect(_)) || index > grid_index)); if let Some(previous_id) = previous_id { assert_eq!(tile.id(), previous_id, "changing theme updates the same texture"); } @@ -97,44 +185,46 @@ mod tests { } } + /// Interior, edge-straddling and fully covering renders must all apply the page alpha once. #[test] fn translucent_pages_show_the_grid_without_applying_page_alpha_twice() { - let rendered = Rect::from_min_max(pos2(20.0, 20.0), pos2(80.0, 80.0)); + let renders = [ + Rect::from_min_max(pos2(20.0, 20.0), pos2(80.0, 80.0)), + Rect::from_min_max(pos2(-20.0, 20.0), pos2(80.0, 80.0)), + Rect::from_min_max(pos2(20.0, -20.0), pos2(80.0, 80.0)), + Rect::from_min_max(pos2(20.0, 20.0), pos2(120.0, 80.0)), + Rect::from_min_max(pos2(20.0, 20.0), pos2(80.0, 120.0)), + Rect::from_min_max(pos2(-20.0, -20.0), pos2(80.0, 80.0)), + canvas(), + Rect::from_min_max(pos2(-20.0, -20.0), pos2(120.0, 120.0)), + ]; for alpha in [0, 128, 255] { - let out = shapes(alpha, Some(rendered)); - let fills: Vec<_> = out - .shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Rect(r) => Some(r.rect), - _ => None, - }) - .collect(); - assert_eq!(fills.len(), 4); - assert!(fills.iter().all(|r| !r.intersect(rendered).is_positive()), "the render's alpha has no page colour beneath it"); - for point in [pos2(10.0, 10.0), pos2(90.0, 90.0), pos2(10.0, 50.0), pos2(90.0, 50.0)] { - assert_eq!(fills.iter().filter(|r| r.contains(point)).count(), 1, "uncovered pixels get the page colour once"); + for rendered in renders { + assert_partition(alpha, Some(rendered)); } - let images = out.shapes.iter().filter(|s| matches!(&s.shape, egui::Shape::Mesh(_))).count(); - assert_eq!(images, usize::from(alpha < 255), "only transparent pages need the checkerboard"); } } + /// Missing, offscreen and merely edge-touching images leave exactly one fallback page fill. #[test] fn absent_or_offscreen_renders_fill_the_page_once() { - for rendered in [None, Some(Rect::from_min_size(pos2(200.0, 200.0), vec2(10.0, 10.0)))] { - let out = shapes(128, rendered); - let fills: Vec<_> = out - .shapes - .iter() - .filter_map(|s| match &s.shape { - egui::Shape::Rect(r) => Some(r), - _ => None, - }) - .collect(); - assert_eq!(fills.len(), 1); - assert_eq!(fills[0].rect, Rect::from_min_size(egui::Pos2::ZERO, vec2(100.0, 100.0))); - assert_eq!(fills[0].fill.a(), 128); + for rendered in + [None, Some(Rect::from_min_size(pos2(200.0, 200.0), vec2(10.0, 10.0))), Some(Rect::from_min_max(pos2(-20.0, 20.0), pos2(0.0, 80.0)))] + { + for alpha in [0, 128, 255] { + assert_partition(alpha, rendered); + let out = shapes(alpha, rendered); + let fills: Vec<_> = out + .shapes + .iter() + .filter_map(|s| match &s.shape { + egui::Shape::Rect(r) => Some(r), + _ => None, + }) + .collect(); + assert_eq!(fills.len(), 1); + assert_eq!(fills[0].rect, canvas()); + } } } } diff --git a/crates/ui-egui/src/field_tests.rs b/crates/ui-egui/src/field_tests.rs index 939258ae5..ed7ad801a 100644 --- a/crates/ui-egui/src/field_tests.rs +++ b/crates/ui-egui/src/field_tests.rs @@ -1300,37 +1300,70 @@ fn page_background_shows_fractional_opacity_like_fill_and_keeps_it_when_opening_ u.app.run("page.setBackground", json!({"color": "#FF000080"})); u.frame(vec![]); assert_eq!(field(&mut u, "Opacity").1, "50.2%"); + let page = u.app.session.page().unwrap(); + let background = |u: &Ui| match u.app.session.doc().unwrap().node(page).unwrap().kind { + figcraft_doc::NodeKind::Page { background, .. } => background, + _ => panic!("the page"), + }; + let original = background(&u); let steps = undo_steps(&u); let swatch = u.ctx.read_response(Id::unique("pagebg_swatch")).expect("shared page swatch").rect.center(); click(&mut u, swatch); assert!(matches!(u.app.ui.picker, Some(crate::colorpicker::PickerTarget::PageBackground))); assert_eq!(undo_steps(&u), steps, "opening the picker does not edit the colour"); + assert_eq!(background(&u), original, "opening preserves the stored alpha"); + assert_eq!(field(&mut u, "Opacity").1, "50.2%"); click(&mut u, swatch); assert!(u.app.ui.picker.is_none(), "the swatch toggles the picker closed"); + assert_eq!(background(&u), original, "closing preserves the stored alpha"); + assert_eq!(field(&mut u, "Opacity").1, "50.2%"); + assert_eq!(undo_steps(&u), steps, "neither toggle creates an edit"); } /// The shared page colour row uses the sidebar's width without hiding either field. #[test] fn page_background_colour_and_opacity_fit_a_resized_sidebar() { - let mut u = Ui::new(Session::new()); - let mut previous_widths: Option<(f32, f32)> = None; - for width in [240.0, 500.0] { - u.app.ui.right_width = width; - for _ in 0..3 { - u.frame(vec![]); + // Find the whole galley, rather than its already-clipped bounding box. + fn hex_text(shape: &egui::Shape) -> Option<&egui::epaint::TextShape> { + match shape { + egui::Shape::Vec(shapes) => shapes.iter().find_map(hex_text), + egui::Shape::Text(text) if text.galley.text() == "123ABC" => Some(text), + _ => None, } - let hex = u.ctx.read_response(Id::unique("pagebg").with("click")).unwrap().rect; - let alpha = u.ctx.read_response(Id::unique("pagebg_a").with("field")).unwrap().rect; - let panel = u.app.ui.right_panel_rect.unwrap(); + } + let mut widths = Vec::new(); + for width in [240.0, 500.0] { + let mut session = Session::new(); + session.prefs.panel_widths.1 = width; + session.execute("page.setBackground", &json!({"color": "#123ABC80"})).unwrap(); + let mut u = Ui::new(session); + let mut previous = None; + let mut settled = None; + // Stop at stable geometry, with a bound so a layout regression cannot hang the test. + for _ in 0..16 { + let out = u.frame_output(vec![]); + let hex = u.ctx.read_response(Id::unique("pagebg").with("click")).unwrap().rect; + let alpha = u.ctx.read_response(Id::unique("pagebg_a").with("field")).unwrap().rect; + let panel = u.app.ui.right_panel_rect.unwrap(); + if previous == Some((hex, alpha, panel)) { + settled = Some((out, hex, alpha, panel)); + break; + } + previous = Some((hex, alpha, panel)); + } + let (out, hex, alpha, panel) = settled.expect("the page row settles"); assert!(hex.left() >= u.app.canvas.rect.right(), "the colour stays in the sidebar"); assert!(hex.right() < alpha.left(), "the colour and opacity never overlap"); - assert!(alpha.right() <= u.screen.x, "the opacity stays visible"); - // The colour uses the extra room while opacity keeps its place at the right edge. - if let Some((previous_hex, previous_panel)) = previous_widths { - assert!((hex.width() - previous_hex - (panel.width() - previous_panel)).abs() < 1.0, "the shared control grows with the panel"); - } - previous_widths = Some((hex.width(), panel.width())); + // Half a point permits device-pixel rounding, not an arbitrary gap from the panel edge. + assert!((alpha.right() - (panel.right() - crate::widgets::PAD_RIGHT)).abs() <= 0.5, "opacity stays at the right edge"); + assert!((hex.center().y - alpha.center().y).abs() <= 0.5, "both fields stay on the same row"); + let (text, clip) = out.shapes.iter().find_map(|s| hex_text(&s.shape).map(|text| (text, s.clip_rect))).expect("the HEX text"); + assert!(!text.galley.elided, "the whole HEX value fits at {width} pt"); + assert!(hex.expand(0.5).contains_rect(text.visual_bounding_rect()), "the HEX text fits its field"); + assert!(clip.expand(0.5).contains_rect(text.visual_bounding_rect()), "the HEX text is not clipped"); + widths.push(hex.width()); } + assert!(widths[1] > widths[0], "a wider sidebar gives the colour field more room"); } /// The Page section's opacity field edits the page background's alpha (#1748; it was a label), diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b6a134811..90d86f77c 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-09 · **Change:** patch (transparent pages show a checkerboard and apply their opacity once; the Page background uses the shared Fill/Stroke/Selection colors field; remaining sidebar colour controls audited; 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:** patch (review tests assert page-alpha strips, edge/full render coverage, checkerboard geometry/order, drawn theme changes and sidebar/picker invariants; transparent pages show a checkerboard and apply their opacity once; the Page background uses the shared Fill/Stroke/Selection colors field; remaining sidebar colour controls audited; 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: @@ -31,7 +31,7 @@ interaction feel not yet compared. | Multiplayer avatars, Share button | missing | waits for multiplayer | | Colour names in hex fields | done | every hex field (fills, strokes, text ranges, effects, layout grids, colour variables, page and prototype backgrounds, the colour picker's Hex) takes a CSS colour name as well: `red` becomes `FF0000`; case, spaces and hyphens are ignored (`Light Blue`); names are opaque and keep the colour's opacity; hex wins where both could match. The picker's CSS field takes every CSS name too (`Color::from_hex_or_name`, `color::named`) | | Fields select their text on focus | done | Number and text values keep their vertical position when their editor gains focus (including typography spacing; `sidebar_field_text_stays_in_place_on_focus`). a click or Tab into a one-line field selects all of its text, so typing replaces the value: hex and colour fields, number fields, inline fields (a fill's hex, variable values, renames), names, search boxes and dialog fields; a drag from the press selects only what it covers; multi-line fields (descriptions, comments) keep the caret where clicked. The first click into an inline field (a fill's hex) now takes the keyboard; it took a second click. A rename (double-click, F2, File ▸ Rename, a page) selects the whole name before its field first draws, so a key typed in that same frame replaces the name; it was appended (`widgets::select_all_on_focus`, `select_all_tests`, `layers_rename_tests`) | -| Canvas page transparency | done | Transparent or translucent page backgrounds show a checkerboard using the theme’s panel and canvas colours, updated immediately when switching between light and dark. The page background alpha is applied once: a rendered image already contains the page colour, so only uncovered areas receive the fallback colour during zoom and pan. An opaque page hides the checkerboard. Theme changes update the same cached texture. The grid is a repeated four-pixel texture at a fixed screen size, separate from document exports. Tests: `canvas::background::tests`. | +| Canvas page transparency | done | Transparent or translucent page backgrounds show a checkerboard using the theme’s panel and canvas colours, updated immediately when switching between light and dark. The page background alpha is applied once: a rendered image already contains the page colour, so only uncovered areas receive the fallback colour during zoom and pan. An opaque page hides the checkerboard. Theme changes update the same cached texture. The grid is a repeated four-pixel texture at a fixed screen size, separate from document exports. Tests: `canvas::background::tests` assert strip colour/alpha, gap-free non-overlapping coverage for absent, offscreen, edge-straddling and fully covering renders, checkerboard rect/UV/order, and theme updates through drawing. | | Page background colour control | done | Uses `widgets::paint_field`, as Fill, Stroke and Selection colors do: one background, inline HEX, accessible swatch toggling the picker and fractional opacity. Typed colours preserve alpha unless 4/8 HEX digits explicitly supply it; opacity scrubs remain live and undo as one step. Tests: `field_tests::the_page_background_opacity_field_sets_the_page_colour_alpha`, `field_tests::page_background_shows_fractional_opacity_like_fill_and_keeps_it_when_opening_the_picker` and `field_tests::page_background_colour_and_opacity_fit_a_resized_sidebar` (240 and 500 pt). | | Colour picker models | done | Hex, RGB, HSL, HSB, LCH, OKLCH and CSS; LCH and OKLCH keep lightness and hue when a colour leaves sRGB (chroma comes down); the values typed stay while the colour is theirs (a hue typed on a grey, a chroma sRGB can't hold); CSS reads `lch()` and `oklch()` with any hue unit and `none` (`figcraft_color::{Lch, Oklch}`). Values are the stored channels read as sRGB | | Colour picker placement and dismissal | done | Opens 8 pt beside the sidebar at the opening control, lifted to fit the screen; outside presses (including the sidebar), Escape and X close it. Reopening resets a dragged position; eyedropper sampling keeps it open, and so does a drag of the open gradient's handles or stops on the canvas (#1852). | @@ -207,6 +207,7 @@ This audit records candidates for subsequent fixes; their behaviour is unchanged | Date | Change | Summary | |---|---|---| +| 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 | | 2026-11-09 | patch | M1: the Page background reuses the shared sidebar paint control; fractional opacity and picker toggling covered alongside alpha-aware HEX and scrub undo. Audited remaining custom sidebar colour controls. Numbers unchanged | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` |