diff --git a/crates/doc/src/props.rs b/crates/doc/src/props.rs index 15d557d88..1c7f16929 100644 --- a/crates/doc/src/props.rs +++ b/crates/doc/src/props.rs @@ -1453,8 +1453,10 @@ pub struct PrintSettings { /// is printed this far out, to be trimmed off. pub bleed: f64, /// Crop marks outside the bleed, at the trim's corners. + #[serde(alias = "crop_marks")] pub crop_marks: bool, /// Bleed marks outside the bleed, at the bleed's corners. + #[serde(alias = "bleed_marks")] pub bleed_marks: bool, /// CMYK colour (converted from RGB) instead of RGB. pub cmyk: bool, @@ -1523,25 +1525,32 @@ pub struct ExportSetting { pub print: PrintSettings, /// Only the layer itself (on), or every layer that shows in its area (off). #[serde(skip_serializing_if = "is_true")] + #[serde(alias = "contents_only")] pub contents_only: bool, /// The layer's own box, cutting off what it paints outside it (shadows, outside strokes). #[serde(skip_serializing_if = "is_false")] + #[serde(alias = "include_bounding_box")] pub include_bounding_box: bool, /// SVG: text as outlines (on) or as `` elements (off). #[serde(skip_serializing_if = "is_true")] + #[serde(alias = "svg_outline_text")] pub svg_outline_text: bool, /// SVG: layer names as `id` attributes. #[serde(skip_serializing_if = "is_false")] + #[serde(alias = "svg_id_attribute")] pub svg_id_attribute: bool, /// SVG: strokes as `stroke` attributes where that's exact (on), else as outlines. #[serde(skip_serializing_if = "is_true")] + #[serde(alias = "svg_simplify_stroke")] pub svg_simplify_stroke: bool, /// Android Vector Drawable: what to do with what a drawable can't hold exactly. #[serde(skip_serializing_if = "VdCompat::is_default")] + #[serde(alias = "vd_compat")] pub vd_compat: VdCompat, /// PNG, JPG and WebP: the color profile the file is written in. SVG, PDF and XML are always /// sRGB, whatever this says. #[serde(skip_serializing_if = "ExportColorProfile::is_default")] + #[serde(alias = "color_profile")] pub color_profile: ExportColorProfile, /// JPG: the encoder's quality, 1 (smallest file) to 100 (see [`ExportSetting::quality`]). #[serde(skip_serializing_if = "is_default_quality")] diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index a3284cd52..a80e6d41b 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -58,6 +58,7 @@ pub enum ToolId { Text, TextOnPath, Comment, + Annotation, Eyedropper, ShapeBuilder, // Board tools. @@ -71,7 +72,7 @@ pub enum ToolId { } impl ToolId { - pub const ALL: [ToolId; 26] = [ + pub const ALL: [ToolId; 27] = [ ToolId::Move, ToolId::Scale, ToolId::Hand, @@ -89,6 +90,7 @@ impl ToolId { ToolId::Text, ToolId::TextOnPath, ToolId::Comment, + ToolId::Annotation, ToolId::Eyedropper, ToolId::ShapeBuilder, ToolId::Sticky, @@ -118,6 +120,7 @@ impl ToolId { ToolId::Text => "text", ToolId::TextOnPath => "textOnPath", ToolId::Comment => "comment", + ToolId::Annotation => "annotation", ToolId::Eyedropper => "eyedropper", ToolId::ShapeBuilder => "shapeBuilder", ToolId::Sticky => "sticky", @@ -129,6 +132,11 @@ impl ToolId { ToolId::Stamp => "stamp", } } + /// Whether Developer Mode keeps this tool: it only inspects, so the tools that edit leave it. + /// The one list behind the engine's pointer routing and command allowlist and the toolbar. + pub fn works_in_dev_mode(self) -> bool { + matches!(self, ToolId::Move | ToolId::Hand | ToolId::Comment | ToolId::Annotation) + } pub fn from_id(s: &str) -> Option { ToolId::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s)) } @@ -151,6 +159,7 @@ impl ToolId { ToolId::Text => "Text", ToolId::TextOnPath => "Text on a path", ToolId::Comment => "Comment", + ToolId::Annotation => "Annotation", ToolId::Eyedropper => "Pick color", ToolId::ShapeBuilder => "Shape builder", ToolId::Sticky => "Sticky note", diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs index 1e9c645e3..d7a40380e 100644 --- a/crates/engine/src/canvas/tools/pointer.rs +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -110,7 +110,7 @@ impl Session { /// The tool pointer events go to: in Dev Mode every tool but the hand and comments selects. fn acting_tool(&self) -> ToolId { match self.tool.tool { - t if self.tool.dev_mode && !matches!(t, ToolId::Hand | ToolId::Comment) => ToolId::Move, + t if self.tool.dev_mode && !t.works_in_dev_mode() => ToolId::Move, t => t, } } @@ -176,6 +176,7 @@ impl Session { self.request_ui("comment.new", json!({"x": ev.x, "y": ev.y})); Ok(()) } + ToolId::Annotation => self.annotation_down(ev), ToolId::Eyedropper => self.eyedrop(ev), t if t.on_boards() => self.board_down(ev), t if t.draws_shape() => self.draw_down(ev), @@ -206,6 +207,19 @@ impl Session { Ok(true) } + /// The Annotation tool's press: the layer under the pointer is selected and the UI is asked + /// for its note (the engine writes nothing; the note is typed in the Inspect tab). A press on + /// empty canvas does nothing. + fn annotation_down(&mut self, ev: &PointerEvent) -> Result<()> { + let ds = self.ds()?; + let (doc, page) = (ds.doc.clone(), ds.page); + // The same reach as the Move tool's pick (`ev.tol() * 2.0`); never the page itself. + let Some(id) = self.hit(&doc, page, ev.p(), ev.tol() * 2.0).filter(|i| doc.node(*i).is_some_and(|n| !n.is_page())) else { return Ok(()) }; + self.set_selection(vec![id]); + self.request_ui("annotation.new", json!({"id": id})); + Ok(()) + } + pub(super) fn hover(&mut self, ev: &PointerEvent) -> Result<()> { let tool = self.acting_tool(); let ds = self.ds()?; diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 576e42cb6..4236d3fad 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -291,6 +291,7 @@ pub static MENU: &[MenuItem] = &[ m!("Tools", "Pencil", "tool.pencil"), m!("Tools", "Text", "tool.text"), m!("Tools", "Comment", "tool.comment"), + m!("Tools", "Annotation", "tool.annotation"), m!("Tools", "Shape builder", "tool.shapeBuilder"), m!("Tools", "Actions", "app.actions"), ]; diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index ca83292ec..2916d4aac 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -26,8 +26,8 @@ pub(crate) use export::FORBIDDEN_FILE_NAME_CHARS; use export::add_member; pub(super) use export::export_setting; pub use export::{ - ExportJob, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, fig_bytes, - frames_pdf, jobs_for, penpot_bytes, safe_file_name, + ExportJob, backdrop_layers, copy_png, drawn_layers, export_area, export_bytes, export_bytes_with, export_jobs, export_scale, exportable_layers, + fig_bytes, frames_pdf, jobs_for, penpot_bytes, safe_file_name, }; use export::{export, export_frames_pdf, export_layers, pdf_bytes}; use load::font_report; @@ -409,7 +409,10 @@ pub fn export_file(doc: &Document, ids: &[NodeId], setting: &ExportSetting, scal Ok((pdf_bytes(doc, &groups, &setting.print, (!own_area).then_some(area))?, Vec::new())) } _ => { - let mut pix = figcraft_render::render_area(doc, &drawn, area, scale) + // Layers exported on their own still read the page beneath them for a background blur + // or glass (#2213); a "contents only" off export already draws the whole page. + let page = if setting.contents_only { backdrop_layers(doc, ids) } else { Vec::new() }; + let mut pix = figcraft_render::render_area_over(doc, &drawn, &page, area, scale) .ok_or_else(|| EngineError::Other("render failed (empty or too large)".into()))?; // The render is in the document's profile; the setting's profile converts the pixels // (out-of-gamut colors clip) and tags the file. diff --git a/crates/engine/src/cmd/file/export.rs b/crates/engine/src/cmd/file/export.rs index de426ba89..bbc52e1b5 100644 --- a/crates/engine/src/cmd/file/export.rs +++ b/crates/engine/src/cmd/file/export.rs @@ -55,6 +55,12 @@ pub fn drawn_layers(doc: &Document, ids: &[NodeId], setting: &ExportSetting) -> crate::cmd::edit::z_sorted(doc, &crate::cmd::edit::roots(doc, &own)) } +/// The top-level layers of the page of `ids`, bottom to top: what a background blur or glass in an +/// export of `ids` reads from (empty when `ids` are on no page). +pub fn backdrop_layers(doc: &Document, ids: &[NodeId]) -> Vec { + ids.first().and_then(|i| doc.page_of(*i)).and_then(|p| doc.node(p)).map(|p| p.children.clone()).unwrap_or_default() +} + /// The frame or group a slice sits in: None on the page or in a section. fn slice_container(doc: &Document, slice: NodeId) -> Option { let parent = doc.node(slice)?.parent?; diff --git a/crates/engine/src/cmd/view.rs b/crates/engine/src/cmd/view.rs index 46ca701ff..fcfd70d44 100644 --- a/crates/engine/src/cmd/view.rs +++ b/crates/engine/src/cmd/view.rs @@ -235,6 +235,26 @@ pub(crate) fn tool(s: &mut Session, t: ToolId) -> Result { Ok(json!({"tool": t.id()})) } +/// Shift+T and choosing the Annotation tool: with a layer selected (or named by `id`/`ids`) the UI +/// is asked for its note at once; in the tool, a press on a layer selects it and asks (the engine +/// never writes the note). A named target is checked first: one that isn't a layer in the document +/// is an error and the tool stays as it was. +fn annotation(s: &mut Session, p: &Value) -> Result { + let named = p.get("id").is_some_and(|v| !v.is_null()) || p.get("ids").is_some_and(|v| !v.is_null()); + let first = crate::params::targets(s, p).into_iter().find(|i| s.doc().is_ok_and(|d| d.node(*i).is_some_and(|n| !n.is_page()))); + if named && first.is_none() { + return Err(bad("tool.annotation", "`id` must name a layer in this document (not a page)".to_string())); + } + let r = tool(s, ToolId::Annotation)?; + if let Some(id) = first { + if named { + crate::cmd::edit::select_and_page(s, vec![id])?; + } + s.request_ui("annotation.new", json!({"id": id})); + } + Ok(r) +} + /// The View menu's commands and their parameters. pub fn specs() -> Vec { vec![ @@ -528,7 +548,7 @@ pub fn specs() -> Vec { spec!( "tool.set", "Choose tool", - "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}", + "{tool: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|annotation|shapeBuilder | on boards: sticky|shape|connector|marker|highlighter|eraser|stamp}", false, |s, p| { let t = req_string("tool.set", p, "tool")?; @@ -536,6 +556,9 @@ pub fn specs() -> Vec { if t.on_boards() { crate::board::require(s, &format!("tool.{}", t.id()))?; } + if t == ToolId::Annotation { + return annotation(s, &json!({})); + } tool(s, t) } ), @@ -638,6 +661,9 @@ pub fn specs() -> Vec { tool(s, ToolId::TextOnPath) }), spec!("tool.comment", "Comment", "{}", false, |s, _| tool(s, ToolId::Comment)), + // Shift+T: with a layer selected (or an `id`) the UI is asked for its note at once; in the + // tool, a press on a layer selects it and asks (the engine never writes the note). + spec!("tool.annotation", "Annotation", "{id?|ids?}", false, annotation), spec!("tool.shapeBuilder", "Shape builder", "{}", false, |s, _| tool(s, ToolId::ShapeBuilder)), // `apply: false` is for the UI colour picker: the eyedropper only samples (into // `ToolState::picked`) and the picker writes the colour to the paint it edits. diff --git a/crates/engine/src/cursor_tests.rs b/crates/engine/src/cursor_tests.rs index 86122c533..7c88e3f09 100644 --- a/crates/engine/src/cursor_tests.rs +++ b/crates/engine/src/cursor_tests.rs @@ -107,6 +107,7 @@ fn each_tool_has_its_cursor_by_modifier_and_what_is_under_the_pointer() { (ToolId::TextOnPath, NONE, ON_SQUARE_EDGE, Text, "the I-beam over a shape's path: a click puts text on it"), (ToolId::Pen, NONE, EMPTY, Pen(PenCursor::New), "the pen: a new point"), (ToolId::Comment, NONE, EMPTY, Comment, "the comment pin"), + (ToolId::Annotation, NONE, ON_SQUARE, Crosshair, "the annotation tool marks the layer it is pressed on"), (ToolId::Eyedropper, NONE, ON_SQUARE, Eyedropper, "the eyedropper"), ]; for (tool, m, at, want, why) in rows { diff --git a/crates/engine/src/dev/devinspect.rs b/crates/engine/src/dev/devinspect.rs index 3daf7d1b0..5ac74b9ee 100644 --- a/crates/engine/src/dev/devinspect.rs +++ b/crates/engine/src/dev/devinspect.rs @@ -230,7 +230,7 @@ pub fn dev_mode_allows(id: &str) -> bool { // Edits recorded as one grouped undo step, so their spec says no history of its own. const GROUPED: [&str; 1] = ["data.fill"]; match id.strip_prefix("tool.") { - Some(t) => matches!(t, "move" | "hand" | "comment"), + Some(t) => crate::tools::ToolId::from_id(t).is_some_and(crate::tools::ToolId::works_in_dev_mode), None if id == "file.revert" || GROUPED.contains(&id) => false, None => OWN.iter().any(|p| id.starts_with(p)) || crate::cmd::find_command(id).is_none_or(|c| !c.history), } diff --git a/crates/engine/src/devmode_behaviour_tests.rs b/crates/engine/src/devmode_behaviour_tests.rs index 47c33aa29..7414a297b 100644 --- a/crates/engine/src/devmode_behaviour_tests.rs +++ b/crates/engine/src/devmode_behaviour_tests.rs @@ -99,6 +99,102 @@ fn the_dev_mode_canvas_selects_and_pans_but_never_edits() { assert!(s.doc().unwrap().node(r).is_some_and(|n| n.transform.as_coeffs()[4] > 100.0), "moved"); } +/// The `annotation.new` requests queued so far, as the layer ids they name. +fn note_requests(s: &Session) -> Vec { + s.ui_requests.iter().filter(|r| r.kind == "annotation.new").filter_map(|r| crate::params::id_of(&r.data["id"])).collect() +} + +fn click(s: &mut Session, x: f64, y: f64) { + s.pointer(&ev(PointerKind::Down, x, y)).unwrap(); + s.pointer(&ev(PointerKind::Up, x, y)).unwrap(); +} + +#[test] +fn the_annotation_tool_picks_a_layer_and_asks_for_its_note() { + for dev_mode in [true, false] { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + let r = rect(&mut s, f, 20.0); + s.tool.dev_mode = dev_mode; + ex(&mut s, "select.set", json!({"ids": []})); + ex(&mut s, "tool.annotation", json!({})); + assert_eq!(s.tool.tool, ToolId::Annotation); + assert!(note_requests(&s).is_empty(), "nothing selected: no note is asked for yet"); + let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len()); + // A press on empty canvas does nothing. + click(&mut s, 1000.0, 1000.0); + assert!(note_requests(&s).is_empty(), "empty canvas asks for nothing (dev_mode {dev_mode})"); + // A press on the rectangle selects it and asks for its note. + click(&mut s, 40.0, 40.0); + assert_eq!(s.selection(), vec![r], "dev_mode {dev_mode}"); + assert_eq!(note_requests(&s), vec![r], "dev_mode {dev_mode}"); + assert_eq!(s.tool.tool, ToolId::Annotation, "the tool stays until V or Escape"); + // The tool writes nothing. + assert_eq!(*s.doc().unwrap(), doc_before); + assert_eq!(s.undo_labels(1000).len(), undo_before); + } + // The keyboard flow: with a layer selected, choosing the tool asks at once. + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + let r = rect(&mut s, f, 20.0); + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, "tool.annotation", json!({})); + assert_eq!(note_requests(&s), vec![r]); + // And with an `id`. + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + let r = rect(&mut s, f, 20.0); + ex(&mut s, "tool.annotation", json!({"id": r})); + assert_eq!(note_requests(&s), vec![r]); + assert_eq!(s.selection(), vec![r]); +} + +/// A named target that isn't a layer (a page, an unknown id) is an error, and the tool is left as it +/// was; several targets pick the first layer among them and select it. +#[test] +fn the_annotation_tool_checks_the_layer_it_is_named_for_before_changing_anything() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + let r = rect(&mut s, f, 20.0); + let page = s.page().unwrap(); + assert!(s.execute("tool.annotation", &json!({"id": page})).is_err(), "a page is not a layer"); + assert!(s.execute("tool.annotation", &json!({"id": "9999:9999"})).is_err(), "no such layer"); + assert!(s.execute("tool.annotation", &json!({"ids": []})).is_err(), "nothing named"); + assert_eq!(s.tool.tool, ToolId::Move, "the tool did not change"); + assert!(note_requests(&s).is_empty()); + // The page first, then the rectangle: the rectangle is the target, and selected. + ex(&mut s, "tool.annotation", json!({"ids": [page, r]})); + assert_eq!((s.tool.tool, s.selection(), note_requests(&s)), (ToolId::Annotation, vec![r], vec![r])); +} + +/// Choosing the tool with `tool.set` (the toolbar) asks for the selected layer's note like Shift+T. +#[test] +fn choosing_the_annotation_tool_from_the_toolbar_asks_for_the_selected_layers_note() { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + let r = rect(&mut s, f, 20.0); + ex(&mut s, "select.set", json!({"ids": [r]})); + ex(&mut s, "tool.set", json!({"tool": "annotation"})); + assert_eq!((s.tool.tool, note_requests(&s)), (ToolId::Annotation, vec![r])); +} + +/// A drag with the tool never moves the layer it starts on, in Developer Mode or out of it. +#[test] +fn dragging_with_the_annotation_tool_moves_nothing() { + for dev_mode in [true, false] { + let mut s = Session::new(); + let f = frame(&mut s, 0.0, "Screen"); + rect(&mut s, f, 20.0); + s.tool.dev_mode = dev_mode; + ex(&mut s, "select.set", json!({"ids": []})); + ex(&mut s, "tool.annotation", json!({})); + let (doc_before, undo_before) = (s.doc().unwrap().clone(), s.undo_labels(1000).len()); + drag(&mut s, (40.0, 40.0), (140.0, 90.0)); + assert_eq!(*s.doc().unwrap(), doc_before, "dev_mode {dev_mode}"); + assert_eq!(s.undo_labels(1000).len(), undo_before, "dev_mode {dev_mode}"); + } +} + #[test] fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts() { for id in [ @@ -121,6 +217,7 @@ fn dev_mode_runs_its_own_edits_and_refuses_design_edits_from_menus_and_shortcuts "tool.move", "tool.hand", "tool.comment", + "tool.annotation", "inspect.code", ] { assert!(dev_mode_allows(id), "{id} runs in Dev Mode"); diff --git a/crates/engine/src/export_behaviour_tests.rs b/crates/engine/src/export_behaviour_tests.rs index 492fbf881..913c9feaf 100644 --- a/crates/engine/src/export_behaviour_tests.rs +++ b/crates/engine/src/export_behaviour_tests.rs @@ -385,6 +385,167 @@ fn copy_as_png_svg_and_code() { assert!(css.contains("background: #FF0000;"), "Copy as CSS is the layer's CSS from Inspect"); } +/// A red square with a fill-less pane over its right part, holding `effect` (#2213). +fn pane_scene(effect: EffectKind) -> (figcraft_doc::Document, NodeId, NodeId) { + use figcraft_doc::{Document, NodeKind}; + let mut d = Document::new("blur-export"); + let page = d.first_page().unwrap(); + let under = d.create(page, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap(); + d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())]; + let pane = d.create(page, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap(); + d.get_mut(pane).unwrap().fills = vec![]; + d.get_mut(pane).unwrap().effects = vec![Effect { kind: effect, visible: true }]; + (d, under, pane) +} + +/// The PNG export of `ids` as the Export command makes it, and what the canvas draws there. +fn export_vs_canvas(d: &figcraft_doc::Document, ids: &[NodeId]) -> (image::RgbaImage, image::RgbaImage) { + let setting = figcraft_doc::ExportSetting::default(); + let page = d.first_page().unwrap(); + let area = crate::cmd::file::export_area(d, ids, &setting).unwrap(); + let canvas = figcraft_render::render_area(d, &d.get(page).unwrap().children.clone(), area, 1.0).unwrap(); + let canvas = image::RgbaImage::from_raw(canvas.width(), canvas.height(), figcraft_render::to_rgba8(&canvas)).unwrap(); + let (bytes, _) = crate::cmd::file::export_file(d, ids, &setting, 1.0).unwrap(); + (pixels(&bytes), canvas) +} + +/// #2213: a layer with a background blur exported on its own shows the same pixels as the canvas +/// shows for that area: the blur reads what lies under it, so the export must draw the layers +/// beneath it as the backdrop (clipped to the layer) rather than blurring nothing. +#[test] +fn exporting_a_background_blur_layer_keeps_the_blurred_backdrop() { + let (d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0))); + let (exported, canvas) = export_vs_canvas(&d, &[pane]); + // The pane's left half sits over the red square, which ends at x = 120 (60 px into the pane). + assert_ne!(canvas.get_pixel(60, 40).0, [0, 0, 0, 0], "the canvas draws the blurred backdrop"); + assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "export lost the backdrop the blur reads"); + assert_eq!(exported.dimensions(), (120, 80), "the pane's own bounds"); +} + +/// #2213, as reported and as measured in Figma (2026-11-10): the pane inside a frame, exported on +/// its own, keeps the frame's fill and the blurred layer beneath it, opaque. +#[test] +fn a_blurred_pane_inside_a_frame_keeps_the_frame_and_the_layer_beneath() { + use figcraft_doc::{Document, NodeKind}; + let mut d = Document::new("blur-export-frame"); + let page = d.first_page().unwrap(); + let art = d.create(page, NodeKind::Frame, "Art", 0.0, 0.0, 300.0, 200.0).unwrap(); + d.get_mut(art).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("FFFFFF").unwrap())]; + let under = d.create(art, NodeKind::Rectangle, "Under", 0.0, 0.0, 120.0, 120.0).unwrap(); + d.get_mut(under).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("E03030").unwrap())]; + let pane = d.create(art, NodeKind::Rectangle, "Glass", 60.0, 20.0, 120.0, 80.0).unwrap(); + d.get_mut(pane).unwrap().fills = vec![]; + d.get_mut(pane).unwrap().effects = vec![Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0)), visible: true }]; + let (exported, canvas) = export_vs_canvas(&d, &[pane]); + assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40), "the blurred square under the pane"); + assert_eq!(exported.get_pixel(100, 40).0[3], 255, "the frame's white behind the pane, opaque"); +} + +/// #2213: the same for Glass. +#[test] +fn exporting_a_glass_layer_keeps_the_backdrop_it_bends() { + let (d, _, pane) = pane_scene(EffectKind::Glass(figcraft_doc::Glass::default())); + let (exported, canvas) = export_vs_canvas(&d, &[pane]); + assert_ne!(canvas.get_pixel(30, 40).0, [0, 0, 0, 0]); + assert_eq!(exported.get_pixel(30, 40), canvas.get_pixel(30, 40), "export lost the backdrop the glass reads"); + assert_eq!(exported.get_pixel(60, 40), canvas.get_pixel(60, 40)); +} + +/// Measured in Figma 2026-11-10: inside the pane's bounds the export is opaque where the backdrop +/// is (the blurred red), and the layers over the pane stay out. +#[test] +fn a_blur_export_is_opaque_inside_the_pane_and_leaves_layers_above_out() { + let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(12.0))); + let page = d.first_page().unwrap(); + let above = d.create(page, figcraft_doc::NodeKind::Rectangle, "Above", 0.0, 0.0, 200.0, 200.0).unwrap(); + d.get_mut(above).unwrap().fills = vec![figcraft_color::Paint::solid(figcraft_color::Color::from_hex("0000FF").unwrap())]; + let (exported, _) = export_vs_canvas(&d, &[pane]); + let left = exported.get_pixel(10, 40).0; + assert_eq!(left[3], 255, "red under the pane: opaque"); + assert!(left[0] > 150 && left[2] < 60, "red, not the blue layer above: {left:?}"); + // The far right of the pane has no square under it: nothing to read, nothing drawn. + assert_eq!(exported.get_pixel(110, 40).0[3], 0); +} + +/// Guard: a layer without backdrop effects over another still exports alone ("contents only"). +#[test] +fn a_layer_without_backdrop_effects_still_leaves_the_layers_below_out() { + let (mut d, _, pane) = pane_scene(EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(0.0))); + d.get_mut(pane).unwrap().effects.clear(); + let (exported, _) = export_vs_canvas(&d, &[pane]); + assert!(exported.pixels().all(|p| p.0 == [0, 0, 0, 0]), "a fill-less pane alone exports clear"); +} + +#[test] +fn node_set_takes_back_the_export_settings_node_get_returns() { + use figcraft_doc::{ExportColorProfile, ExportFormat, ExportSetting, PrintSettings, VdCompat}; + + // Every multi-word field (and `quality`) away from its default. + let expected = ExportSetting { + format: ExportFormat::Jpg, + suffix: "@3x".into(), + print: PrintSettings { bleed: 9.0, crop_marks: true, bleed_marks: true, cmyk: true }, + contents_only: false, + include_bounding_box: true, + svg_outline_text: false, + svg_id_attribute: true, + svg_simplify_stroke: false, + vd_compat: VdCompat::Exact, + color_profile: ExportColorProfile::DisplayP3, + quality: 55, + ..Default::default() + }; + let default = ExportSetting::default(); + for (field, differs) in [ + ("print", expected.print != default.print), + ("contents_only", expected.contents_only != default.contents_only), + ("include_bounding_box", expected.include_bounding_box != default.include_bounding_box), + ("svg_outline_text", expected.svg_outline_text != default.svg_outline_text), + ("svg_id_attribute", expected.svg_id_attribute != default.svg_id_attribute), + ("svg_simplify_stroke", expected.svg_simplify_stroke != default.svg_simplify_stroke), + ("vd_compat", expected.vd_compat != default.vd_compat), + ("color_profile", expected.color_profile != default.color_profile), + ("quality", expected.quality != default.quality), + ] { + assert!(differs, "{field} must be non-default in the expected setting"); + } + + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0, 40.0, 20.0, "#FF0000", None); + let stored = |s: &Session| s.doc().unwrap().node(a).unwrap().exports.clone(); + ex( + &mut s, + "export.add", + json!({"ids": [a], "setting": { + "format": "jpg", "suffix": "@3x", "bleed": 9, "cropMarks": true, "bleedMarks": true, "cmyk": true, + "contentsOnly": false, "includeBoundingBox": true, "svgOutlineText": false, "svgIdAttribute": true, + "svgSimplifyStroke": false, "vdCompat": "exact", "colorProfile": "displayP3", "quality": 55, + }}), + ); + assert_eq!(stored(&s), vec![expected.clone()], "export.add stores every field"); + + // What node.get returns goes back through node.set unchanged. + let got = ex(&mut s, "node.get", json!({"id": a}))["exports"].clone(); + set(&mut s, &[a], json!({"exports": []})); + assert!(stored(&s).is_empty()); + set(&mut s, &[a], json!({"exports": got})); + assert_eq!(stored(&s), vec![expected.clone()], "node.set stores what node.get returned, unchanged"); + + // The snake_case spelling is taken too. + set(&mut s, &[a], json!({"exports": []})); + set( + &mut s, + &[a], + json!({"exports": [{ + "format": "jpg", "suffix": "@3x", + "print": {"bleed": 9, "crop_marks": true, "bleed_marks": true, "cmyk": true}, + "contents_only": false, "include_bounding_box": true, "svg_outline_text": false, "svg_id_attribute": true, + "svg_simplify_stroke": false, "vd_compat": "exact", "color_profile": "displayP3", "quality": 55, + }]}), + ); + assert_eq!(stored(&s), vec![expected], "node.set takes the snake_case keys"); +} + #[test] fn export_rejects_an_invalid_layer_id_instead_of_exporting_the_page() { let mut s = Session::new(); diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index e45ced8d4..11e7e316a 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -192,6 +192,7 @@ pub static KEYS: &[Binding] = &[ k(Tools, "tool.pencil", &["Shift+P"]), k(Tools, "tool.text", &["T"]), k(Tools, "tool.comment", &["C"]), + k(Tools, "tool.annotation", &["Shift+T"]), k(Tools, "tool.shapeBuilder", &["Shift+M"]), // Board tools (on boards only), on keys nothing else uses. k(Tools, "tool.sticky", &["Q"]), diff --git a/crates/engine/src/keymap_tests.rs b/crates/engine/src/keymap_tests.rs index 67c5bf258..59206c5ff 100644 --- a/crates/engine/src/keymap_tests.rs +++ b/crates/engine/src/keymap_tests.rs @@ -206,6 +206,8 @@ fn tool_keys_come_from_the_keymap() { assert_eq!(ToolId::Eyedropper.shortcut(), "I"); assert_eq!(ToolId::Polygon.shortcut(), ""); assert_eq!(crate::catalog::shortcut("tool.text"), Some("T")); + assert_eq!(ToolId::Annotation.shortcut(), "Shift+T"); + assert_eq!(crate::catalog::shortcut("tool.annotation"), Some("Shift+T")); } #[test] diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index ae73b022d..e15a164b8 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -110,6 +110,7 @@ static ROWS: &[Row] = &[ doc(Tools, "Pencil tool", &["Shift+P"], &[], Bound("tool.pencil"), ""), doc(Tools, "Text tool", &["T"], &[], Bound("tool.text"), ""), doc(Tools, "Comment mode", &["C"], &[], Bound("tool.comment"), ""), + doc(Tools, "Annotation tool", &["Shift+T"], &[], Bound("tool.annotation"), "Accessibility at Figma, Add an annotation; Developer Mode"), doc(Tools, "Hand tool while held", &["Space"], &[], Gesture("Pan the canvas"), "held, not pressed"), doc(Tools, "Pan with the arrows when nothing is selected (Shift: farther)", &["ArrowRight"], &[], Gesture("Pan with nothing selected (Shift: farther)"), "view.pan; nudges move the selection when there is one"), @@ -416,7 +417,7 @@ fn counts_match_the_docs() { let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count(); let pinned = ROWS.len() - documented.len(); // docs/ui-parity.md quotes these. - assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (136, 135, 1, 0, 2, 57)); + assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (137, 136, 1, 0, 2, 57)); } #[test] diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index e4a306a1d..9a546dc2a 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -336,7 +336,7 @@ fn tools() -> Vec { "y": num("Page y"), "to": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2, "description": "Drag end [x, y] (page)"}, "steps": int("Intermediate move events for drag (default 8)"), - "tool": {"type": "string", "description": "Tool to choose first: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment"}, + "tool": {"type": "string", "description": "Tool to choose first: move|scale|hand|frame|section|slice|rectangle|line|arrow|ellipse|polygon|star|pen|pencil|text|comment|annotation"}, "shift": boolean("Shift held"), "alt": boolean("Alt/Option held"), "cmd": boolean("Cmd (macOS) / Ctrl held"), diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index f138521ae..5bfaaed5c 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -266,11 +266,11 @@ fn render_one(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Opti /// Render several nodes (in z-order) into an existing pixmap through `view`. pub fn render_nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap) { - nodes_into(doc, ids, view, pix, 0.0); + nodes_into(doc, ids, view, pix, 0.0, None); } /// [`render_nodes_into`] `time` seconds into animated GIFs. -fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64) { +fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, time: f64, under: Option<&backdrop::Under>) { let bounds = draw::Bounds::default(); let ctx = draw::Ctx { doc, @@ -282,7 +282,7 @@ fn nodes_into(doc: &Document, ids: &[NodeId], view: &View, pix: &mut Pixmap, tim lod: false, preview: false, magnified_image_pixels: false, - under: None, + under, time, bounds: &bounds, }; @@ -353,16 +353,24 @@ pub fn render_node(doc: &Document, id: NodeId, scale: f64) -> Option { /// [`render_node`] `time` seconds into animated GIFs (the presenter plays them). pub fn render_node_at(doc: &Document, id: NodeId, scale: f64, time: f64) -> Option { - area_at(doc, &[id], export_bounds(doc, id)?, scale, time) + area_at(doc, &[id], None, export_bounds(doc, id)?, scale, time) } /// Rasterise `ids` (bottom to top) over page area `b` at `scale` (export): the image starts at the /// area's top-left and its sides round up to whole pixels. `None` past 16384 pixels a side. pub fn render_area(doc: &Document, ids: &[NodeId], b: Rect, scale: f64) -> Option { - area_at(doc, ids, b, scale, 0.0) + area_at(doc, ids, None, b, scale, 0.0) } -fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Option { +/// [`render_area`] for an export of layers that sit among `page_layers` (the page's top-level +/// layers, in z-order): a background blur or glass in `ids` reads what lies under the first of +/// them on that page, clipped to the area (#2213). Only `ids` are drawn; the layers above stay +/// out. Without such an effect it is [`render_area`]. +pub fn render_area_over(doc: &Document, ids: &[NodeId], page_layers: &[NodeId], b: Rect, scale: f64) -> Option { + area_at(doc, ids, Some(page_layers), b, scale, 0.0) +} + +fn area_at(doc: &Document, ids: &[NodeId], page_layers: Option<&[NodeId]>, b: Rect, scale: f64, time: f64) -> Option { let scale = if scale.is_finite() && scale > 0.0 { scale.min(64.0) } else { 1.0 }; let w = (b.width() * scale - 1e-6).ceil().max(1.0); let h = (b.height() * scale - 1e-6).ceil().max(1.0); @@ -371,7 +379,31 @@ fn area_at(doc: &Document, ids: &[NodeId], b: Rect, scale: f64, time: f64) -> Op } let view = View { origin: (b.x0, b.y0), scale, width: w as u32, height: h as u32 }; let mut pix = new_pixmap(view.width, view.height)?; - nodes_into(doc, ids, &view, &mut pix, time); + // What the backdrop effects read: the page under the first layer, with the margin they reach. + let reach = page_layers.and_then(|_| { + ids.iter() + .flat_map(|i| doc.subtree(*i)) + .filter_map(|i| doc.node(i)) + .filter(|n| n.visible && backdrop::has_effects(n)) + .map(|n| backdrop::pad(n, scale)) + .max() + }); + let beneath = match (page_layers, ids.first(), reach) { + (Some(layers), Some(first), Some(m)) => { + let m = i32::try_from(m).unwrap_or(0); + let p = f64::from(m) / scale; + let around = View { + origin: (b.x0 - p, b.y0 - p), + scale, + width: view.width.saturating_add(m.unsigned_abs() * 2), + height: view.height.saturating_add(m.unsigned_abs() * 2), + }; + backdrop::render_beneath(doc, layers, *first, &around).map(|back| (back, m)) + } + _ => None, + }; + let under = beneath.as_ref().map(|(back, m)| backdrop::Under::new(back, (*m, *m), None)); + nodes_into(doc, ids, &view, &mut pix, time, under.as_ref()); Some(pix) } diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3d3fb7fc4..040f4ba27 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -122,6 +122,7 @@ name = "English" "Android Vector Drawable" = "Android Vector Drawable" "Angular" = "Angular" "Animation" = "Animation" +"Annotation" = "Annotation" "Annotations" = "Annotations" "Annotations shown on the canvas" = "Annotations shown on the canvas" "Another server, or one that needs its key. The usual server is set in Preferences." = "Another server, or one that needs its key. The usual server is set in Preferences." diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 16ec3350c..e5957172a 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -22,6 +22,7 @@ status = "draft — needs native review" "Along the top" = "A lo largo del borde superior" "An earlier vote" = "Una votación anterior" "Android Vector Drawable" = "Android Vector Drawable" +"Annotation" = "Anotación" "Best effort" = "Mejor aproximación" "Between the selected layers" = "Entre las capas seleccionadas" "Brainstorm" = "Lluvia de ideas" @@ -2366,6 +2367,7 @@ status = "draft — needs native review" "text.underline" = "Subrayado" "text.weightDown" = "Reducir peso de fuente" "text.weightUp" = "Aumentar peso de fuente" +"tool.annotation" = "Anotación" "tool.arrow" = "Flecha" "tool.comment" = "Comentario" "tool.connector" = "Conector" diff --git a/crates/ui-egui/src/app/commands.rs b/crates/ui-egui/src/app/commands.rs index abca326e5..e36471b42 100644 --- a/crates/ui-egui/src/app/commands.rs +++ b/crates/ui-egui/src/app/commands.rs @@ -266,6 +266,18 @@ impl FigApp { self.ui.comments.show(id); } } + "annotation.new" => { + // The Annotation tool: show the Inspect tab (never toggle it away) and put the + // caret in the layer's new-note field. + // Only a selected layer's field can show; a request for anything else would wait + // and take the keyboard later. + let shown = + |id| self.session.selection().contains(&id) && self.session.doc().is_ok_and(|d| d.node(id).is_some_and(|n| !n.is_page())); + if let Some(id) = r.data.get("id").and_then(figcraft_engine::params::id_of).filter(|id| shown(*id)) { + self.ui.right_tab = RightTab::Inspect; + self.ui.dev.focus_new_note = Some(id); + } + } "comment.new" => { let x = r.data.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0); let y = r.data.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0); diff --git a/crates/ui-egui/src/dev_mode_tests.rs b/crates/ui-egui/src/dev_mode_tests.rs index 4d497585e..b7a9f129d 100644 --- a/crates/ui-egui/src/dev_mode_tests.rs +++ b/crates/ui-egui/src/dev_mode_tests.rs @@ -196,3 +196,47 @@ fn the_playground_swaps_an_instance_in_the_preview_only() { let props = u.app.session.execute("component.properties", &json!({"id": inst})).unwrap(); assert_eq!(props["properties"][0]["value"], json!(heart.0), "{star:?}"); } + +#[test] +fn the_annotation_tool_opens_the_inspect_tab_with_the_note_field_focused() { + let (mut u, _, r) = setup(); + u.app.ui.right_tab = crate::RightTab::Design; + u.frames(2, Vec::new); + u.run("tool.annotation", json!({"id": r})); + u.frames(2, Vec::new); + assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect, "the request shows the Inspect tab"); + let field = egui::Id::unique(("annot_new", r)).with("te"); + assert!(u.ctx.memory(|m| m.has_focus(field)), "the layer's new-note field has the caret"); + assert_eq!(u.app.ui.dev.focus_new_note, None, "the focus request is spent"); + // Asking again while already on the Inspect tab does not toggle it away. + u.run("tool.annotation", json!({"id": r})); + u.frames(2, Vec::new); + assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect); + assert!(u.ctx.memory(|m| m.has_focus(field)), "the field has the caret again"); + assert_eq!(u.app.ui.dev.focus_new_note, None, "and the request is spent"); +} + +/// A request for a layer that isn't selected (or is gone) does not wait to take the keyboard later. +#[test] +fn a_note_request_for_a_layer_that_is_not_selected_is_dropped() { + let (mut u, _, r) = setup(); + u.app.session.execute("select.set", &json!({"ids": []})).unwrap(); + u.frames(2, Vec::new); + u.app.session.request_ui("annotation.new", json!({"id": r})); + u.frames(2, Vec::new); + assert_eq!(u.app.ui.dev.focus_new_note, None); +} + +/// Choosing the Annotation tool from the toolbar on the Inspect tab keeps the tab (an editing tool +/// would leave it for Design) and asks for the selected layer's note. +#[test] +fn the_toolbar_keeps_the_inspect_tab_for_the_annotation_tool() { + let (mut u, _, r) = setup(); + u.app.session.execute("select.set", &json!({"ids": [r]})).unwrap(); + crate::toolbar::choose(&mut u.app, figcraft_engine::ToolId::Annotation); + u.frames(2, Vec::new); + assert_eq!(u.app.ui.right_tab, crate::RightTab::Inspect); + assert!(u.ctx.memory(|m| m.has_focus(egui::Id::unique(("annot_new", r)).with("te"))), "the note field has the caret"); + crate::toolbar::choose(&mut u.app, figcraft_engine::ToolId::Rectangle); + assert_eq!(u.app.ui.right_tab, crate::RightTab::Design, "a drawing tool leaves it"); +} diff --git a/crates/ui-egui/src/panels/dev_mode.rs b/crates/ui-egui/src/panels/dev_mode.rs index bcb97edbb..09c3b9732 100644 --- a/crates/ui-egui/src/panels/dev_mode.rs +++ b/crates/ui-egui/src/panels/dev_mode.rs @@ -92,6 +92,9 @@ impl CategoryFilter { /// revision), the page or the choice change. #[derive(Default)] pub struct DevUi { + /// The layer whose new-note field takes the keyboard focus once its section draws (the + /// Annotation tool asked for that layer's note). + pub focus_new_note: Option, pub filter: usize, pub categories: CategoryFilter, pub compare: Option, @@ -618,6 +621,11 @@ pub fn annotations_section(app: &mut FigApp, ui: &mut Ui, doc: &Document, id: No } ui.add_space(6.0); } + // The Annotation tool asked for this layer's note: put the caret in the new-note field, once. + if app.ui.dev.focus_new_note == Some(id) { + app.ui.dev.focus_new_note = None; + ui.memory_mut(|m| m.request_focus(Id::unique(("annot_new", id)).with("te"))); + } // A new annotation starts as a note; properties are pinned to it afterwards. if n.annotations.len() < annotations::MAX_PER_LAYER && let Some(label) = widgets::multiline_field(ui, Id::unique(("annot_new", id)), "", tr("Add an annotation for developers"), 1) diff --git a/crates/ui-egui/src/toolbar.rs b/crates/ui-egui/src/toolbar.rs index 7aff24993..d57f2019b 100644 --- a/crates/ui-egui/src/toolbar.rs +++ b/crates/ui-egui/src/toolbar.rs @@ -626,8 +626,8 @@ fn flyout(app: &mut FigApp, ui: &mut Ui, g: &Group, bottom_left: egui::Pos2) { /// Choose `tool` from the toolbar. Dev Mode only inspects: a tool that edits leaves it for the /// design. -fn choose(app: &mut FigApp, tool: ToolId) { - if app.ui.right_tab == crate::RightTab::Inspect && !matches!(tool, ToolId::Move | ToolId::Hand | ToolId::Comment) { +pub(crate) fn choose(app: &mut FigApp, tool: ToolId) { + if app.ui.right_tab == crate::RightTab::Inspect && !tool.works_in_dev_mode() { app.ui.right_tab = crate::RightTab::Design; } app.run("tool.set", json!({"tool": tool.id()})); diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index 87dc3739f..66ef3a0ae 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -1,6 +1,6 @@ # Dev Mode parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-09 · **Change:** patch (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 Inspect tab then opens (never toggles away) with the caret in that layer's new-note field, and the engine never writes the note itself (#1840); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; class names transliterate non-ASCII layer names (#1235); measurements kept on the canvas; a read-only layers panel in Dev Mode; earlier: import lines in generated layer trees; compare with any saved version; Dev Mode behaviour suite: an inspect-only canvas, Changed in the ready list, statuses only on frames and sections; plugin panels from the Inspect tab) · **Target:** Figma Design Dev Mode (desktop app 126.10.7) **Breadth ~98%, ready ~78% (estimated).** The inspect surface is broad, components connect to the code components of a repository (with their imports, also inside generated layer trees), @@ -15,7 +15,7 @@ repository, third-party integrations. Remaining: 1–2 h. | Properties with their styles and variables; redlines on hover | done | positions from the frame around the layer (groups aren't frames), rotation, radius (CSS corner order), opacity, blend; colours in hex, RGB, CSS, HSL or HSB (`inspect.properties {colorFormat}`, the Inspect tab's Color format) | | Code: CSS, iOS (SwiftUI), Android (Compose), Flutter | done | plus HTML, React, Vue and Leptos (ours; Leptos: `#[component]` functions with `#[prop]` defaults, variant enums and `view!` markup; Flutter is ours too: a `StatelessWidget` for a component, a widget tree for a layer); components take properties as parameters, instances are calls; CSS in px or rem (`inspect.code {unit, rootFontSize}`), with rotation, blend modes, gradient directions and kinds (linear, radial, conic), text alignment, case and decoration; class names from layer names transliterate non-ASCII scripts into readable ASCII (로그인 화면 → `rogeuin-hwamyeon`, кнопка → `knopka`, über → `uber`; symbols-only names take the node's kind, `.text`), and the 40-character cut lands on a word boundary (#1235) | | Assets list with export | done | layers with export settings, icons (small, vector shapes only: SVG) and images (PNG); the icon rule is ours | -| Annotations: pinned properties, categories, coloured callouts, filter | done | | +| Annotations: pinned properties, categories, coloured callouts, filter | done | the Annotation tool (Shift+T, `tool.annotation`) picks a layer by a click or from the selection and opens its note field in the Inspect tab. Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet | | Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) | | Focus view (one frame, previous / next) | done | | | Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions | @@ -29,6 +29,7 @@ repository, third-party integrations. Remaining: 1–2 h. | Date | Change | Summary | |---|---|---| +| 2026-11-09 | patch | The Annotation tool (Shift+T, `tool.annotation {id?}`): selects the layer pressed on (or the selection, or `id`) and asks the UI for its note, which the Inspect tab takes in its new-note field; the engine writes nothing itself, and Developer Mode lets the tool through its gates (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet | | 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish [--mappings figcraft.connect.json] [--dry-run]` publishes a code repository's mappings to a design file (as the incumbent's Code Connect CLI publishes a repository's mappings): it links each entry's component or set by name, warns about and skips links that don't fit, saves the file in place through the engine's save (or not, with `--dry-run`) and fails when nothing links. Dev Mode breadth (~98%) and ready (~78%) unchanged at this rounding; hours unchanged | | 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged | | 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index ccc133c51..d4e9d750d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # FigCraft parity with Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-11 · **Change:** minor (an exported layer with a background blur or glass keeps the blurred content behind it within its bounds (#2213); a typed number and a full stop at the start of a paragraph starts a numbered list at that number, as measured in Figma 2026-11-11; justified Chinese, Japanese and Korean text, written without spaces, spreads each line's room between its characters so its right edge aligns (#1970); joining the two ends of a path broken open by a deleted point closes it again, so its fill and selection outline follow the new segment and its corner radius instead of a straight line across the gap (#1951); glass's docs say which centre splay bends away from and where a shape gets no core; glass bends towards its core, rounded rectangles in closed form; glass's light is a line on its bevel and splay turns the bend towards the centre; glass bends with a thick-lens profile and splits its colours over 8 steps, measured against the reference tool; bulleted lists use the same bullet at every level of indentation, as the help center says, instead of a white bullet and a square at the deeper levels; typing `1)` and a space at the start of a paragraph starts a numbered list, as `1.` does, both being the help center's creation characters; a link on text is underlined by default, as the help center says, and Underline takes the underline off while the link stays; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; gap 6: widgets draw lines and SVG pictures (plugin API 6); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Thai, Lao, Khmer and Burmese lines break between words (#1219); MCP and control calls name their document (#998); shadows of images with transparent pixels follow their alpha (#1289); Arabic draft catalog; truncated text fills its last line before the ellipsis and keeps truncating when resized; auto-height text with max lines (#1375, #1376, #1377), numbers unchanged; plugin widgets take text input and a property menu (plugin API 4); boards: templates and tables; boards: a shared timer and voting sessions; boards: the dot grid, connector ends dragged to re-attach, curved connectors; `.sketch` import, best effort: import/export breadth 86% → 87%, file formats 60% → 61%, headline numbers unchanged; Canvas & navigation's remaining work no longer lists outline mode's Include hidden layers and Include object bounds; boards: area 22 (weight 0, outside the weighted sum); CJK line breaking: JIS X 4051's line-start and line-end sets completed with the halfwidth small kana, the halfwidth and spacing (semi-)voiced sound marks, iteration marks 〱–〵 and the halfwidth parentheses; Turkish reviewed, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; vector edit mode regions: hover hatch, selection and drag; plugin log byte/count limits (#191); core editor leftovers: component playground, auto gap handles, variables inside styles, inner shadows and background blur on groups, command-made text readable on dark pages, a one-step frame/group convert, delays on mouse triggers and manual overlay offsets, wrapped-line alignment; starter templates, a Templates tab and Getting started tips on the home screen: essentials ~57% → ~59%; everyday editing partials: number fields, outline options, Hide when publishing, cursors, panel widths, dark pages, numbers unchanged but menu breadth 211/211; text box sizes measured against documents re-saved by other software: whole-pixel line heights, advances on a 1024-per-em grid, letter spacing between glyphs only, numbers unchanged; .fig export checked by opening written files in other software, numbers unchanged; a public figcraft-server: open hosting, owner tokens, per-address limits, idle expiry, moderation, install.sh; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; headline percentages recomputed from the area table: feature breadth ~84%, core ~94%, mainstream ~50%; text lists, hanging punctuation and decoration styles; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; styles and variables behaviour suite and fixes; prototyping behaviour suite and fixes; components behaviour suite and fixes; embedded image colour profiles; layers panel keyboard: F2 rename, Shift+F10 context menu, keyboard box selection, 10k-layer QA; per-side stroke weight variables; text behaviour suite: CJK line breaking, metrics, vertical trim; selection and transform behaviour suite and fixes; `.fig` import, best effort; auto layout behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings; feature breadth ~80%; ready ~49%; last major: full re-measure against Figma desktop 126.10.7; rows for the community batches: Selection colors, smart-selection spacing and a Present tab (#753), `.fig` import fidelity, paste from another design app, a `.fig` copy from Save as; control requests while the window is minimised (#720); catalog counts re-measured; automation roots (#715); number variables in the text content picker (#629); frames and sections convert into each other (#719)) · **Target:** Figma Design (desktop app 126.10.7, macOS) The authoritative assessment of how close FigCraft is to Figma Design. [`ROADMAP.md`](../ROADMAP.md) summarizes it; [`gaps.md`](gaps.md) is the ranked work list; the measured menu checklist is @@ -102,7 +102,7 @@ Breadth: does it exist. Ready: depth, correctness and fidelity for real projects | 4 | Shapes & vector | 8 | 99% | 73% | Corner radius and smoothing on boolean groups that contain rectangles; per-point caps and joins on dashed, variable-width and brush strokes; vector QA with real files (behaviour suites from the documentation, corner radius and star ratio handles, corner smoothing on polygons, stars and closed vectors, rounded vector points drawn, also beside curved segments, a cap and join per point, several handles dragged together, cut and paint bucket drags, delete and heal along the curve, a boolean group's look by operation and the miter angle landed) | 1–3 | | 5 | Paint | 7 | 90% | 71% | Video fills, wide-gamut canvas, HEIC images; plus darker in PDF (behaviour suite from the documentation, pass-through and isolating groups, clipping frames that don't isolate, plus darker, animated GIF fills, TIFF images and embedded image colour profiles landed) | 2–5 | | 6 | Strokes | 3 | 95% | 75% | Stroke QA with real files (colour styles on strokes, stroke weight and per-side weight variables, brush strokes landed, see 20) | 1–2 | -| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed) | 2–5 | +| 7 | Effects | 4 | 100% | 75% | Glass and progressive blur tuned side by side (behaviour suite from the documentation, inner shadows and background blur on groups, shadows hidden behind translucent layers on the canvas and in every export landed; a background blur or glass exported on its own keeps the content behind it within its bounds, measured 2026-11-10, #2213) | 2–5 | | 8 | Frames, groups, sections, masks | 5 | 97% | 80% | Visual QA with real files (a one-step frame/group convert, and a behaviour suite from the documentation landed: masked-out areas don't take clicks, mask group bounds and exports follow the mask, sections kept out of frames and groups, frames and sections drawn around layers take them, section resizing leaves contents in place, X/Y measured in the frame) | 1–3 | | 9 | Auto layout & constraints | 9 | 98% | 74% | Behaviour against real Figma files (behaviour suite from the documentation, `fr` tracks like CSS, vertical wrap, auto gaps evenly/around, cell alignment and wrapped-line alignment landed) ([auto-layout-parity](auto-layout-parity.md)) | 3–6 | | 10 | Text | 8 | 90% | 70% | Spell check, text metrics on real files and in other fonts (text box sizes measured against documents re-saved by other software: all 230 self-sizing boxes in 7 documents come out to the pixel, one font family; behaviour suite with CJK line breaking, Thai, Lao, Khmer and Burmese breaks between words, vertical trim and carets by grapheme cluster landed; lists per paragraph with nesting and list spacing (typing `-`, `*`, `1.` or `1)` and a space starts one), hanging punctuation and lists, decoration style, thickness, offset, skip ink and colour landed) | 3–7 | @@ -655,6 +655,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | 2026-11-04 | patch | M4, #1970: justified Chinese, Japanese and Korean text, written without spaces between words, aligns its right edge: the room on each line but a paragraph's last goes between its characters, each a word of its own as for line breaking ("Justify aligns the first word in each line of text to the left-most edge, and the last to the right-most edge"), wherever a line could break there, so none opens before a full stop or closing bracket, nor after an opening one. Latin text widens its spaces as before; mixed text widens both. A gap is reordered with the line's runs as a space of no width would be, so one beside a Hebrew word in Chinese text, or beside a Chinese word within that Hebrew, opens beside the word, not inside it. `figcraft_text::breaks::justify_between`, beside `break_between`, says where. Evidence: `tests::justified_ideographs_spread_the_room_between_characters` (fails on main). | | 2026-11-04 | patch | M8, #1951 (#1506 again): a path broken open by deleting a point (Delete, not Shift+Delete) is filled closed by a straight line across the gap, as an open path's fill is; joining its two ends again (the pen onto the other end, Connect, ⌘J) left the loop without the new segment, so once the points were rounded the fill and the selection outline ran straight across while the stroke followed the segment and its arcs. Joining now closes the loop through the new segment ("Click to close the path."; "You can use the Paint tool to add fills to any closed regions of a vector network."): `ops::connect` puts it into the loop whose segments are the chain between the ends, at the gap and in the loop's own order, so a hole keeps its winding whichever end was clicked first; a loop listed out of walking order is left as it was. Evidence: `vectoredit::tests::a_rounded_chamfer_made_other_ways_fills_along_its_arcs` (fails on main: the loop kept 4 of 5 segments), `a_rounded_chamfer_cut_in_the_editor_fills_along_its_arcs` (Shift+Delete, with and without a region fill), `doc::tests::a_chamfered_rectangles_rounded_fill_is_cut_at_every_point` (fill, region path and stroke agree for every loop order and segment direction of a chamfered rectangle). | | 2026-11-06 | patch | M3: glass's docs say which centre `splay` bends away from: the centre of the layer's bounds, as the reference tool's, checked side by side on a ring and an L at Splay 0% and 100% (an inner edge bends towards the hole in both). And where a glass shape gets no core: past 4M px on the desktop and 1M on the web (`MAX_SHAPE_PX`) it bends along its edge normals, so a large glass text or vector can bend differently once zoomed into, in a 2× export, or on the web. Docs only. Grade unchanged. | +| 2026-11-10 | patch | M10, #2213: a layer with a background blur or glass exported on its own includes the blurred content behind it within its bounds, as the reference tool's web app does (measured 2026-11-10: the pane's own bounds, opaque where the backdrop is, nothing outside them; layers above it stay out, "contents only" unchanged for layers without those effects); the export reads the page's layers below the exported one as its backdrop | | 2026-11-02 | minor | M3, #1885: glass bends towards its core, the shape shrunk by `depth` and the bevel, as the reference tool's: round a sharp corner the bend turns smoothly, and a thin part (a capsule, a letter's stroke) bends across itself, checked side by side on a capsule 44 units tall. Rounded rectangles work it out in closed form. A shape too big to keep whole at the zoom bends along its edge normals instead, so no render works out a core over more pixels than a kept shape holds. Tests: `glass::tests::a_sharp_corner_bends_round_towards_the_core`, `glass::tests::a_thin_part_bends_across_itself`, `glass::tests::a_shape_too_big_to_keep_bends_along_its_normals`, `glass_cut_tests::a_cut_through_the_pane_matches_the_whole_render`. Grade unchanged. | | 2026-11-02 | minor | M3, #1885: glass's light is a line on its 1-unit bevel added to the backdrop, as the reference tool's: fully where the edge faces the light, none below 0.35 of facing it, 0.6 as much on the opposite side, with a faint gradient across the curve. `splay` turns the bend from along the edge's normal towards the way away from the layer's centre and no longer widens the light, as in the reference tool. Checked side by side on synthetic files: a splay of 1 bends the backdrop towards the centre in both. Tests: `glass::tests::the_light_lies_on_the_bevel_and_the_gradient_across_the_curve`, `backdrop_tests::splay_turns_the_bend_towards_the_centre`, `glass_cut_tests::the_light_is_a_line_on_the_bevel`. Grade unchanged. | | 2026-11-02 | minor | M3, #1885: glass bends the backdrop with the reference tool's thick-lens profile (most a 1-unit bevel in from the edge, falling off as the 3.5th power of the way out of the curve to nothing `depth` further in) and splits its colours over 8 steps, red and blue 0.16 of the bend apart at 100% dispersion. Measured against the reference tool on synthetic files at 2×: the black band a pane pulls to its edge ends within 3 px of the reference tool's, and red and blue part 2.27 px at 50% dispersion and 4.66 px at 100% (2.38 and 4.69 there). Tests: `glass::tests::the_bend_follows_the_thick_lens_profile`, `glass::tests::dispersion_splits_the_channels_along_the_bend`. Grade unchanged. | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 7501cf005..08e0db44f 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 (the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); 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 (the Rotation field shows and sets the rotation on the page, in frames and groups, through node.set absoluteRotation (#2196); 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: @@ -102,6 +102,7 @@ interaction feel not yet compared. | Text: superscript and subscript | done | `sups` / `subs` use the font's own glyphs where it has the feature and every letter or digit of the run gets one; otherwise the standard glyphs are drawn shrunk and raised or lowered by the font's OS/2 superscript and subscript metrics (0.65 em at 0.35 em up or 0.14 em down without them), and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says ("Figma draws a synthesized version by using the font's standard version of the character, shrinking it, and positioning it accordingly"; mixed glyphs "all update to the synthesized form for consistency"), #1828; tests `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them` | | Eyedropper (I) | done | picks the colour seen under the pointer (opaque) into the selection's fill and the recent colours, one undo step (`fx_behaviour_tests`); with Cmd/Ctrl+Shift held it makes a colour style or variable of it instead, as the reference app's eyedropper does: a Create from color window asks which, its name (the next free "Color n") and a variable's collection (the file's first, or a new one made with it in the same undo step), and leaves the selection's fills alone (`eyedropper_create_tests`, `sampled_color_tests`) | | Comment (C), Shift+C to show/hide | done | local only | +| Annotation tool (Shift+T) | done | picks the layer under the pointer, or the selection (or an `id`) with `tool.annotation`, and opens its note in the Inspect tab with the new-note field focused; it works in Developer Mode and in Design mode, and stays selected until V or Escape (inferred). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet | | Text on a path (Figma Draw) | done | the Text on a path tool in the Text flyout (`tool.textOnPath`) shows the I-beam over a shape's or vector's path, and a click there puts new text on the path, starting at the click, with the path's effects and its fill when one shows; the path layer is removed, and the path comes back if nothing is typed. Also from the Design panel or `text.onPath`; the path is edited by resizing only | | Stretch and scatter brushes (Figma Draw) | done | eight presets drawn in code; the Pencil's brush is set by `tool.pencilBrush`, not from the toolbar | | Image crop handles; Crop image | done | Object ▸ Crop image, the right-click menu and the crop button on an image fill row (`image.crop`) switch the image to Crop where it already shows and enter crop mode: the whole image shows, dimmed outside the layer; the layer's box carries L-shaped corner brackets and edge bars in the selection colour (in place of the square handles), and its edges and corners crop it with the picture held still (as Cmd/Ctrl-resizing does anywhere), the image drags to move and its corners scale it (Shift: freely); Enter, Escape or a click outside finishes (`image_crop_tests`, `crop_image_tests`). Not compared side by side | @@ -138,9 +139,9 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | | Count | Notes | |---|---|---| -| Documented shortcuts (rows) | 136 | each with macOS and Windows/Linux keys | -| Bound as documented | 135 (110 before this suite) | 112 keymap bindings, 15 held keys, gestures and presentation keys, 6 vector edit tool keys | -| Bound as documented | 135 (110 before this suite) | 112 keymap bindings, 16 held keys, gestures and presentation keys, 6 vector edit tool keys | +| Documented shortcuts (rows) | 137 | each with macOS and Windows/Linux keys | +| Bound as documented | 136 (110 before this suite) | 113 keymap bindings, 15 held keys, gestures and presentation keys, 6 vector edit tool keys | +| Bound as documented | 136 (110 before this suite) | 113 keymap bindings, 16 held keys, gestures and presentation keys, 6 vector edit tool keys | | Bound on other keys (documented conflict) | 1 | Run last plugin: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; the plugin takes Ctrl+Alt+Shift+P there | | Missing | 0 | moving the rotation origin (Alt+R, `transform.rotationOrigin`) and placing an object with Enter after choosing its tool (`tool.place`) were the last two | | Documented keys a platform keeps | 2 | Ctrl+Space (Windows/Linux keyboard box selection) switches some CJK input methods; F6 goes to a browser's address bar in the web build | @@ -189,6 +190,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| | 2026-11-10 | minor | The Rotation field shows and sets the layer's rotation on the page (its own angle plus its parents' when no ancestor is flipped; with a flipped ancestor FigCraft reads the composed angle, which flips the sign of the layer's own angle, and Figma's behaviour there was not measured), in frames and groups alike, as measured in Figma's web app for rotated, unflipped parents (#2196) | +| 2026-11-09 | minor | Shortcuts: Shift+T picks the new Annotation tool (`tool.annotation {id?}`, Accessibility at Figma, Add an annotation): with a layer selected, or an `id`, it asks the UI for that layer's note at once; in the tool a press on a layer selects it and asks, a press on empty canvas does nothing; the Inspect tab opens with the caret in the new-note field and the engine writes nothing itself; documented shortcuts 137, bound 136 (#1840). Figma's placement, cursor and whether the tool stays selected are unmeasured (Developer Mode is paywalled in the web app); staying selected until V or Escape, like the Comment tool, is inferred, and there is no toolbar button or icon yet | | 2026-11-11 | patch | M5, gap 4: marquee selection as measured in Figma's web app (Select layers and objects, Selection marquee): a partly covered top-level frame or section gives the layers in it the marquee touches (it was selected only when enclosed, so nothing came), an empty frame is selected on touch, a rotated shape is touched by its outline and not its box, a drag from inside a frame selects that frame only when the marquee also takes another top-level frame (then whole), and also takes the top-level layers it touches outside it, and selecting a frame whole keeps the marquee at the top level (a partly covered frame then comes whole, not its layers). Flipped: `marquee_selects_what_it_touches`, `cmd_marquee_selects_nested_layers`, `a_box_selects_like_a_marquee`. Tests: `marquee_behaviour_tests`. Counts unchanged | | 2026-11-08 | minor | Vector edit mode, #1875, measured in Figma (web, real keys): Copy with segments selected takes only those (with just a point selected, the segments touching it) and a paste outside edit mode is a new vector layer in place; Cut removes them, the path splitting into subpaths in the same layer with edit mode staying on, and a paste outside gives a new layer with the cut segments; Paste while editing a vector adds the clipboard's segments, or a copied layer's outline, to the same network in place, selecting the pasted points, with no new layer and the bounds growing. FigCraft copied the whole layer, Cut left nothing to paste and Paste did nothing. `edit.copy`, `edit.cut` and `edit.paste` act so with no `ids`/`parent` given (Ctrl+X is the real Cut again; Shift+Delete still heals); the copy carries the layer's look (fills, strokes, effects, opacity, blend; the width profile only when every segment is copied); layers with no outline are skipped. Tests: `vector_clipboard_tests` | | 2026-11-08 | patch | Selection: Escape with layers selected clears the selection instead of stepping out to the parent first. Measured in the Figma web app with a rectangle in a group in a frame, selected from the canvas, from the Layers panel and after Tab: one Escape selects nothing. Editing, gestures and the pen still end first |