From 4201fed27da01ee1ae141b6572ed664f2c28cdc9 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 09:17:19 -0500 Subject: [PATCH 01/12] M9: .jam import: stickies come in as native board stickies (#1808) On a FigJam whiteboard the mapper builds a sticky with complete generated records as a native board sticky: the saved colour, size, place, opacity and shadows, the note and the author line as text layers, no background vector. It writes a board field on the REST JSON, which the REST importer reads (junk values are ignored with a warning). Damaged stickies and stickies in a .fig keep the generated layers. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 briefed; Sonnet 5.5 wrote at medium --- crates/format/src/fig.rs | 2 +- crates/format/src/fig/board_import_tests.rs | 98 ++++++++++++++++++- crates/format/src/fig/map.rs | 12 +++ crates/format/src/fig/map/board.rs | 88 +++++++++++++++++ crates/format/src/fig/map/layer.rs | 12 ++- crates/format/src/rest.rs | 33 +++++++ .../src/rest/import_regression_tests.rs | 20 ++++ crates/ui-egui/locales/en.toml | 1 + docs/boards-parity.md | 5 +- docs/file-format-parity.md | 5 +- 10 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 crates/format/src/fig/map/board.rs diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index f7547c3d0..24268da3c 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -148,7 +148,7 @@ fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result let max_nodes = layer_budget(packed); drop(message); let file_name = archive.file_name.as_deref().filter(|n| !n.trim().is_empty()).unwrap_or(name); - let (mut json, mut warnings, networks) = map::to_rest(&msg, file_name, max_nodes); + let (mut json, mut warnings, networks) = map::to_rest_for(&msg, file_name, max_nodes, board); // The message goes before the layers are built, so the images it carries are copied out first. let embedded = embedded_copies(&msg); drop(msg); diff --git a/crates/format/src/fig/board_import_tests.rs b/crates/format/src/fig/board_import_tests.rs index dcf52c46c..89c6cba83 100644 --- a/crates/format/src/fig/board_import_tests.rs +++ b/crates/format/src/fig/board_import_tests.rs @@ -446,10 +446,106 @@ fn a_jam_file_imports_like_a_fig() { let (doc, warnings) = import(&bytes, "Synthetic board").unwrap(); assert_eq!(doc.kind, figcraft_doc::FileKind::Board, "a FigJam whiteboard opens as a board"); let note = named(&doc, "Board object"); - assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 3, "the sticky with its background, note and author"); + assert!(matches!(note.kind, NodeKind::Frame) && note.children.len() == 2, "the sticky is native: its note and author"); + assert_eq!(note.board, Some(figcraft_doc::BoardItem::Sticky { author: "Test Author".into() })); assert!(matches!(named(&doc, "Synthetic table").kind, NodeKind::Frame)); assert!(doc.nodes().all(|n| n.name != "Unknown object"), "left out"); assert!(warnings.iter().any(|w| w.contains("table layers import as")), "{warnings:?}"); assert!(warnings.iter().any(|w| w.contains("SYNTHETIC_JAM_ONLY")), "a note says what was left out: {warnings:?}"); } } + +/// Import `layer` through the public importer as a FigJam whiteboard (`fig-jam.` header). +fn jam(layer: Kv<'static>) -> (Document, Vec) { + let mut bytes = canvas(&board_schema(), &message(layer)); + bytes.splice(..8, *b"fig-jam."); + import(&bytes, "Synthetic board").unwrap() +} + +/// On a whiteboard a sticky is a native sticky: its colour, size, text and author come along. +#[test] +fn a_jam_sticky_is_a_native_sticky_with_its_colour_text_and_author() { + let (doc, warnings) = jam(board_node("STICKY")); + assert_eq!(doc.kind, figcraft_doc::FileKind::Board); + let frame = named(&doc, "Board object"); + assert_eq!(frame.board, Some(figcraft_doc::BoardItem::Sticky { author: "Test Author".into() })); + assert_eq!(frame.fills[0].solid_color().unwrap().to_rgba8(), [255, 191, 64, 255]); + assert_eq!((frame.width, frame.height), (240.0, 240.0)); + assert_eq!(frame.opacity, 0.5); + let layout = frame.layout.as_ref().expect("sticky auto layout"); + assert_eq!(layout.padding, [20.0; 4]); + assert_eq!(frame.child_layout.min_h, Some(240.0)); + assert_eq!(frame.children.len(), 2, "the note and its author, no background vector"); + let texts: Vec<_> = frame.children.iter().map(|id| doc.node(*id).unwrap()).collect(); + assert!(texts.iter().all(|n| n.kind.text().is_some())); + let note = texts[0].kind.text().unwrap(); + assert_eq!(note.characters, "Olá\nIdea"); + assert_eq!(note.style.font_size, 24.0); + assert!(note.runs.iter().any(|r| r.style.font_size == 18.0), "inline styles survive"); + let author = texts[1].kind.text().unwrap(); + assert_eq!(author.characters, "Test Author"); + assert_eq!(author.style.font_size, 12.0); + assert!(!warnings.iter().any(|w| w.contains("skipped")), "{warnings:?}"); +} + +/// A sticky whose author line is not saved has an empty author and just its note. +#[test] +fn a_jam_sticky_without_an_author_line_has_no_author() { + let mut layer = board_node("STICKY"); + let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]); + set(&mut layer, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text("Olá\nIdea", 24.0))]))])); + let (doc, _) = jam(layer); + let frame = named(&doc, "Board object"); + assert_eq!(frame.children.len(), 1); + let Some(figcraft_doc::BoardItem::Sticky { author }) = &frame.board else { panic!("{:?}", frame.board) }; + assert!(author.is_empty()); +} + +/// A sticky record that isn't complete keeps today's generated layers and no board item. +#[test] +fn a_damaged_jam_sticky_keeps_the_generated_layers() { + let gradient = o(vec![("type", e("GRADIENT_LINEAR")), ("visible", Kv::Bool(true))]); + let cases: Vec> = { + let mut v = Vec::new(); + // Layout version 2. + let mut a = board_node("STICKY"); + set(&mut a, "derivedImmutableFrameData", o(vec![("version", u(2))])); + v.push(a); + // Background record missing. + let mut b = board_node("STICKY"); + set(&mut b, "nodeGenerationData", o(vec![("overrides", arr(vec![record(1, text("Olá", 24.0))]))])); + v.push(b); + // A stroke on the background. + let mut c = board_node("STICKY"); + let stroked = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)])), ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)]))]); + set(&mut c, "nodeGenerationData", o(vec![("overrides", arr(vec![stroked, record(1, text("Olá", 24.0))]))])); + v.push(c); + // A gradient on the background. + let mut d = board_node("STICKY"); + let graded = record(0, vec![("fillPaints", arr(vec![gradient]))]); + set(&mut d, "nodeGenerationData", o(vec![("overrides", arr(vec![graded, record(1, text("Olá", 24.0))]))])); + v.push(d); + // Blank note. + let mut f = board_node("STICKY"); + let background = record(0, vec![("fillPaints", arr(vec![solid(1.0, 0.75, 0.25)]))]); + set(&mut f, "nodeGenerationData", o(vec![("overrides", arr(vec![background, record(1, text(" ", 24.0))]))])); + v.push(f); + v + }; + for (i, layer) in cases.into_iter().enumerate() { + let (as_fig, _) = imported(layer.clone()); + let (doc, _) = jam(layer); + let frame = named(&doc, "Board object"); + assert!(frame.board.is_none(), "case {i}"); + assert_eq!(frame.children.len(), named(&as_fig, "Board object").children.len(), "case {i}: the generated layers, as in a .fig"); + } +} + +/// The same sticky in a `.fig` stays the design frame it always was. +#[test] +fn a_fig_sticky_stays_a_design_frame() { + let (doc, _) = imported(board_node("STICKY")); + let frame = named(&doc, "Board object"); + assert!(frame.board.is_none()); + assert_eq!(frame.children.len(), 3); +} diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index fd1b47575..a236e1fdb 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -17,6 +17,7 @@ use serde_json::{Map, Value, json}; use super::kiwi::Kv; use super::network; +mod board; mod component; mod connector; mod document; @@ -50,8 +51,16 @@ const MAX_ASSET_PART: usize = 256; /// The REST API file JSON for message `msg`, with the warnings about what was skipped and the /// vector networks its layers name by `vectorNetworkRef` (for [`crate::rest::import_with_networks`]). +#[cfg(test)] pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec, Vec>) { + to_rest_for(msg, name, max_nodes, false) +} + +/// [`to_rest`] for a FigJam whiteboard (`board`) or a design file: a whiteboard's stickies come +/// out as native board items. +pub(super) fn to_rest_for(msg: &Kv, name: &str, max_nodes: usize, board: bool) -> (Value, Vec, Vec>) { let mut m = Mapper::new(msg, max_nodes); + m.board = board; let _canon = VarCanon::install(&m); let doc = m.document(); // Large values are moved in, never passed through `json!`, which serialises its arguments: @@ -361,6 +370,8 @@ struct Mapper<'a> { /// Region paint elements (paints and gradient stops) read so far: one budget for the whole /// file, since layers with region paints don't share their network. region_paint_elements: usize, + /// The file is a FigJam whiteboard: its objects are built as native board items. + board: bool, } impl<'a> Mapper<'a> { @@ -438,6 +449,7 @@ impl<'a> Mapper<'a> { max_nodes, reserved: 0, networks: Vec::new(), + board: false, network_ids: HashMap::new(), region_paint_elements: 0, } diff --git a/crates/format/src/fig/map/board.rs b/crates/format/src/fig/map/board.rs new file mode 100644 index 000000000..7dcf57cfe --- /dev/null +++ b/crates/format/src/fig/map/board.rs @@ -0,0 +1,88 @@ +//! FigJam whiteboard objects as native board items: what a `.jam` import builds in place of the +//! generated frames a `.fig` keeps. The REST JSON carries them as a `board` field +//! ([`crate::rest`] reads it). + +use super::layer::{own_text, valid_size}; +use super::paint::paint; +use super::{Affine, Kv, MAX_DEPTH, Mapper, Obj, Value, View, generated_record, json, num}; + +impl<'a> Mapper<'a> { + /// A sticky as a native board sticky: its frame (size, place, opacity and shadows as saved, + /// the saved colour) holding the note and, when saved, the author line. `None` (nothing + /// changed) unless every generated record is complete: then the caller keeps the generated + /// layers. + pub(super) fn native_sticky(&mut self, o: &mut Obj, v: &View<'a>, rest_id: &str, depth: usize) -> Option> { + if v.get("nodeGenerationData").is_none() || v.get("derivedImmutableFrameData").and_then(|d| num(d, "version")) != Some(1.0) { + return None; + } + let record = |id: u32| { + let (style, bounds) = (generated_record(v, "nodeGenerationData", id)?, generated_record(v, "derivedImmutableFrameData", id)?); + let view = View::of(style).with([(bounds, 1.0)]); + (valid_size(view.get("size")).is_some() && view.transform().is_some()).then_some(view) + }; + let background = record(0)?; + let fills = background.list("fillPaints"); + let solid = matches!(fills, [p] if p.get("type").and_then(Kv::str) == Some("SOLID") && p.get("visible").and_then(Kv::bool) != Some(false)); + if !solid || !background.list("strokePaints").is_empty() { + return None; + } + let note = record(1).filter(|n| own_text(n).is_some())?; + let author = record(2).filter(|a| a.flag("visible") != Some(false) && own_text(a).is_some()); + let wanted = 1 + usize::from(author.is_some()); + if depth >= MAX_DEPTH || self.count.saturating_add(wanted) > self.budget() { + return None; + } + let (w, h) = v.size().or_else(|| o.get("size").and_then(|s| Some((s.get("x")?.as_f64()?, s.get("y")?.as_f64()?))))?; + let at = note.transform().unwrap_or_default().as_coeffs(); + let pad = |x: f64| if x.is_finite() { x.clamp(0.0, (w.min(h) / 2.0).max(0.0)) } else { 0.0 }; + let (pad_x, pad_y) = (pad(at[4]), pad(at[5])); + // Everything is valid: build. + let paints: Vec = fills.iter().filter_map(|p| paint(p, &mut |m| self.warn(m))).collect(); + o.insert("fills".into(), Value::Array(paints)); + o.insert("strokes".into(), json!([])); + o.insert("clipsContent".into(), json!(false)); + match background.len("cornerRadius") { + Some(r) => o.insert("cornerRadius".into(), json!(r)), + None => o.remove("cornerRadius"), + }; + for (k, val) in [ + ("layoutMode", json!("VERTICAL")), + ("primaryAxisAlignItems", json!("SPACE_BETWEEN")), + ("counterAxisAlignItems", json!("MIN")), + ("primaryAxisSizingMode", json!("AUTO")), + ("counterAxisSizingMode", json!("FIXED")), + ("minHeight", json!(h)), + ("itemSpacing", json!(8.0)), + ("paddingTop", json!(pad_y)), + ("paddingBottom", json!(pad_y)), + ("paddingLeft", json!(pad_x)), + ("paddingRight", json!(pad_x)), + ] { + o.insert(k.into(), val); + } + let mut kids = Vec::new(); + let mut written = String::new(); + for (local, view) in [(1, Some(note)), (2, author)] { + let Some(view) = view else { continue }; + let chars = own_text(&view).unwrap_or_default(); + let mut layer = Obj::new(); + layer.insert("id".into(), json!(format!("{rest_id};generated{local}"))); + layer.insert("type".into(), json!("TEXT")); + layer.insert("name".into(), json!(if local == 1 { chars.chars().take(40).collect::() } else { "Author".to_string() })); + self.common(&mut layer, &view, "TEXT", Affine::IDENTITY, None); + self.text(&mut layer, &view); + if let Some(Value::Object(style)) = layer.get_mut("style") { + style.insert("textAutoResize".into(), json!("HEIGHT")); + style.insert("textAlignHorizontal".into(), json!("LEFT")); + } + layer.insert("layoutSizingHorizontal".into(), json!("FILL")); + if local == 2 { + written = chars.trim().to_string(); + } + self.count += 1; + kids.push(Value::Object(layer)); + } + o.insert("board".into(), json!({"type": "sticky", "author": written})); + Some(kids) + } +} diff --git a/crates/format/src/fig/map/layer.rs b/crates/format/src/fig/map/layer.rs index e3de96368..79a786167 100644 --- a/crates/format/src/fig/map/layer.rs +++ b/crates/format/src/fig/map/layer.rs @@ -174,7 +174,9 @@ impl<'a> Mapper<'a> { // A label without a snapshot: the connector becomes a frame holding its line and its label. let connector_label = own == "CONNECTOR" && !connector_snapshot && self.connector_label_fits(&view, depth); let ty = if connector_label { "FRAME" } else { Self::mapped_type(own, &view, connector_snapshot, connector_terminal, stand)? }; - if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH") { + // A whiteboard's sticky says it imports as a frame only when it can't be a native one. + let native_sticky = self.board && own == "STICKY"; + if let Some((_, what)) = stand.filter(|_| own != "TEXT_PATH" && !native_sticky) { self.warn(format!("{} layers import as {what}", own.to_ascii_lowercase().replace('_', " "))); } if connector_snapshot { @@ -262,12 +264,18 @@ impl<'a> Mapper<'a> { }); kids.splice(0..0, connector_kids); if stand.is_some_and(|s| s.0 == "FRAME") { - if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) { + let native = if native_sticky { self.native_sticky(&mut o, &view, &rest_id, depth) } else { None }; + if let Some(native) = native { + kids.splice(0..0, native); + } else if let Some(snapshot) = self.board_snapshot(&mut o, &view, own, &rest_id, depth) { kids.splice(0..0, snapshot); } else { kids.splice(0..0, self.shape_with_text(&mut o, &view, own, &rest_id)); kids.extend(self.inner_text(&o, &view, &rest_id, own == "STICKY")); } + if native_sticky && !o.contains_key("board") { + self.warn("sticky layers import as frames with their text"); + } } if !kids.is_empty() { o.insert("children".into(), Value::Array(kids)); diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 99acbab2f..681a0defa 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -442,6 +442,8 @@ pub(crate) struct Ctx<'a> { set_defs: BTreeMap>, /// Prototype starting points: (page, name, REST node id). flows: Vec<(NodeId, String, String)>, + /// Board connectors and labels, resolved once every layer is imported (their ends name layers). + pending_boards: Vec<(NodeId, &'a Value)>, /// Layers with prototype interactions, resolved once every layer is imported. protos: Vec<(NodeId, &'a Value)>, /// Layers that carry overlay settings (position, background, outside clicks), by REST id. @@ -478,6 +480,7 @@ impl<'a> Ctx<'a> { swap_defaults: Vec::new(), set_defs: BTreeMap::new(), flows: Vec::new(), + pending_boards: Vec::new(), protos: Vec::new(), overlay_meta: BTreeMap::new(), var_kinds: BTreeMap::new(), @@ -781,6 +784,9 @@ impl<'a> Ctx<'a> { self.overlay_meta.entry(rid).or_insert(j); } self.bindings(n, j); + if let Some(b) = j.get("board") { + self.board_item(n, b); + } let restored = j.get("outlinedStroke").is_some_and(|original| restore_stroke(n, original)); // Styles and bindings were read against the stroke: they follow its paints to the fill. if outlined && !restored { @@ -788,6 +794,32 @@ impl<'a> Ctx<'a> { } } + /// The `board` field (a FigCraft extension): what the object is on a board. A sticky or shape + /// is set at once; anything else, or junk, is ignored with a warning. + fn board_item(&mut self, n: &mut Node, b: &'a Value) { + let item = match text(b, "type") { + Some("sticky") => { + let author = text(b, "author").unwrap_or("").trim(); + Some(figcraft_doc::BoardItem::Sticky { author: author.chars().take(64).collect() }) + } + Some("shape") => text(b, "shape").and_then(figcraft_doc::BoardShape::from_id).map(|shape| figcraft_doc::BoardItem::Shape { shape }), + _ => None, + }; + match item { + Some(item) => n.board = Some(item), + None => self.warn(format!("unknown board item on {} was ignored", n.name)), + } + } + + /// Board items that name other layers (connector ends, a label's connector), once every + /// layer is imported and `ids` can resolve them. + fn resolve_boards(&mut self) { + for (id, _) in std::mem::take(&mut self.pending_boards) { + let name = self.doc.node(id).map(|n| n.name.clone()).unwrap_or_default(); + self.warn(format!("unknown board item on {name} was ignored")); + } + } + /// A vector's network: the editable one when the JSON carries it (`vectorNetwork`, or /// `vectorNetworkRef` naming one built by the `.fig` import: our extensions), else from its /// geometry. A stroke-only vector uses the stroke outline, painted with the stroke paints @@ -1096,6 +1128,7 @@ impl<'a> Ctx<'a> { } } self.resolve_instances(); + self.resolve_boards(); if let Some(sources) = self.fig_sources.take() { let missing = sources.apply(&mut self.doc, &self.ids); if missing > 0 { diff --git a/crates/format/src/rest/import_regression_tests.rs b/crates/format/src/rest/import_regression_tests.rs index 5aeb4548b..90cc4c5c3 100644 --- a/crates/format/src/rest/import_regression_tests.rs +++ b/crates/format/src/rest/import_regression_tests.rs @@ -93,3 +93,23 @@ fn thousands_of_distinct_style_references_import_in_linear_time() { assert!(start.elapsed() < std::time::Duration::from_secs(10), "{:?}", start.elapsed()); assert_eq!(doc.styles.len(), n); } + +#[test] +fn a_board_field_becomes_the_nodes_board_item() { + let frame = json!({"id": "1:1", "name": "Note", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 200, "y": 200}, + "board": {"type": "sticky", "author": "A"}}); + let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap(); + assert_eq!(named(&doc, "Note").board, Some(figcraft_doc::BoardItem::Sticky { author: "A".into() })); + assert!(!warnings.iter().any(|w| w.contains("board item")), "{warnings:?}"); +} + +#[test] +fn junk_board_fields_are_ignored_with_a_warning() { + for junk in [json!(5), json!({"type": "nope"}), json!({"type": "shape", "shape": "blob"}), json!({"type": "connector"})] { + let frame = + json!({"id": "1:1", "name": "Junk", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}, "board": junk}); + let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap(); + assert!(named(&doc, "Junk").board.is_none(), "{junk}"); + assert!(warnings.iter().any(|w| w.contains("unknown board item on Junk was ignored")), "{junk}: {warnings:?}"); + } +} diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 625ee3bd8..43ffdcc26 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1885,6 +1885,7 @@ name = "English" "some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke" "stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored" "stars and polygons without point counts or geometry use the default point count" = "stars and polygons without point counts or geometry use the default point count" +"sticky layers import as frames with their text" = "sticky layers import as frames with their text" "style settings of some vector segments aren't supported; those segments use the layer's stroke" = "style settings of some vector segments aren't supported; those segments use the layer's stroke" "text path underline or strikethrough decoration is not rendered" = "text path underline or strikethrough decoration is not rendered" "text style overrides refer to missing table entries" = "text style overrides refer to missing table entries" diff --git a/docs/boards-parity.md b/docs/boards-parity.md index 16456492c..dbfad16ed 100644 --- a/docs/boards-parity.md +++ b/docs/boards-parity.md @@ -1,6 +1,6 @@ # Boards parity -> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-04 · **Change:** minor (a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) +> **Last reviewed:** 2026-10-24 · **Last updated:** 2026-11-10 · **Change:** minor (a `.jam` file's stickies come in as native board stickies, with their colour, text and author kept and the size as saved (#1808); a `.jam` elbowed connector saved with its path keeps it on import instead of being routed again (#1910); a "+" handle dragged carries the preview: the next shape is made where it is let go, or a connector joins the object it is let go on (#1919); a connector attached to a side starts 4 pt outside the border, so its stroke never lies on the fill (#1916); a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a connector end dropped along a side of a shape attaches to that side (#1708); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; a connector's bar sets its start and end points, dashes, weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); "+" handles beside a selected sticky or shape make the next one, joined by a connector (#1597); a FigJam `.jam` file opens as a board (#1342); board grid settings: dots, lines, graph paper or none, spacing, size and contrast; the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); templates and tables; facilitation: a shared timer and voting sessions; the dot grid, dragging a connector's end to re-attach it, curved connectors, elbows that go around what they join) · **Target:** the incumbent's whiteboard product (behaviour from its public help centre) **Breadth ~47%, ready ~29% (estimated, erring low).** A board is a FigCraft file of its own kind (`file.newBoard`) with a dot grid on the page, sticky notes, shapes with text, connectors that @@ -66,13 +66,14 @@ all end with the same geometry. | Audio and huddles | missing | | | AI features (summaries, sorting stickies, generating boards) | missing | | | Grid on the page | done | the canvas render draws it after the page colour and before the layers (`figcraft_render::Dots`), so objects cover it. Dots by default, or lines, graph paper (a strong line every 2–12 cells over fainter ones, like Japanese section paper) or none; spacing (4–512), mark size and contrast are settings kept with the preferences (`board.grid`; Preferences ▸ Board grid). Zoomed out it thins out (dots and lines by halves, graph paper down to its strong lines) until the marks are at least 14 points apart (10 for lines), the marks the next step drops fading out first; dots stay about a point and a half across on screen (1 to 3 points), scaled by the size setting. Dark marks on a light page, light ones on a dark page, from the theme's ink; written straight into the image, no allocation per mark, a crossing of two lines blended once; never in exports. FigCraft's own settings: the incumbent's board grid has none | -| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies and shapes with text come in as frames with their generated layers and its connectors as vectors, not as native board objects (an elbowed connector keeps the path it was saved with, #1910); no export | see [`file-format-parity.md`](file-format-parity.md) | +| `.jam` import and export | **partial**: a `.jam` opens as a board, best effort (#1342): its stickies come in as native stickies (their colour, text and author kept; size as saved), its shapes with text as frames with their generated layers and its connectors as vectors, not yet as native board objects (an elbowed connector keeps the path it was saved with, #1910); no export | see [`file-format-parity.md`](file-format-parity.md) | ## Revision history | Date | Change | Summary | |---|---|---| | 2026-11-03 | minor | M12, #1919: as the quick create help says ("Click to select and create the next shape in that direction, or drag the preview to any location on your board"; "A new shape is created with a connector and its text field active"), a "+" beside the selected sticky or shape can be dragged: the preview follows the pointer and, let go on empty board, the next one is made there, centred on the pointer, and joined by a connector leaving by the handle's side and arriving by the side facing it (`board.addConnected` gains `at: [x, y]`, a point of the page placed in the parent's coordinates); let go on another sticky or shape, inside a section or frame too (`board::sticky_or_shape_at`), a connector joins the two instead (`board.connector`), that object outlined while the pointer is over it; let go off the canvas or over the toolbar, nothing, and the bar above the selection stays away meanwhile, as for any drag. The click is as before. A shape picker on drop and choosing the connector type are not done (the help doesn't describe them). Tests: `board_connect_tests` (the drag ones), engine `board_connected_tests::the_next_one_goes_where_it_is_dropped`. Numbers unchanged. | +| 2026-11-10 | minor | M9, #1808: a `.jam` sticky imports as a native board sticky (`BoardItem::Sticky`): its frame keeps the saved size (240 pt, not FigCraft's 200), place, opacity and shadows, the saved colour is kept as it is (not snapped to the board colours), the note and the author line are its text layers, and it lays out like one made here (vertical, space between, hugging the height down to the saved size, the saved inset as padding). The importer reads a `board` field on the REST JSON (a FigCraft extension) for this; a sticky whose generated records are damaged, or the same sticky in a `.fig`, keeps the generated layers as before. Shapes, connectors, stamps and tables are unchanged; the note's text keeps the file's size until it is edited. Tests: `board_import_tests.rs` (native sticky, no author, damaged records, `.fig` unchanged), `import_regression_tests.rs` (the `board` field and junk values). Numbers unchanged. | | 2026-11-04 | minor | M12, #1916: a connector end attached to a side starts 4 pt outside the border (`board_route::END_GAP`), as FigJam's do (the issue's screenshots: the line starts a few pixels outside, with a round cap), so the stroke never lies on the fill; elbows leaving one side share their first run along its normal, curves leave tangent to it and straight ones fan out from the same point; the `.jam` importer's routes, re-attached ends, path handles and labels follow the route's points; an end placed by hand with Cmd/Ctrl held keeps its exact point. Tests: `board_route_tests` (three). Numbers unchanged. | | 2026-11-04 | patch | M6, #1910: a `.jam` elbowed connector saved with its path (FigJam's `connectorControlPoints`) keeps it on import: the points become the `ConnectorPath::Runs` a connector reshaped by hand has and the line is walked along them, so a connector drawn around a layer stays around it; one whose points don't make a path from its ends' sides is routed as before. Still a vector, not a native board object. Details in [`file-format-parity.md`](file-format-parity.md). | | 2026-10-31 | minor | M12: as the help describes, holding Cmd/Ctrl while moving a connector's end places it on any part of an object instead of snapping to a side. It keeps that spot as a fraction of the object's box, so it follows the object when it moves or resizes, and without the key an end snaps as before. `board.connector {fromAt, toAt}` and `board.reattach {at}` take it; saved only when set. Numbers unchanged | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index dd04fdf75..cd604432d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-08 · **Change:** minor (`.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (`.jam` import: stickies come in as native board stickies (#1808); `.fig` import scales per-point vector corner radii with the row that supplies their network and keeps different instance scales separate in the network cache (#2097); `.fig` export preserves scaled-instance lengths reset to their unscaled main values, including nested linked-style overrides (#2097); `.fig` import: the layers of an instance scaled with the Scale tool draw at its scale factor, not at their main's lengths (#2091); a color profile per PNG, JPG and WebP export setting (Same as file, sRGB or Display P3), the pixels converted; a `.jam` connector end with an `AUTO` or `CENTER` magnet leaves the side facing the other end, not the side its stale saved point named (#1909); a FigJam elbow connector saved with its path (`connectorControlPoints`) keeps it on import, as a board connector reshaped by hand does, instead of being routed again (#1910); JPG exports take a quality, 1 to 100 with 90 the default, and embed the file's colour profile when it is sRGB too (#2004); a FigJam connector without a saved snapshot took no stroke and was invisible; its line's paint, weight, cap and dashes now come from the file's generated line record (#1807); a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.fig` export writes colour calculations as the colour they show in each mode and leaves out, with a warning, one that resolves to nothing (#1280); `.fig` import budgets four decoded values per compressed byte instead of one, still within 20M–128M, so dense files within that budget open instead of being refused as having too many values (#574); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; folders, devices and pipes are refused before anything is read (#1114); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); a FigJam `.jam` file opens as a board (#1342); FigJam `.jam` files open like `.fig` files: File ▸ Open, drag and drop, `file.import` and the command line (#1342); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); SVG import draws each pattern tile once per scale, within the layer budget (#1080); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); offline Penpot 2.x import/export (#1143); `.sketch` import checked against Sketch's renders: master backgrounds, symbol clipping, tints before Sketch 64, mask opacity, bitmap fills, image overrides on shapes, stretching stacks, blur radii, colour adjustments, OKLab gradients, text growth, shape group borders (#1389); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); image member names are checked on read, and REST image placeholders get archive-safe keys (#1086); Android Vector Drawable (XML) export; `.fig` export: files the incumbent imports, with component set properties, and grid track sizes at its field ids; Save and auto-save don't replace changes another app made to the open file (#1330); `.sketch` import, best effort, Sketch 48 and newer; the PNG row names ignore overlapping layers and where include bounding box is offered; the Fonts table's missing-font row describes the Missing fonts window; `.figcraft` carries the file kind (boards); `.fig` import: resized instances and variable modes by asset key; atomic saves; auto-save; SVG export clip of nested frames; SVG/PDF export depth refusal (#208); `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; `.fig` export: written files checked by opening them in other software; experimental `.fig` export; export and inspect behaviour suite and fixes; .fig import: editable vector networks and more layer kinds; embedded image colour profiles; `.fig` import, best effort; animated GIF fills and TIFF images; last major: created, formats from the installed Figma's Info.plist and help center, ours from source; REST import keeps slot layers and instances' slot content (#792); REST import reads grid track sizes (#465); `.fig` import fidelity from the community batches, budgets for large files, the import report, a `.fig` copy from Save as, paste from another design app; instance layers' size and resizing and Hug on import (#731, #732)) · **Target:** Figma Design (desktop app 126.10.7) Every format Figma reads or writes, with FigCraft's support. Sources: `Info.plist` of `/Applications/Figma.app` (document types only; nothing inside the bundle's code was read), Figma's @@ -20,7 +20,7 @@ and prototype interactions are still unchecked there. | `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks with region-specific fill paints (points, curved segments, filled areas with their winding, per-point corner radius and handle mirroring, end caps; outlines when the network is missing or damaged), auto layout (grid included; a flow that records no primary-axis sizing hugs on that axis, an instance that records no auto layout has its main's), constraints, ellipse arcs, boolean operations, components, sets (with their descriptions), instances (expanded from their main with overrides, derived layout and property values; the size and resizing their layers override stay overrides; an instance scaled with the Scale tool, which stores `symbolData.uniformScaleFactor`, draws its layers at the main's lengths times that factor, composed through nested instances: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent, except where an override or derived row gives the value, which is already at the instance's scale; per-point vector corner radii also take their vector-data row's factor; the vector outline paths a layer carries (a network's points follow the layer's size), fixed grid tracks, image tile scales and glass effects are not scaled by the factor, #2091), styles, variables with modes and bindings, prototyping (set-variable, set-variable-mode and conditional actions included) and image fills from the archive come across; sticky notes and shapes with text keep their generated backgrounds, styled text, text paints, saved child bounds and sticky author lines as editable frame children, including saved vector networks without geometry paths, outer fill/stroke outlines when generated geometry is damaged, and outer body text when its generated child is unusable even if an author survives (#1341, #1397); connectors, tables, code blocks, slides and similar layers come in as the closest frames, text layers and vectors, with a warning; anything else is a warning. Since 2026-10-17 (community batches #727, #728): an instance's own identity and the cached library components a file carries, overrides applied by key and order, local text values and instance overrides over a shared text style, grid track sizes (fixed, content-sized, fill), slot content, labels of connectors saved with their drawn path (a static snapshot; the ends aren't live), skip ink, paragraph options and hanging lists, list start numbers, vertical trim, per-point stroke joins, editable text on a path, image adjustments and images carried in the message; since 2026-10-29, style links that name a library style by asset key and version (the file's own copy of it), with override rows above the linking one still winning, and override rows restating a nested level's overrides applied at that level, below the instance's own (#1234), and the line breaks the file saved with a text, followed until an edit or a resize could move one, or where a line would come out more than 3% wider than its box in the fonts at hand (#1236). Budgets come from the file's own size (four decoded values per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows the file's size too), so a 15.4 MB synthetic file with 300,000 layers opens whole; ZIP image reads have a separate total budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, with the 256 MiB per-image cap kept: a member that cannot fit is skipped before its output buffer is allocated, and the import report names the budget and counts skipped images (#1076). Failed reads and invalid image contents still consume the bytes read; a reference used by several layers is read once. This is not a limit on decoded pixels or total import memory; images carried inside the message are unchanged. The file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Elbowed and curved connectors without a saved path are routed as FigCraft's board connectors are (right angles, or one curve, from the sides of the layers they join); an elbowed one saved with its path (`connectorControlPoints`: a point and an axis per run between the first and the last) keeps that path when it leaves and arrives by the ends' sides (#1910), and a curved one's saved `connectorBezierControlPoints` are not read; a connector routed here takes its line's paint, weight, cap and dashes from the file's generated line record, as one with a saved snapshot does (#1807). A connector's label without a saved snapshot is kept as text centred on its line, without its background. Not read: table cells beyond their own layers, live style and variable links for vector region fills, variables bound to fields the editor can't bind, video fills. Tested on fixtures generated in code (`crates/format/src/fig/tests.rs`, `tail_tests.rs` and the per-feature `*_tests.rs` beside them, fuzzed), `crates/engine/src/shapebuilder_region_paints_tests.rs`, and by reading back what the export writes; the FigJam report sample from #1341 checked locally only; no representative real-world corpus yet | read tail 5–10, write: checks against real files 5–15 | | `.penpot` (Penpot 2.x ZIP export) | — | **yes, offline** (open, import, drop, CLI, MCP) | **yes, offline** (export, CLI convert) | Editable layers, supported flex/grid layout, components, tokens and prototypes using the existing model. Unsupported layouts retain saved positions and bounded metadata; toggle-overlay and embedded font binaries are reported. Font references remain editable; no downloads. Synthetic regression coverage; live Penpot and cross-platform QA remain open. See [Penpot reference](penpot.md). | further live Penpot and cross-platform QA | | `.sketch` (Sketch 48 and newer) | import (file browser ▸ Import or drop, desktop app too; converted to a Figma file, symbols as components; per the help center) | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP; Open With from Finder, Explorer and Linux file managers once installed; recent files show its `previews/preview.png`) | no (out of scope) | zip + JSON read from the public format description; Sketch 43–47 (text as binary property lists) and older (SQLite) files are refused with a message to resave them in a newer Sketch. Pages, artboards as frames with their background, groups; Sketch 2025 and newer: frames (clipping or not) with their style, graphics, stacks as auto layout (direction, gap, alignment, padding, wrap, hug/fill sizing, stretching filling every item across, items ignoring the stack), corners in the style (rounded, smooth with its smoothing; angled, inside square and inside arc as paths), pins and sizing, per-side borders, lists of blurs (progressive background blurs, glass), inner shadows in the shadow list, tints (a graphic's, group's or instance's fill colouring its layers, and colour overrides; from Sketch 64, before which a group's fill draws nothing), text colour and visibility overrides, text sized by its sizing; rectangles with corner radii, ovals, stars and polygons while their points are the generated ones, paths as editable vector networks, shape groups as their shape, as one compound path (children without an operation, by the group's winding rule) or as boolean operations (mixed operations nested from the left); operands' own borders ignored as Sketch does; open paths filled as if closed, edited ovals and rectangles as paths, bitmaps (their image under their style's fills; colour adjustments as image filters with Sketch's tone curve, contrast and then brightness added), slices, hotspots; transforms (rotation, flips after it), resizing constraints; fills (solid, gradients with Sketch's geometry, gradients mixed in OKLab or OKLCH as sRGB stops along the same colours, image patterns), borders (the first enabled border's width and position), shadows, inner shadows, layer, motion and background blurs (Sketch's radius is the standard deviation), opacity and blend modes; clipping masks (an outline mask that shows its own style draws a copy under itself, as Sketch does, and its opacity fades it and what it clips together; alpha masks only mask; broken mask chains wrapped), progressive opacity (Fade) as an alpha mask of its gradient; text with styled runs (UTF-16 ranges), paragraph and vertical alignment, lines placed by Sketch's line spacing behaviour, box sizing, fonts found by their PostScript names among the installed ones (else the family read from the name), variable font axes, Sketch's natural line (ascent and descent rounded, no line gap, a fifth more for classic Mac fonts) for text without a fixed line height; symbols as components (library copies on a page of their own) and instances carrying their masters' layers with overrides (text, nested swaps and hiding, images, also on shapes' image fills, layer and text styles, nested paths), clipping only where the master clips (`clippingBehavior` 1), laid out for their size the way Sketch does it (proportional sharing per pins from format 110, scaling before) and still live; master backgrounds in instances only when the master includes them in both instances and exports, as on Sketch's canvas and renders; Apple's system fonts found under the names designs use, with the platform's tracking kept as letter spacing; shared layer styles as paint (fills, border colours) and effect styles, shared text styles as text styles; Color Variables (also library ones) as colour variables with bindings; prototype links, flow starts, export options, layout and square grids (hidden, as Sketch shows them only in its layout view). Instances of a stack laid out at their size and texts an override changed measured again on open, an auto-width one growing from its aligned edge. Warnings: noise fills, OKLab and OKLCH gradients, motion and zoom blurs (drawn as plain blurs), fonts embedded with their data (not used), Smart Layout (kept as laid out), locked overrides, scaled instances. Tested on fixtures built in code (`crates/format/src/sketch/*_tests.rs`, fuzzed) and locally (`FIGCRAFT_SKETCH_SAMPLES`) on one real Sketch 50 file and the public Apache-2.0 IBM Carbon Design Kit saved by Sketch 68 and 83 (70,000 layers open in ~3 s, debug build; its edition saved by Sketch 47 is refused, as it should be); compared artboard by artboard with Sketch 2026's own renders (`FIGCRAFT_SKETCHTOOL`, black-box): the Sketch 50 file's 73 artboards differ in at most 2.3% of their pixels (0.8% on average) (text metrics, fonts FigCraft doesn't find, antialiasing), Carbon 11's 69 1.1% on average and at most 4.4% (text in IBM Plex, which the file embeds and FigCraft doesn't use yet), a Sketch 64 file's 65 artboards 2.1% on average, a Sketch 2026 showcase file's 16 frames 0.9% on average (at most 1.9%); the rules for master backgrounds, symbol clipping, mask opacity, bitmap fills, image overrides, stretching stacks, blur radii, colour adjustments, gradient interpolation and text growth checked against Sketch's renders of files built for each case (black-box, synthetic) | 5–10 (Smart Layout as auto layout, motion and zoom blurs, several borders of different widths; fonts embedded in the file once `.figcraft` documents can carry fonts: until then they would only last the session). Known limitations: a plain group (not a frame) that fills a stack keeps its layers' size where Sketch stretches them; a font Sketch can't find draws in the installed family's own weight (Sketch falls back to a regular face); text in the system SF Pro comes out a few percent narrower than Sketch's; macOS's reserved system fonts (PingFang) aren't found; brightness past about +0.4 is approximated (image filters can't add); OKLab gradients become sRGB stops (FigCraft gradients mix in sRGB) | -| `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with generated sticky and shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors (an elbowed connector keeps the path it was saved with, #1910), with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — | +| `.jam` (whiteboards) | native to FigJam | **yes, best effort** (`file.open`, `file.import`, File ▸ Open, drop, CLI, MCP) | no | the same archive as a `.fig` (its canvas header reads `fig-jam.`) through the same reader (#1342): it opens as a board (the file kind; a `.fig` stays a design file), with stickies as native board stickies (their colour, text and author kept, size as saved, #1808) and generated shape backgrounds and text kept (#1341) as ordinary layers rather than native board objects (see [boards-parity](boards-parity.md)); tables, stamps, code blocks and the like come in as frames with their text and connectors as vectors (an elbowed connector keeps the path it was saved with, #1910), with an import note; a layer type the reader doesn't know is left out with a note naming it; writing `.jam` is not supported | — | | `.deck` (Slides) | native to other Figma products | no | no | out of scope | — | | REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — | | `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`); image members are `images/`, and a key that can't be one — path text, a space, over 128 chars — is left out with a complaint instead of loaded (`image_tests`); saves write a unique staging file and rename it into place (#284); optional auto-save of files with a path (`pref.autoSave`, #305); Save and auto-save refuse to write over a file another app (figcraft-cli, say) changed since it was opened or saved here, judged by its size and modification time: Revert to saved loads those changes, Save as keeps both, `file.save {overwrite: true}` replaces them (#1330); `kind: "board"` marks a board (left out for design files, so files from before boards are design files; the home screen reads it for the file's mark); board objects keep their `board` metadata on the layer; Developer Mode measurements are kept on the layer they start from (`measurements: [{to} | {side}]`, left out when there are none, so older files open with none) | — | @@ -201,6 +201,7 @@ scales and empty-style clears; they have not been checked in other software. | 2026-11-07 | minor | `.fig` import, #2091 item 1: an instance scaled with the Scale tool stores `symbolData.uniformScaleFactor`, which the import never read, so the layers of a 128 px instance of a 256 px component came in at 256 px with the main's radii, strokes and spacing. The layers of an instance now take the main's lengths times the factor (composed through nested instances, so 0.5 inside 0.5 is 0.25 for the inner layers), the lengths the Scale tool scales: sizes and places, corner radii, stroke weights and dashes, effect radii, spreads and offsets, auto layout gaps and paddings, grid gaps, min and max sizes, layout grids, font sizes, pixel line heights and letter spacings, paragraph spacing and indent. A value an override or derived row gives is taken as written, already at the instance's scale; the instance's own size is the file's; a missing, non-finite, non-positive or out-of-range factor (outside 0.001 to 1000) changes nothing. Not scaled: vector outline paths (a network's points follow the layer's size), per-point corner radii, fixed grid tracks, image tile scales and glass effects. Tests: `fig::tests::a_scaled_instance_draws_its_layers_at_the_factor` and `fig::scale_factor_tests`. Numbers unchanged | | 2026-11-03 | patch | M6, #1909: `.jam` import: a connector end whose magnet is `AUTO` (FigJam picks the side when it draws) or `CENTER` (the end aims at the layer's middle) left the side its stale `relativePosition` named, the left one in the reporter's file, so the connectors looped behind their shape or crossed it. It now leaves the side facing the other end, as a board connector drawn here does (`Side::Auto`), for straight, elbowed and curved connectors alike; a named magnet (`TOP`, `RIGHT`, `BOTTOM`, `LEFT`) is unchanged, and an end without a magnet still takes the edge its point lies on. The Plugin API lists the magnets (`ConnectorEndpoint.magnet`: "Which side of the node it attaches to"; NONE, AUTO, TOP, LEFT, BOTTOM, RIGHT, CENTER; "Straight connectors may use only CENTER or NONE magnets"). Test: `connectors_with_an_automatic_side_leave_the_side_facing_the_target`. Numbers unchanged. | | 2026-11-04 | minor | JPG export: `quality` (1 to 100, default 90) on `file.export`, `export.add` / `export.update` and MCP `export`, kept in the export setting; an sRGB file's JPGs embed the sRGB ICC profile, as Display P3 files' JPGs already embedded theirs, since an untagged JPG is only assumed to be sRGB (#2004). | +| 2026-11-10 | minor | M9, #1808: `.jam` import: a FigJam sticky comes in as a native board sticky. The mapper knows the canvas header (`fig-jam.`) and, for a whiteboard only, writes a `board` field (`{"type": "sticky", "author": ..}`, a FigCraft extension of the REST JSON) on a sticky whose generated records are complete (layout version 1, one visible solid background paint without strokes, a non-blank note), with the background's colour and corner radius, the file's size, place, opacity and shadows, the note and the author line as text layers and no background vector; the REST importer sets it as the layer's board item and ignores a junk `board` value with a warning. A sticky that isn't complete, or any sticky in a `.fig`, keeps the generated layers and the old note. Shapes with text, connectors, stamps and tables are unchanged. Tests: `fig::tests::board` (four), `rest::import_regression_tests` (two). | | 2026-11-04 | minor | `.fig` and `.jam` import, #1910: an elbowed connector saved with its path keeps it. FigJam stores `connectorControlPoints`, one per run between the first and the last, each a point on its run (in the connector's parent's coordinates) with the run's axis; the first run leaves the start's side and the last arrives at the end's. They become the `ConnectorPath::Runs` a board connector reshaped by hand has (`figcraft_doc::route_along`), so a connector drawn around a layer in FigJam stays around it instead of being routed through it. Points that don't make such a path (off both axes, two runs in a row the same way, a first or last run sideways to its side or going back into its object, more runs than a hand could make) leave the connector to the router as before, and only connectors routed here count in the "follow FigCraft's own routing" note. A curved connector's `connectorBezierControlPoints` are still not read. Tests: `tail_tests::an_elbow_connector_saved_with_its_path_keeps_it`, `tail_tests::an_elbow_whose_saved_path_doesnt_fit_its_sides_is_routed_here`. | | 2026-11-01 | patch | `.fig` and `.jam` import: a connector without a saved snapshot (elbowed and curved ones are routed here) took no stroke, so it was invisible (#1807); its line's paint, weight, cap and dashes now come from the file's generated line record, as a saved snapshot's do, and the connector's own values, when it has any, win. Test: `fig::connector_label_tests::a_routed_connector_takes_its_line_style_from_the_generated_record`. Numbers unchanged | | 2026-10-31 | minor | Export settings: each PNG, JPG and WebP setting has a color profile, Same as file (the default; the document's profile when it exports), sRGB or Display P3, in its advanced settings and as `colorProfile` on `export.add`, `export.update`, `file.export`, `file.exportLayers` and MCP `export`. The pixels convert to the chosen profile (out-of-gamut colors clip) and the file is tagged as before: sRGB PNG with an `sRGB` chunk, Display P3 with the generated ICC profile (and `cICP` in PNG); sRGB JPG and WebP stay untagged. A setting keeps its choice when its format changes; settings saved without one read as Same as file. SVG, PDF and XML show no choice and stay sRGB (PDF has no output intent yet). Numbers unchanged | From a415598e114e67f10569bea75c197158431af419 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 09:43:30 -0500 Subject: [PATCH 02/12] .jam import: board items only on frames, authors without control characters, board items in the dump (#1808) From the pre-ready review: a sticky or shape item on a non-frame layer is ignored with the warning; the author drops control characters as board.sticky does; dump_samples writes a whiteboard's board items; a doc link made plain text. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote; Fable 5.1 reviewed --- crates/format/src/fig/map.rs | 4 ++-- crates/format/src/fig/tests.rs | 3 ++- crates/format/src/rest.rs | 10 +++++++--- crates/format/src/rest/import_regression_tests.rs | 8 +++++++- 4 files changed, 18 insertions(+), 7 deletions(-) diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index a236e1fdb..dc51f9156 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -56,8 +56,8 @@ pub(super) fn to_rest(msg: &Kv, name: &str, max_nodes: usize) -> (Value, Vec (Value, Vec, Vec>) { let mut m = Mapper::new(msg, max_nodes); m.board = board; diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index a8abf56fe..e024920a7 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -1583,7 +1583,8 @@ fn dump_samples() { std::fs::write(format!("{out}/{stem}.schema.txt"), txt).unwrap(); let msg = schema.decode_message(schema.find("Message").unwrap(), &message).unwrap(); std::fs::write(format!("{out}/{stem}.json"), serde_json::to_string_pretty(&debug_json(&msg)).unwrap()).unwrap(); - let (rest, ..) = map::to_rest(&msg, &stem, super::layer_budget(message.len())); + // A whiteboard's dump shows its native board items as the import makes them. + let (rest, ..) = map::to_rest_for(&msg, &stem, super::layer_budget(message.len()), a.canvas.starts_with(super::JAM_HEADER)); std::fs::write(format!("{out}/{stem}.rest.json"), serde_json::to_string_pretty(&rest).unwrap()).unwrap(); } } diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index 681a0defa..8797d9efa 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -797,12 +797,16 @@ impl<'a> Ctx<'a> { /// The `board` field (a FigCraft extension): what the object is on a board. A sticky or shape /// is set at once; anything else, or junk, is ignored with a warning. fn board_item(&mut self, n: &mut Node, b: &'a Value) { + // A sticky or shape is a frame holding its layers: on anything else it is ignored. + let frame = n.kind.is_frame_like(); let item = match text(b, "type") { - Some("sticky") => { + Some("sticky") if frame => { let author = text(b, "author").unwrap_or("").trim(); - Some(figcraft_doc::BoardItem::Sticky { author: author.chars().take(64).collect() }) + Some(figcraft_doc::BoardItem::Sticky { author: author.chars().filter(|c| !c.is_control()).take(64).collect() }) + } + Some("shape") if frame => { + text(b, "shape").and_then(figcraft_doc::BoardShape::from_id).map(|shape| figcraft_doc::BoardItem::Shape { shape }) } - Some("shape") => text(b, "shape").and_then(figcraft_doc::BoardShape::from_id).map(|shape| figcraft_doc::BoardItem::Shape { shape }), _ => None, }; match item { diff --git a/crates/format/src/rest/import_regression_tests.rs b/crates/format/src/rest/import_regression_tests.rs index 90cc4c5c3..5d5bfb48e 100644 --- a/crates/format/src/rest/import_regression_tests.rs +++ b/crates/format/src/rest/import_regression_tests.rs @@ -97,7 +97,7 @@ fn thousands_of_distinct_style_references_import_in_linear_time() { #[test] fn a_board_field_becomes_the_nodes_board_item() { let frame = json!({"id": "1:1", "name": "Note", "type": "FRAME", "relativeTransform": at(0.0, 0.0), "size": {"x": 200, "y": 200}, - "board": {"type": "sticky", "author": "A"}}); + "board": {"type": "sticky", "author": "A\u{7}"}}); let (doc, warnings) = import(&file(json!([frame])), &no_images).unwrap(); assert_eq!(named(&doc, "Note").board, Some(figcraft_doc::BoardItem::Sticky { author: "A".into() })); assert!(!warnings.iter().any(|w| w.contains("board item")), "{warnings:?}"); @@ -112,4 +112,10 @@ fn junk_board_fields_are_ignored_with_a_warning() { assert!(named(&doc, "Junk").board.is_none(), "{junk}"); assert!(warnings.iter().any(|w| w.contains("unknown board item on Junk was ignored")), "{junk}: {warnings:?}"); } + // A sticky is a frame holding its layers: on a rectangle the item is ignored. + let rect = json!({"id": "1:2", "name": "Rect", "type": "RECTANGLE", "relativeTransform": at(0.0, 0.0), "size": {"x": 20, "y": 20}, + "board": {"type": "sticky", "author": "A"}}); + let (doc, warnings) = import(&file(json!([rect])), &no_images).unwrap(); + assert!(named(&doc, "Rect").board.is_none()); + assert!(warnings.iter().any(|w| w.contains("unknown board item on Rect was ignored")), "{warnings:?}"); } From ccb33e0bbce3a858b7403643eb91ba5bc7de4332 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 12:43:24 -0500 Subject: [PATCH 03/12] M7: Text styles: bind a number variable to the weight (#2174) A text style takes a Number variable on `fontWeight` (`style.bind`): the style gets the face nearest the number and sets the `wght` axis, so static and variable fonts both follow. A bound weight and a bound face name replace each other, and the weight wins in a file carrying both. The style editor's weight menu offers number variables beside string ones, and a variable bound to a weight is Unitless in the CSS export. A number bound to a line height stays pixels, as the reference tool applies it; a test pins that. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 designed; Sonnet 5.5 wrote at medium --- ROADMAP.md | 2 +- crates/engine/src/assets/style_vars.rs | 13 ++- crates/engine/src/assets/token_roles.rs | 7 ++ crates/engine/src/cmd/styles.rs | 7 +- crates/engine/src/style_vars_tests.rs | 99 +++++++++++++++++++ crates/ui-egui/src/panels/variable_ui.rs | 26 ++++- crates/ui-egui/src/widgets/style_picker.rs | 9 +- .../ui-egui/src/widgets/style_picker/tests.rs | 45 +++++++++ docs/components-parity.md | 3 +- docs/mcp.md | 4 +- 10 files changed, 203 insertions(+), 12 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index dc6204b44..cbab3235a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** patch (the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out 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; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); 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); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); 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); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; 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; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); 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, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** patch (a text style binds a number variable to its weight (#2174); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out 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; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); 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); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); 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); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; 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; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); 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, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs index 13b120f7e..fc7307403 100644 --- a/crates/engine/src/assets/style_vars.rs +++ b/crates/engine/src/assets/style_vars.rs @@ -1,7 +1,8 @@ //! Variables bound inside shared styles. A style's `bound` map names fields of its own value in //! the keys a layer's bindings use (`fills//color`, `fills//stops//color`, //! `effects//`, `layoutGrids//`, a text style's `fontSize`, `lineHeight`, -//! `letterSpacing`, `fontFamily`, `fontStyle`). Each layer using the style sees those fields +//! `letterSpacing`, `fontWeight`, `fontFamily`, `fontStyle`; a number weight and a style name set the +//! same property, the weight wins). Each layer using the style sees those fields //! resolved in its own modes; the style itself stores them resolved in the default modes. use std::borrow::Cow; @@ -32,7 +33,7 @@ pub fn field_type(kind: &StyleKind, key: &str) -> Option { (StyleKind::Effect { effects }, Some((BoundList::Effects, i, field))) => effect_field_type(&effects.get(i)?.kind, field), (StyleKind::Grid { grids }, Some((BoundList::Grids, i, "size" | "count" | "gutter" | "offset"))) => grids.get(i).map(|_| Number), (StyleKind::Text { .. }, None) => match key { - "fontSize" | "lineHeight" | "letterSpacing" => Some(Number), + "fontSize" | "lineHeight" | "letterSpacing" | "fontWeight" => Some(Number), "fontFamily" | "fontStyle" => Some(String), _ => None, }, @@ -70,6 +71,7 @@ pub fn field_scopes(kind: &StyleKind, key: &str) -> Option<&'static [&'static st "letterSpacing" => &["LETTER_SPACING"], "fontFamily" => &["FONT_FAMILY"], "fontStyle" => &["FONT_STYLE"], + "fontWeight" => &["FONT_WEIGHT"], _ => return None, }, _ => return None, @@ -135,6 +137,13 @@ fn set_text_field(style: &mut TextStyle, key: &str, val: &VariableValue) { ("letterSpacing", VariableValue::Number(x)) if x.is_finite() => style.letter_spacing = LetterSpacing::Pixels(x.clamp(-10_000.0, 10_000.0)), ("fontFamily", VariableValue::String(f)) if !f.trim().is_empty() => style.font_family = f.clone(), ("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.font_style = f.clone(), + ("fontWeight", VariableValue::Number(x)) if x.is_finite() => { + // The face nearest the number (a static family) and the `wght` axis (a variable font). + let w = x.clamp(1.0, 1000.0); + let italic = style.italic(); + style.set_font_style(crate::cmd::props::styled_name(crate::cmd::props::weight_name(w as u16), italic)); + style.variations.insert("wght".to_string(), w); + } _ => {} } } diff --git a/crates/engine/src/assets/token_roles.rs b/crates/engine/src/assets/token_roles.rs index 3827ecbea..648897fcc 100644 --- a/crates/engine/src/assets/token_roles.rs +++ b/crates/engine/src/assets/token_roles.rs @@ -60,6 +60,13 @@ impl Roles { } } } + for st in &doc.styles { + for (field, id) in &st.bound { + if field == "fontWeight" { + roles.entry(id.clone()).or_insert(NumberRole::Unitless); + } + } + } for v in vars.iter().filter(|v| v.kind == VariableType::Number) { if let Some(r) = v.scopes.as_ref().and_then(|s| s.iter().find_map(|s| role_of_scope(s))) { roles.entry(v.id.clone()).or_insert(r); diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 9ba420c43..ca48180ec 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -54,7 +54,7 @@ pub fn specs() -> Vec { spec!( "style.bind", "Bind variable in style", - "{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills//color` (or `fill`: its first paint) and `fills//stops//color`, an effect style's `effects//` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids//`, a text style's `fontSize|lineHeight|letterSpacing` (numbers, px) and `fontFamily|fontStyle` (strings). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes", + "{style, field, variable? (id or name; none: unbind)} bind a variable to a field of the style's value: a paint style's `fills//color` (or `fill`: its first paint) and `fills//stops//color`, an effect style's `effects//` (glass: `lightAngle|lightIntensity|refraction|depth|dispersion|radius|splay`), a grid style's `layoutGrids//`, a text style's `fontSize|lineHeight|letterSpacing|fontWeight` (numbers; line height and letter spacing in px, as the reference tool applies them; a weight picks the family's face nearest it and the `wght` axis of a variable font) and `fontFamily|fontStyle` (strings; a weight bound as a number replaces a bound style name and the reverse). Every layer using the style shows the field resolved in its own modes; the style keeps it resolved in the default modes", true, bind_style ), @@ -520,6 +520,11 @@ fn bind_style(s: &mut Session, p: &Value) -> Result { { rebind_paint_alpha(paints, &mut [], &key, true); } + match key.as_str() { + "fontWeight" => style.bound.remove("fontStyle"), + "fontStyle" => style.bound.remove("fontWeight"), + _ => None, + }; style.bound.insert(key, id); } None => { diff --git a/crates/engine/src/style_vars_tests.rs b/crates/engine/src/style_vars_tests.rs index 66768daa7..52fed332a 100644 --- a/crates/engine/src/style_vars_tests.rs +++ b/crates/engine/src/style_vars_tests.rs @@ -160,6 +160,7 @@ fn what_a_style_cannot_bind_is_refused() { ("fills/3/color", "bg", "no field"), ("effects/0/radius", "blur", "no field"), ("fontSize", "size", "no field"), + ("fontWeight", "bg", "no field"), ("opacity", "size", "no field"), ("fill", "nope", "no such variable"), ] { @@ -209,6 +210,7 @@ fn style_fields_offer_their_properties_scopes() { (&body, "letterSpacing", Some(&["LETTER_SPACING"][..])), (&body, "fontFamily", Some(&["FONT_FAMILY"][..])), (&body, "fontStyle", Some(&["FONT_STYLE"][..])), + (&body, "fontWeight", Some(&["FONT_WEIGHT"][..])), // Grid fields take any number variable, as on layers; fields a style lacks take none. (&grid, "layoutGrids/0/size", None), (&sid, "fills/3/color", None), @@ -244,3 +246,100 @@ fn rest_and_fig_carry_style_bindings() { assert!(got.bound.is_empty() && got.styles.fill.as_deref() == Some(st.id.as_str()), "{got:?}"); } } + +/// A text style "Body" (made from a layer, or from `value`) used by a layer in the Light frame +/// and one in the Dark one; returns (light layer, dark layer, style id). +fn body_style(s: &mut Session, value: Option) -> (NodeId, NodeId, String) { + let (_, dark, a, _, _) = two_modes(s); + let light = node(s, a).parent.unwrap(); + let text = |s: &mut Session, f: NodeId| id(run(s, "text.create", json!({"text": "Hello", "x": 0, "y": 60, "parent": f}))); + let (t1, t2) = (text(s, light), text(s, dark)); + let from = match &value { + Some(v) => json!({"type": "text", "name": "Body", "value": v}), + None => json!({"type": "text", "name": "Body", "from": t1}), + }; + let body = run(s, "style.create", from)["id"].as_str().unwrap().to_string(); + run(s, "style.apply", json!({"ids": [t1, t2], "style": body})); + (t1, t2, body) +} + +fn text_of(s: &Session, n: NodeId) -> figcraft_doc::TextStyle { + node(s, n).kind.text().unwrap().style.clone() +} + +#[test] +fn a_number_weight_picks_the_face_and_the_wght_axis() { + let mut s = Session::new(); + let (t1, t2, body) = body_style(&mut s, None); + run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}})); + run(&mut s, "style.bind", json!({"style": body, "field": "fontWeight", "variable": "weight"})); + let face = |s: &Session, n: NodeId| { + let t = text_of(s, n); + (t.font_style, t.variations.get("wght").copied()) + }; + assert_eq!(face(&s, t1), ("Bold".to_string(), Some(700.0))); + assert_eq!(face(&s, t2), ("Regular".to_string(), Some(400.0))); + run(&mut s, "variable.set", json!({"variable": "weight", "mode": "Dark", "value": 650})); + assert_eq!(face(&s, t2), ("Bold".to_string(), Some(650.0))); + let e = s.execute("style.bind", &json!({"style": body, "field": "fontWeight", "variable": "bg"})).unwrap_err().to_string(); + assert!(e.contains("takes a number variable"), "{e}"); + // A style name bound afterwards replaces the number binding. + run(&mut s, "variable.createCollection", json!({"name": "Names", "modes": ["Only"]})); + run(&mut s, "variable.create", json!({"collection": "Names", "name": "face", "type": "string", "values": {"Only": "Light"}})); + run(&mut s, "style.bind", json!({"style": body, "field": "fontStyle", "variable": "face"})); + let bound = s.doc().unwrap().style(&body).unwrap().bound.clone(); + assert!(bound.contains_key("fontStyle") && !bound.contains_key("fontWeight"), "{bound:?}"); +} + +#[test] +fn font_weight_wins_over_font_style_when_a_file_carries_both() { + let mut s = Session::new(); + let (t1, _, body) = body_style(&mut s, None); + run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}})); + run(&mut s, "variable.createCollection", json!({"name": "Names", "modes": ["Only"]})); + run(&mut s, "variable.create", json!({"collection": "Names", "name": "face", "type": "string", "values": {"Only": "Light"}})); + let doc = s.doc_mut().unwrap(); + let (w, f) = (doc.variables.get("weight").unwrap().id.clone(), doc.variables.get("face").unwrap().id.clone()); + let st = doc.styles.iter_mut().find(|x| x.id == body).unwrap(); + st.bound.insert("fontStyle".into(), f); + st.bound.insert("fontWeight".into(), w); + crate::style_vars::after_load(doc); + assert_eq!(text_of(&s, t1).font_style, "Bold"); +} + +#[test] +fn a_number_bound_to_a_percent_line_height_applies_as_pixels() { + let mut s = Session::new(); + let (t1, _, body) = body_style(&mut s, Some(json!({"fontSize": 24, "lineHeight": "150%"}))); + run(&mut s, "variable.create", json!({"collection": "Theme", "name": "lh", "type": "number", "values": {"Light": 150, "Dark": 36}})); + run(&mut s, "style.bind", json!({"style": body, "field": "lineHeight", "variable": "lh"})); + let lh = |s: &Session| match &s.doc().unwrap().style(&body).unwrap().kind { + StyleKind::Text { style, .. } => style.line_height, + k => panic!("{k:?}"), + }; + assert_eq!(lh(&s), figcraft_doc::LineHeight::Pixels(150.0)); + assert_eq!(text_of(&s, t1).line_height, figcraft_doc::LineHeight::Pixels(150.0)); + assert_eq!(node(&s, t1).height, 150.0); + run(&mut s, "variable.set", json!({"variable": "lh", "mode": "Light", "value": 36})); + assert_eq!((lh(&s), node(&s, t1).height), (figcraft_doc::LineHeight::Pixels(36.0), 36.0)); +} + +#[test] +fn a_text_style_binding_round_trips_through_fig() { + let mut s = Session::new(); + let (_, _, body) = body_style(&mut s, None); + run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": 700, "Dark": 400}})); + for (field, var) in [("fontSize", "size"), ("lineHeight", "size"), ("fontWeight", "weight")] { + run(&mut s, "style.bind", json!({"style": body, "field": field, "variable": var})); + } + let doc = s.doc().unwrap().clone(); + let (bytes, warnings) = figcraft_format::fig::export(&doc, None).unwrap(); + let (mut back, _) = figcraft_format::fig::import(&bytes, "x").unwrap(); + crate::style_vars::after_load(&mut back); + let st = back.styles.iter().find(|x| x.name == "Body").unwrap(); + assert!(st.bound.contains_key("fontSize") && st.bound.contains_key("lineHeight"), "{st:?}"); + // The format has no weight field yet: the settled name comes back, not the binding (the file has no `wght` axis field yet). + assert!(warnings.iter().any(|w| w.contains("no field for")), "{warnings:?}"); + let StyleKind::Text { style, .. } = &st.kind else { panic!("{st:?}") }; + assert_eq!(style.font_style, "Bold"); +} diff --git a/crates/ui-egui/src/panels/variable_ui.rs b/crates/ui-egui/src/panels/variable_ui.rs index 53327440f..11345d13f 100644 --- a/crates/ui-egui/src/panels/variable_ui.rs +++ b/crates/ui-egui/src/panels/variable_ui.rs @@ -30,6 +30,9 @@ enum Target { Proto(crate::proto_editor::VarSlot), /// Bind it to `field` of the shared style `style`. Style { style: String, field: String }, + /// Bind it to the weight of the shared text style `style`: a string variable names the face + /// (`fontStyle`), a number one sets the weight (`fontWeight`). + StyleWeight { style: String }, } /// The open variable picker: what it does, the variable type (None: any), where it opens. @@ -197,6 +200,13 @@ pub fn open_style(ui: &Ui, style: &str, field: &str, kind: VariableType, at: Pos ui.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind: Some(kind), at })); } +/// Open the picker at `at` to bind a string (face name) or number (weight) variable to the weight of +/// the shared text style `style`. +pub fn open_style_weight(ui: &Ui, style: &str, at: Pos2) { + let target = Target::StyleWeight { style: style.to_string() }; + ui.data_mut(|d| d.insert_temp(picker_id(), Picker { target, kind: None, at })); +} + /// Where the open picker is on screen (clicks in it don't close what opened it). pub fn picker_rect(ctx: &egui::Context) -> Option { picker_open(ctx).then(|| ctx.memory(|m| m.area_rect(Id::unique("variable_picker_area")))).flatten() @@ -398,7 +408,7 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { let (scope, own) = match &p.target { Target::Bind { ids, field } => (ids.first().and_then(|id| figcraft_engine::cmd::variables::field_scope(&doc, *id, field)), None), Target::Alias { variable, .. } => (None, doc.variables.get_by_id(variable)), - Target::Proto(_) | Target::Style { .. } => (None, None), + Target::Proto(_) | Target::Style { .. } | Target::StyleWeight { .. } => (None, None), }; // A style's fields offer variables scoped to one of the properties the field sets. let style_scopes = match &p.target { @@ -406,7 +416,8 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { _ => None, }; // Prototypes and styles use this file's variables. - let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. }); + let local_only = matches!(p.target, Target::Proto(_) | Target::Style { .. } | Target::StyleWeight { .. }); + let weight_target = matches!(p.target, Target::StyleWeight { .. }); let field = match &p.target { Target::Bind { field, .. } => Some(field.as_str()), _ => None, @@ -417,6 +428,12 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { && (q.is_empty() || v.name.to_lowercase().contains(&q)) && scope.is_none_or(|s| v.in_scope(s)) && style_scopes.is_none_or(|list| list.iter().any(|s| v.in_scope(s))) + && (!weight_target + || match v.kind { + VariableType::String => v.in_scope("FONT_STYLE"), + VariableType::Number => v.in_scope("FONT_WEIGHT"), + _ => false, + }) && (lib.is_some() || own.is_none_or(|o| figcraft_engine::cmd::variables::alias_target(&doc.variables, o, &v.id).is_ok())) }; let groups = groups(&doc, &libs, keep); @@ -495,6 +512,11 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { (Target::Style { style, field }, _) => { app.run("style.bind", json!({"style": style, "field": field, "variable": pick.id})); } + (Target::StyleWeight { style }, _) => { + let number = app.session.ds().is_ok_and(|d| d.doc.variables.get_by_id(&pick.id).is_some_and(|v| v.kind == VariableType::Number)); + let field = if number { "fontWeight" } else { "fontStyle" }; + app.run("style.bind", json!({"style": style, "field": field, "variable": pick.id})); + } } close = true; } diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index 7c9254151..b1732984a 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -787,15 +787,16 @@ fn font_style_size_row(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSty let id = style.id.as_str(); let mut style_resp = None; ui.horizontal(|ui| { - match bound_name(doc, style, "fontStyle") { + let weight_field = if style.bound.contains_key("fontWeight") { "fontWeight" } else { "fontStyle" }; + match bound_name(doc, style, weight_field) { Some(name) => { let (r, _) = ui.allocate_exact_size(vec2(half, widgets::FIELD_H), Sense::hover()); text_binding( ui, TextBinding { style: id, - field: "fontStyle", - kind: VariableType::String, + field: weight_field, + kind: if weight_field == "fontWeight" { VariableType::Number } else { VariableType::String }, rect: r, name: &name, lead: widgets::Lead::Named(tr("Font style")), @@ -944,7 +945,7 @@ fn font_family_menu(ui: &Ui, id: &str, def: &TextStyle, family_resp: &Response, fn font_style_menu(id: &str, def: &TextStyle, style_resp: &Response, out: &mut Frame) { crate::right::menu_popup(style_resp).width(200.0).show(|ui| { if crate::right::menu_item(ui, tr("Apply variable"), "") { - crate::variable_ui::open_style(ui, id, "fontStyle", VariableType::String, style_resp.rect.left_bottom()); + crate::variable_ui::open_style_weight(ui, id, style_resp.rect.left_bottom()); ui.close(); return; } diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs index 9d14ac44a..d9e994fee 100644 --- a/crates/ui-egui/src/widgets/style_picker/tests.rs +++ b/crates/ui-egui/src/widgets/style_picker/tests.rs @@ -362,9 +362,19 @@ fn text_variables_session(bind: bool) -> crate::layers_drag_tests::Ui { ("Body tracking", "number", json!(1)), ("Body font", "string", json!("Inter")), ("Body weight", "string", json!("Bold")), + ("Body weight number", "number", json!(700)), ] { s.execute("variable.create", &json!({"collection": "Type", "name": name, "type": ty, "values": {"Base": value}})).unwrap(); } + for (name, scope) in [ + ("Body size", "FONT_SIZE"), + ("Body leading", "LINE_HEIGHT"), + ("Body tracking", "LETTER_SPACING"), + ("Body font", "FONT_FAMILY"), + ("Body weight number", "FONT_WEIGHT"), + ] { + s.execute("variable.update", &json!({"variable": name, "scopes": [scope]})).unwrap(); + } if bind { for (field, var) in [ ("fontSize", "Body size"), @@ -454,6 +464,41 @@ fn text_style_editor_shows_bound_fields_as_variables_and_detaches_them() { assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } +#[test] +fn the_weight_menu_binds_a_number_variable_as_font_weight() { + let mut u = text_variables_session(false); + let left = editor_left(&mut u); + let in_editor = |u: &mut crate::layers_drag_tests::Ui| -> Vec<(String, Pos2)> { + drawn_texts(u).into_iter().filter(|(_, p)| p.x > left && p.x < left + W).collect() + }; + let texts = in_editor(&mut u); + click(&mut u, text_at(&texts, "Regular")); + let texts = drawn_texts(&mut u); + click(&mut u, text_at(&texts, "Apply variable")); + assert!(crate::variable_ui::picker_open(&u.ctx), "the variable picker opens"); + let texts = drawn_texts(&mut u); + assert!(texts.iter().any(|(t, _)| t == "Body weight"), "string variables are offered"); + assert!(!texts.iter().any(|(t, _)| t == "Body font" || t == "Body leading"), "other fields' variables are not"); + click(&mut u, text_at(&texts, "Body weight number")); + assert_eq!(bound_to(&u, "fontWeight").as_deref(), Some("Body weight number")); + assert_eq!(bound_to(&u, "fontStyle"), None); + let font_style = || { + let doc = u.app.session.doc().unwrap(); + let Some(StyleKind::Text { style: def, .. }) = doc.style("Body").map(|s| s.kind.clone()) else { panic!("a text style") }; + def.font_style + }; + assert_eq!(font_style(), "Bold"); + // The chip shows it in the style slot, and its detach button clears the weight binding. + for _ in 0..2 { + u.frame(vec![]); + } + let texts = in_editor(&mut u); + let chip = text_at(&texts, "Body weight number"); + click(&mut u, Pos2::new(left + (W - 8.0) / 2.0 - 11.0, chip.y)); + assert_eq!(bound_to(&u, "fontWeight"), None, "the weight is detached"); + assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); +} + #[test] fn text_style_editor_binds_variables_from_its_fields() { let mut u = text_variables_session(false); diff --git a/docs/components-parity.md b/docs/components-parity.md index 8fcaea78a..3dc1c2eeb 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -63,7 +63,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Paint, text, effect, grid styles; style picker; text styles on character ranges | done | behaviour suite: `styles_behaviour_tests.rs`; a text style carries paragraph spacing and indent, the list style and the OpenType features beside the character properties (`paragraph` next to `style` in `style.list`): applied with it, moved by `style.update`, unlinking when edited, kept by `.fig` files (#1829) | | Editing a style updates its layers; detach and delete keep values; editing a styled property unlinks that style | done | an edited fill/stroke/effect/grid list or text chars unlink; applying a style replaces variables bound to what it sets; text styles on text layers only | | Organising styles: folders by `/` names, order, duplicate, rename folder, Local styles list | done | `style.move` (into a folder renames), `style.duplicate` (right below), `style.renameFolder`; the page's Local styles list by type and folder (drag to reorder, right-click) | -| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style, size, line height and letter spacing, the rest through the command | +| Variables inside styles (paint, effect, grid and text styles; each user resolves them in its own modes) | done | `style.bind`, `style_vars_tests`; the editor binds paint colours, shadow colours, blur radii, grid sizes and a text style's font, font style (a face name from a string variable, or a numeric weight from a number variable, which picks the nearest face and a variable font's `wght` axis), size, line height and letter spacing, the rest through the command; a bound line height is applied in pixels, as the reference tool does (measured 2026-11-10) | | Variables: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); colour calculations per mode (`{alpha}`: a colour variable with its alpha replaced, `{mix}`: a per-channel blend of two colour variables or literals; they compose, can't loop, and bound layers, modes and undo follow them; REST, CSS, Penpot and `.fig` exports write the colour they show in each of their collection's modes, other collections in their default modes; read-only in the panel, which can swap one for an alias or detach it; `variables_colour_tests.rs`), beyond the incumbent, whose variables only alias; scopes filter pickers only; several variables can be selected in the editor (Shift and Cmd/Ctrl click) and deleted, moved to a group or rescoped at once | | Modes: add, duplicate, rename, delete, set as default / reorder | done | layers on Auto follow a new default; explicit modes stay | | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values and colour calculations reading it keep the colour they show | @@ -79,6 +79,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-11-10 | minor | M7, #2174: a text style binds a number variable to its weight (`style.bind {field: "fontWeight"}`): the style takes the face nearest the number and sets the `wght` axis, so static and variable fonts both follow; a face-name (string) binding and a weight binding replace each other, and the weight wins in a file carrying both. The style editor's weight menu offers number variables beside string ones. A number bound to a line height is applied in pixels, as the reference tool does, which a test now pins. Numbers unchanged. Tests: `style_vars_tests`, `style_picker::tests`. | | 2026-11-05 | minor | M3, #2002: the Local variables window selects several variables, as the Layers panel selects layers. A click selects one row, Shift+click the range from the anchor through the rows shown, Cmd/Ctrl+click toggles a row, Escape or a click on the empty space below the rows clears; selected rows use the sidebar's selected-row colour. Right-clicking a row that is part of a selection of several offers Delete, Move to group and Scope (all supported properties, hidden from pickers, or only one scope) for the selected local variables, each sent as one `command.batch`, so one undo step; no engine command changed. Renaming and value edits stay single-row. Tests: `variables_select_tests` (headless UI). Grade unchanged | | 2026-11-02 | minor | M3, #1829: text styles carry paragraph spacing and indent, the list style and the OpenType features ("Supported in text styles": "Paragraph spacing and indentation", "Lists", "OpenType features", Create and apply text styles) beside the character properties: `style.create` takes them from the layer (or the selected paragraphs' list) or from a `value` (`paragraphSpacing`, `paragraphIndent`, `listStyle`, `features`), `style.apply` sets them (on a range, the list goes to the paragraphs touched; the rest is the layer's, as in Figma), `style.update` moves them on every linked layer, editing one on a styled text unlinks it as editing its font does, the style editor lists them, and a `.fig` file keeps them with the style (its sample layer). Grade unchanged. | | 2026-11-06 | patch | M3: a glass's light angle bound to a number past ±180 turns round to the same direction (270 is -90), as the angle drawn from it does, instead of stopping at 180. Tests: `effects_tests::a_bound_light_angle_turns_round_instead_of_stopping`. Grade unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 04b37e1f0..f7cdd4fbf 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1867,7 +1867,9 @@ properties with their values, its code and, with `path`, a PNG of it. The docume unbinds): `fill` or `fills//color` and `fills//stops//color` on a paint style, `effects//` on an effect style, `layoutGrids//` on a grid style, `fontSize`, `lineHeight`, -`letterSpacing`, `fontFamily`, `fontStyle` on a text style. Each layer using the style shows the +`letterSpacing`, `fontWeight` (numbers; a weight picks the family's face nearest it and sets the +`wght` axis of a variable font), `fontFamily`, `fontStyle` (strings; a bound weight and a bound style +name replace each other) on a text style. Each layer using the style shows the field in its own modes and stays linked; `style.list` gives a style's `bound` map. `effect.add {ids?, type? | effect?}` adds an effect (`dropShadow`, `innerShadow`, `layerBlur`, From 281918e2f275c8461c1d1c12ea1f0b86e737ab3b Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 13:09:35 -0500 Subject: [PATCH 04/12] M7: Text styles: a face name bound after a weight clears the axis; review fixes (#2174) A style's fontStyle binding now goes through set_font_style, so it drops the stale wght axis a weight binding left behind. Also: a test and doc line for the token_roles weight role, the apply-order comment, the weight chip opening the weight picker, junk weight values pinned, fixture assertions fixed, and the ROADMAP and parity status lines. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 reviewed; Sonnet 5.5 wrote at medium --- ROADMAP.md | 2 +- crates/engine/src/assets/style_vars.rs | 4 +++- crates/engine/src/assets/token_roles.rs | 2 +- crates/engine/src/style_vars_tests.rs | 18 +++++++++++++++ crates/engine/src/variables_io_tests.rs | 13 +++++++++++ crates/ui-egui/src/widgets/style_picker.rs | 2 ++ .../ui-egui/src/widgets/style_picker/tests.rs | 23 +++++++++++++++---- docs/components-parity.md | 2 +- 8 files changed, 57 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index cbab3235a..4d64ef3f9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~26 points and ~162–292 h away (one Opus 5.5 agent; ~33–67 h wall-clock with 5 agents) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-07 · **Change:** patch (a text style binds a number variable to its weight (#2174); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out 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; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); 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); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); 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); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; 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; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); 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, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a text style binds a number variable to its weight (#2174); the handoff mode reads Developer Mode in the interface, its ids and saved fields unchanged; the left rail's and tab strip's icons are larger, 20 pt (#553); Suggest auto layout adds nested auto layout frames by rows and columns and keeps the placement of the design, as the help center describes (#1823); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); the desktop UI takes a system face for Ethiopic, Devanagari, Gurmukhi, Gujarati, Tamil, Telugu, Kannada and Malayalam, Sinhala, Lao, Myanmar, Khmer, Armenian and Georgian as it did for Hebrew and Arabic, so a String variable's Amharic value draws in the panels instead of boxes (#2014); a layer's effect binding takes only a field its effect has; ; the boolean operation menu leads each entry with an original icon (#2003); glass takes one sample instead of its dispersion window where the colours split under 1/256 px; glass bends towards its core, and rounded rectangles work it out 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; glass effects take variables and keep their frost within 0–100; web host bridge: `?host=parent` or `data-host="page"` hands a page the app's files, documented in the hosting guide; a connector end moved with Cmd/Ctrl held goes on any part of an object, and stays there as the object moves or resizes; a tap on a plugin panel's button keeps what was typed in its fields; a Dash cap setting (none, round or square) on Dash and Custom strokes, drawn on both ends of every dash; library analytics list how many layers and files use each style and variable; the Controller menu names each button as PlayStation and Switch controllers do; widgets draw lines and SVG pictures (plugin API 6); a connector end dropped along a side of a shape attaches to that side (#1708); regression tests pin a rounded shape's one-sided stroke and SVG ids and font families without XML-forbidden characters (#1351, #1124); a damaged image is refused with a reason instead of showing as an empty box (#1474); an image copied in another app pastes again on the desktop, and in the browser for the first time (#492); SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; 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 Frame tool's Phone presets have the iPhone 17 family (#1691); Show in folder works in the Flatpak build and selects the file on Linux (#1701); a dashed line starts and ends with a half-length dash, on the canvas and in exports; the Pages, Layers and Assets search fields keep their text in the middle of the field (#1703); a board connector's path can be reshaped by dragging its path handles (#1710); boards have 16 more shapes (arrows, polygons, star, plus and flowchart symbols), in pickers that lay them out in a grid; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a stroke's Custom style takes a dash pattern of any length as a comma-separated list (#1681); a control request can set its own timeout, up to 10 minutes (`timeoutMs`; `figcraft-cli mcp --attach PORT --timeout SECONDS`) (#1696); gap 6: a widget opens its plugin's panel (plugin API 5), whose callbacks redraw it; the keyboard's Menu key opens the context menu, as Shift+F10 does; an Open link action can open in place of the prototype, with "Open in new tab" off (gap 15); a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; a live session's welcome sends the document compressed to clients that read it, about seven times smaller (#1665); a control request that times out says truthfully whether it was dropped or is still running and will take effect, and only screenshots and input blame the window (#1673); a board keeps earlier votes' results, listed newest first in Results and deleted one at a time; the object a stamp will stick to is outlined while the stamp tool is over it or a stamp is dragged over it, and board objects take no drops (gap 24); a server can list the files shared from it now for everyone who reaches it, Home ▸ Online shows them and their links carry no secret (`team_files`, part of #742); team library analytics count detached instances per component and file (gap 10); skipping the Open link notice by command is saved with the preferences at once (gap 15); a selected connector's bar on a board sets its start and end points, solid or dashed, its weight and any colour (#1596); cells copied from a spreadsheet paste onto a board as a table; a long layer name stays on one line in the Export button and the Dev Mode header, ending with an ellipsis, and no longer covers the Design panel (#1650); with hundreds of layers selected, the Design panel and the selection overlays stop going over the whole selection on every frame, so dragging 648 layers averages about 1.1 ms of UI per frame (#1571); plugin panels show pictures: an `image` control, base64 within 256 KB and 4,096 px a side, named by its label (gap 6); `.fig` connector labels without a saved snapshot are kept, centred on their line (gap 1); a drag moving many layers re-runs only what sits around them, not each layer's bindings, sizing, text and instance checks, on every move (#1571); damaged font tracking table offsets are checked instead of wrapping on 32-bit targets, and font indexing makes its existing offset bounds explicit (#1125); Bosnian (`bs`) draft catalog; text style dedupe is O(spans) via a last-two cache and fingerprint buckets (#1102); pasted layers whose image the clipboard lacks are named and show a placeholder that reads as missing (#1646); paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); progress docs cleaned of what merges left twice: rows merged into one, repeated status items removed, and `cargo xtask docs` (in `ci`) fails on a repeated table row or status item; a name typed for a variable or collection finds the one with that exact id before one named like it, and stored mode ids, font family variables and the panels' labels read exact ids (#1613); plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); `.fig` elbowed and curved connectors without a saved path are routed like board connectors instead of drawn straight (gap 1); every file a command reads whole by path is a regular file within a cap that fits it: documents 16 GiB, placed images 256 MiB, CLI scripts 1 GiB; `figcraft-cli connect publish` publishes a code repository's code connect mappings to a design file (gap 16); while a drag only moves a large selection, its outlines, box, slots, spacing and Design panel values are worked out once and shifted, not rebuilt on every move (#1571); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); an imported or pasted file finds an installed family under the other names its fonts give it, and with other case and spacing, instead of reporting it missing (#1292); a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); `.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; the server key is checked by comparing SHA-256 hashes of the presented and the kept key, as the deployment guide says (#1130); opening a file contacts no team library server it names until you check it (#1097); a hostile server can no longer grow a client's memory with made-up people, and a session remembers a bounded number of deleted layer ids (#1121); `doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); with a large selection, frames where nothing changed reuse the selection's outlines, box and slots and the Design panel's instance and position values instead of working them out again (#1571); team libraries and plugins larger than the 16 MiB download burst can be downloaded again, more slowly (#1089); a batch keeps one undo snapshot, not one per step (#1099); files nested 256 levels deep import on the browser's 1 MB stack (#1101); SVG and PDF exports count pattern sources and the renders they embed from the layer's depth, so one cap bounds a whole export (#1082); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); joining and leaving a file nobody is editing reads and writes nothing on the server: it stays loaded a minute after everyone leaves (#1074); opening a `.figcraft` file unpacks at most 6 GiB in all (2 GiB in the browser), every area together (#1079); hovering the canvas hit-tests once per pointer move, not on every frame, and moving an auto layout frame no longer lays out its contents again on every move (#1571); `.fig` instance expansion no longer walks variable nodes for every instance or copies every outer override row into each nested instance (#1077); `comment.people` lists names that were only @mentioned, and `comment.next` and `comment.prompt` carry the mentions too (#1415); grid auto-placement looks for free cells a 64-bit word at a time and skips what blocks it, with the same placements (#1083); 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); a recovery copy made while a version is previewed holds the file, not the version shown; a FigJam `.jam` file opens as a board (#1342); 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); share links can comment: a third access level beside edit and view, whose holder sees the file live and adds, replies to, reacts to and resolves comments but can't change the design; Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); `comment.list {mentions}` lists only the threads that @mention a name (#1415); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); figcraft-cli parses its arguments with clap, following RFC-0012 (#1396); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); the browser build publishes files to a server, so Share ▸ Online and Publish to server work there (#917); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); SVG import reads `color()` and `color-mix()` colours, with a note and a grey for those it can't (#1550); the portable Windows zip keeps its settings, recovery copies and plugins in `config\` beside the executable, starting with an installed copy's preferences; the window starts on other graphics when its first ones fail before the first frame: Direct3D 12 compiles with FXC and no device compiles wgpu's indirect-validation shaders, then Vulkan, OpenGL and lower requirements on the power-saving GPU (#965, #1438); Thai, Lao, Khmer and Burmese lines break between words (#1219); an AI agent attached over MCP shows where it works (#1416); a manual overlay's position can be dragged on the canvas (#405); one name for live sessions and comments: Preferences ▸ Your name, in the Join dialog too, kept in the browser, and a rename shows at once in the sessions you are in (#1380); requests that change a server's files have a budget per address, the server key's too, and join codes are found by their hash (#1075); the Join dialog shows a join from the click until its file is ready, with Cancel, and a failed join with Try again (#1381); SVG import draws each pattern tile once per scale, within the layer budget (#1080); Vietnamese letters typed with combining marks (decomposed) take one font that has every mark, instead of missing-glyph boxes beside the letter (#1478); comment.list reports each message's @mentions (#1415); mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); components can't contain their own instances, directly or through other components; `edit.duplicate` returns an id map with `map: true` (#995); the batches kept for reconnecting apps are bounded in bytes by default, and `figcraft-server` takes `max_log_mb` (#1069); MCP and control calls name their document (#998); sibling placement in live sessions searches instead of counting (#1065); an AI agent attached over MCP comments under its own name, with an Agent label (#1414); the instance-swap menus have a search field (#1338); a refused batch's fixes take time linear in the batch (#1063); the connector tool shows the connector it will make while dragging, ends snapping to attach points (#1387); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); a file's JPEG thumbnail can't crash the home screen (#1491's progressive JPEG check, now shared); `.fig` import: library text styles linked by asset key, and override rows restating a nested level (#1234); `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); Download a missing free font from the Missing fonts window (`font.download`, #754); the web build's wasm back under its 24 MiB gate (#1472); shadows of images with transparent pixels follow their alpha (#1289); Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); offline Penpot 2.x import/export (#1143); optional batch continue-on-error with per-step outcomes (#1386); the Design panel's sections on one grid (#858); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); images placed at their own size, 2x screenshots in points (#1360); the toolbar on one 32 pt grid, without its Resources button (#1210); M4: zero-length round-capped dashes draw dots; dash expansion uses the existing safety budget; extracted from #1143; Arabic draft catalog; scaled instances keep their scale when their main is rebuilt (#922); .fig embedded images are copied once per blob, not once per hash (#1059); page ruler guides survive save and reopen (#1476); colour picker LCH and OKLCH models; non-ASCII layer names transliterate in code export (#1235); branches in live sessions and branch history entries, gap 17; 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; Installed weights of an embedded font family (#1246); `.sketch` import, best effort (Sketch 48 and newer); 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, the iteration marks 〱–〵 and the halfwidth parentheses, with behaviour cases for each; Adobe Fonts found on macOS (#298); egui 0.37 hidden-capture deadline contract; Dev Mode measurements kept on the canvas and a read-only layers panel; boards: a board area row and progress entry; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) FigCraft is a clean-room, open-source, pure-Rust reimplementation of the Figma design workflow, native on macOS, Windows, Linux and FreeBSD and in the browser, fully drivable by agents through diff --git a/crates/engine/src/assets/style_vars.rs b/crates/engine/src/assets/style_vars.rs index fc7307403..0c8103635 100644 --- a/crates/engine/src/assets/style_vars.rs +++ b/crates/engine/src/assets/style_vars.rs @@ -113,6 +113,8 @@ pub fn from_layer(n: &Node, field: &str, kind: &StyleKind) -> BTreeMap StyleKind { let mut kind = st.kind.clone(); + // `bound` is a BTreeMap, so `fontStyle` applies before `fontWeight` and a weight wins when a file carries both + // (pinned by `font_weight_wins_over_font_style_when_a_file_carries_both`). for (key, var) in &st.bound { let Some(val) = vars.resolve(var, modes) else { continue }; match (&mut kind, list_binding(key), &val) { @@ -136,7 +138,7 @@ fn set_text_field(style: &mut TextStyle, key: &str, val: &VariableValue) { ("lineHeight", VariableValue::Number(x)) if x.is_finite() => style.line_height = LineHeight::Pixels(x.clamp(0.0, 100_000.0)), ("letterSpacing", VariableValue::Number(x)) if x.is_finite() => style.letter_spacing = LetterSpacing::Pixels(x.clamp(-10_000.0, 10_000.0)), ("fontFamily", VariableValue::String(f)) if !f.trim().is_empty() => style.font_family = f.clone(), - ("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.font_style = f.clone(), + ("fontStyle", VariableValue::String(f)) if !f.trim().is_empty() => style.set_font_style(f.clone()), ("fontWeight", VariableValue::Number(x)) if x.is_finite() => { // The face nearest the number (a static family) and the `wght` axis (a variable font). let w = x.clamp(1.0, 1000.0); diff --git a/crates/engine/src/assets/token_roles.rs b/crates/engine/src/assets/token_roles.rs index 648897fcc..dca86dfd6 100644 --- a/crates/engine/src/assets/token_roles.rs +++ b/crates/engine/src/assets/token_roles.rs @@ -1,6 +1,6 @@ //! What a file's number variables measure, so exported tokens carry the right unit: a radius, //! font size, spacing or other length is in pixels, opacity and font weight are bare numbers. -//! A number's role comes from the layer fields bound to it, else its scopes, and an alias target +//! A number's role comes from the layer fields bound to it (and a text style's weight), else its scopes, and an alias target //! with no role of its own takes the role of the variables that read it (the target of an opacity //! alias stays unitless). A number nothing tells about is a length: design tokens mostly are. diff --git a/crates/engine/src/style_vars_tests.rs b/crates/engine/src/style_vars_tests.rs index 52fed332a..536b27f6b 100644 --- a/crates/engine/src/style_vars_tests.rs +++ b/crates/engine/src/style_vars_tests.rs @@ -289,6 +289,24 @@ fn a_number_weight_picks_the_face_and_the_wght_axis() { run(&mut s, "style.bind", json!({"style": body, "field": "fontStyle", "variable": "face"})); let bound = s.doc().unwrap().style(&body).unwrap().bound.clone(); assert!(bound.contains_key("fontStyle") && !bound.contains_key("fontWeight"), "{bound:?}"); + // The name replaces the number's weight: no stale `wght` axis keeps drawing 700. + assert_eq!(face(&s, t1), ("Light".to_string(), None)); + assert_eq!(text_of(&s, t1).weight(), 300); +} + +#[test] +fn junk_weight_values_clamp_to_the_lightest_and_heaviest_face() { + let mut s = Session::new(); + let (t1, _, body) = body_style(&mut s, None); + run(&mut s, "variable.create", json!({"collection": "Theme", "name": "weight", "type": "number", "values": {"Light": -50, "Dark": 5000}})); + run(&mut s, "style.bind", json!({"style": body, "field": "fontWeight", "variable": "weight"})); + let face = |s: &Session| { + let t = text_of(s, t1); + (t.font_style, t.variations.get("wght").copied()) + }; + assert_eq!(face(&s), ("Thin".to_string(), Some(1.0))); + run(&mut s, "variable.set", json!({"variable": "weight", "mode": "Light", "value": 5000})); + assert_eq!(face(&s), ("Black".to_string(), Some(1000.0))); } #[test] diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index eba692802..fc032d007 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -562,6 +562,19 @@ fn css_export_gives_lengths_a_unit_and_keeps_opacity_bare() { assert!(css.contains("--card: 12px;") && css.contains("--fade: 0.5;") && css.contains("--half: 0.5;"), "{css}"); } +#[test] +fn css_export_keeps_a_style_weight_bare() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Brand"})); + ex(&mut s, "variable.create", json!({"collection": "Brand", "name": "weight", "type": "number", "value": 700})); + let t = idv(&ex(&mut s, "text.create", json!({"text": "Hi", "x": 0, "y": 0}))); + let st = ex(&mut s, "style.create", json!({"type": "text", "name": "Body", "from": t}))["id"].as_str().unwrap_or_default().to_string(); + ex(&mut s, "style.bind", json!({"style": st, "field": "fontWeight", "variable": "weight"})); + let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap_or_default().to_string(); + // A weight bound only to a text style is still a bare number, not a length. + assert!(css.contains("--weight: 700;") && !css.contains("700px"), "{css}"); +} + #[test] fn review_a_long_alias_chain_keeps_its_role_to_the_end() { let mut s = Session::new(); diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index b1732984a..bf1692dd5 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -726,6 +726,8 @@ struct TextBinding<'a> { fn text_binding(ui: &mut Ui, binding: TextBinding<'_>, out: &mut Frame) { let TextBinding { style, field, kind, rect, name, lead } = binding; match widgets::bound_field(ui, Id::unique(("ts_bound", style, field)), lead, rect, name) { + // The weight slot takes a face name or a number, so its chip opens the picker that offers both. + Some(widgets::BindEvent::Open(at)) if matches!(field, "fontWeight" | "fontStyle") => crate::variable_ui::open_style_weight(ui, style, at), Some(widgets::BindEvent::Open(at)) => crate::variable_ui::open_style(ui, style, field, kind, at), Some(widgets::BindEvent::Detach) => out.act = Some(Act::Unbind(style.to_string(), field.to_string())), None => {} diff --git a/crates/ui-egui/src/widgets/style_picker/tests.rs b/crates/ui-egui/src/widgets/style_picker/tests.rs index d9e994fee..984f6c8d4 100644 --- a/crates/ui-egui/src/widgets/style_picker/tests.rs +++ b/crates/ui-egui/src/widgets/style_picker/tests.rs @@ -488,14 +488,27 @@ fn the_weight_menu_binds_a_number_variable_as_font_weight() { def.font_style }; assert_eq!(font_style(), "Bold"); - // The chip shows it in the style slot, and its detach button clears the weight binding. + // The chip shows it in the style slot; clicking it lists the string variable too, and picking that + // replaces the number: fontStyle is bound and fontWeight is not. for _ in 0..2 { u.frame(vec![]); } let texts = in_editor(&mut u); - let chip = text_at(&texts, "Body weight number"); + click(&mut u, text_at(&texts, "Body weight number")); + assert!(crate::variable_ui::picker_open(&u.ctx), "the chip opens the weight picker"); + let texts = drawn_texts(&mut u); + assert!(texts.iter().any(|(t, _)| t == "Body weight"), "the string variable is offered beside the number one"); + click(&mut u, text_at(&texts, "Body weight")); + assert_eq!(bound_to(&u, "fontStyle").as_deref(), Some("Body weight")); + assert_eq!(bound_to(&u, "fontWeight"), None, "the name replaced the number"); + // The chip's detach button clears the binding. + for _ in 0..2 { + u.frame(vec![]); + } + let texts = in_editor(&mut u); + let chip = text_at(&texts, "Body weight"); click(&mut u, Pos2::new(left + (W - 8.0) / 2.0 - 11.0, chip.y)); - assert_eq!(bound_to(&u, "fontWeight"), None, "the weight is detached"); + assert_eq!(bound_to(&u, "fontStyle"), None, "the face name is detached"); assert!(u.app.ui.last_error.is_none(), "{:?}", u.app.ui.last_error); } @@ -522,7 +535,7 @@ fn text_style_editor_binds_variables_from_its_fields() { click(&mut u, Pos2::new(x, y)); assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens"); let texts = drawn_texts(&mut u); - assert!(!texts.iter().any(|(t, _)| t == "Body font"), "{field} offers number variables only"); + assert!(!texts.iter().any(|(t, _)| t == "Body weight"), "{field} offers number variables only"); click(&mut u, text_at(&texts, name)); assert_eq!(bound_to(&u, field).as_deref(), Some(name)); } @@ -536,7 +549,7 @@ fn text_style_editor_binds_variables_from_its_fields() { assert!(crate::variable_ui::picker_open(&u.ctx), "{field}: the variable picker opens"); let texts = drawn_texts(&mut u); // "Body size" shows once, on the size field's chip: the picker doesn't list it. - assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} offers string variables only"); + assert_eq!(texts.iter().filter(|(t, _)| t == "Body size").count(), 1, "{field} does not offer the size variable"); click(&mut u, text_at(&texts, name)); assert_eq!(bound_to(&u, field).as_deref(), Some(name)); } diff --git a/docs/components-parity.md b/docs/components-parity.md index 3dc1c2eeb..3f5f78d4f 100644 --- a/docs/components-parity.md +++ b/docs/components-parity.md @@ -1,6 +1,6 @@ # Components, variants, styles and variables parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-06 · **Change:** minor (the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-10 · **Change:** minor (a text style binds a number variable to its weight (#2174); the Local variables window selects several variables (click, Shift+click for a range, Cmd/Ctrl+click to toggle, Escape to clear) and delete, move to a group and scope act on the selected local variables in one undo step (#2002); text styles carry paragraph spacing and indent, the list style and the OpenType features, as the Supported in text styles table of Create and apply text styles says (#1829); a layer's effect binding takes only a field its effect has; Go to main component on an instance of a library component opens the library file in a tab and selects the main there; glass effects take variables; Delete on a layer inside an instance hides it as an override instead of being refused, as the help center says: "Figma will only toggle the layer's visibility instead of removing it"; library analytics list how many layers and files use each style and variable; SwiftUI and Compose code names a bound variable by its iOS or Android code syntax; library analytics count insertions and show insertions and detachments over the past 30 days, 90 days or year; a collection of this file can be extended (Extend collection on its row, `variable.extendCollection`), its extension following its edits at once, and extended collections extend again in the file; a typed or stored variable, collection or mode id names only the object with that id (#1613); colour variables can be calculated from other colours per mode — `{alpha}` replaces a colour's alpha, `{mix}` blends two — and follow them (#1280); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); a component set's property rows fit the 240 px right panel: variant values wrap two to a line there and every + and − stays in view; opening a file contacts no team library server it names until you check it (#1097); an explicit variables file path (`variable.import`, the `variables` of `file.open` and `file.import`) is refused past 256 MiB or when it isn't a regular file, as the sibling `variables.json` already was (part of #1114); component properties keep the order they were made in when one is renamed, and Make slot asks for the slot's name first (#1536, #1537); components can't contain their own instances, directly or through others (#1084); the instance-swap menus have a search field (#1338); an instance-swap property's default menu scrolls (#1338); variables on a text style's fields from the style editor; instance-swap properties in the component playground; duplicating or Alt-dragging a main component makes an instance of it (#892); a bound colour variable's alpha applies once (#802); variables import reads `variables` outside `meta` and warns when it imports none (#790); variant properties reorder by drag; library files saved right after a read are read again; Hide when publishing note corrected; size and resizing overrides of layers inside instances (#731); variables export in the browser as a download; core editor leftovers: component playground, variables inside styles; layers and assets panels behaviour suite and fixes; extended collections tail; styles and variables behaviour suite and fixes; components behaviour suite and fixes; team libraries on the server, library usage and swapping a library; extended collections, variables import/export, gradient stops and variant properties bound to variables) · **Target:** Figma Design (desktop app 126.10.7) Design systems are why teams adopt Figma. **Components: breadth ~96%, ready ~72%. Styles and variables: breadth ~95%, ready ~76% (estimated).** QA'd with a synthetic design system of ~300 From ebcaa58c4f899c93d8d30d84636725532bf62bf0 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Tue, 10 Nov 2026 15:55:41 -0500 Subject: [PATCH 05/12] M10: Design panel: section header icons draw at the panel's icon size (#2227) Section header buttons painted their glyph in a 12 pt box inside the 24 pt button while icon_button_at used 14 pt. Both now use icon_glyph_rect (ICON_BUTTON_INSET 5 pt), as do the 24 pt Close and Swap buttons in the assets and prototype panels. Test compares the painted Eye bounds of a header and an icon button. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/ui-egui/src/header_icon_size_tests.rs | 45 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/assets_panel.rs | 4 +- crates/ui-egui/src/panels/proto_panel.rs | 2 +- crates/ui-egui/src/widgets.rs | 13 +++++- docs/ui-parity.md | 3 +- 6 files changed, 63 insertions(+), 6 deletions(-) create mode 100644 crates/ui-egui/src/header_icon_size_tests.rs diff --git a/crates/ui-egui/src/header_icon_size_tests.rs b/crates/ui-egui/src/header_icon_size_tests.rs new file mode 100644 index 000000000..35db827df --- /dev/null +++ b/crates/ui-egui/src/header_icon_size_tests.rs @@ -0,0 +1,45 @@ +//! A section header's icon buttons (Appearance, Fill, Stroke, Effects...) draw their glyphs at the +//! same size as the panel's other 24 pt icon buttons (#2227). + +use egui::{Context, Rect, pos2, vec2}; + +use crate::icons::Icon; +use crate::widgets::{icon_button_at, icon_glyph_rect, section_header}; + +/// Bounding box of everything painted in one frame of `add`. +fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect { + let ctx = Context::default(); + let mut add = Some(add); + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + if let Some(f) = add.take() { + f(ui); + } + }); + out.textures_delta.clear(); + out.shapes + .iter() + .filter(|s| matches!(s.shape, egui::Shape::Path(_) | egui::Shape::Mesh(_) | egui::Shape::LineSegment { .. } | egui::Shape::Circle(_))) + .map(|s| s.shape.visual_bounding_rect()) + .fold(Rect::NOTHING, |a, b| a.union(b)) +} + +#[test] +fn header_eye_draws_as_large_as_an_icon_buttons_eye() { + let header = painted_box(|ui| { + section_header(ui, "Appearance", &[(Icon::Eye, "Hide")], false); + }); + let button = painted_box(|ui| { + let r = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0)); + icon_button_at(ui, ui.scope_id().with("b"), r, Icon::Eye, "Hide"); + }); + assert!(header.is_finite() && button.is_finite(), "{header:?} {button:?}"); + assert!((header.width() - button.width()).abs() < 0.5, "widths {} vs {}", header.width(), button.width()); + assert!((header.height() - button.height()).abs() < 0.5, "heights {} vs {}", header.height(), button.height()); +} + +#[test] +fn glyph_rect_is_the_shared_inset_of_the_button() { + let b = Rect::from_min_size(pos2(0.0, 0.0), vec2(24.0, 24.0)); + assert_eq!(icon_glyph_rect(b).size(), vec2(14.0, 14.0)); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 60c68f526..9216c2fff 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -122,6 +122,8 @@ mod guide_release_tests; #[cfg(test)] mod guide_snap_tests; #[cfg(test)] +mod header_icon_size_tests; +#[cfg(test)] mod home_recents_tests; #[cfg(test)] mod home_tests; diff --git a/crates/ui-egui/src/panels/assets_panel.rs b/crates/ui-egui/src/panels/assets_panel.rs index a63c10c9a..0da5a9bee 100644 --- a/crates/ui-egui/src/panels/assets_panel.rs +++ b/crates/ui-egui/src/panels/assets_panel.rs @@ -1157,7 +1157,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { if xr.hovered() { ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), x.shrink(6.0), Icon::Close, t.icon); + icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(x), Icon::Close, t.icon); if xr.clicked() { remove = Some(l.id.clone()); } @@ -1169,7 +1169,7 @@ fn enabled_libraries(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { if sr.hovered() { ui.painter().rect_filled(sw, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), sw.shrink(6.0), Icon::Swap, t.icon); + icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sw), Icon::Swap, t.icon); crate::right::menu_popup(&sr).show(|ui| { for other in doc.libraries.enabled.iter().filter(|o| o.id != l.id) { if crate::right::menu_item(ui, &trf("Swap to {name}", &[("name", &other.name)]), "") { diff --git a/crates/ui-egui/src/panels/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs index d7728cbdc..fa9f2585c 100644 --- a/crates/ui-egui/src/panels/proto_panel.rs +++ b/crates/ui-egui/src/panels/proto_panel.rs @@ -684,7 +684,7 @@ fn editor_popover(app: &mut FigApp, ctx: &egui::Context, popover: InteractionPop if xr.hovered() { ui.painter().rect_filled(x, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), x.shrink(6.0), Icon::Close, t.icon); + icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(x), Icon::Close, t.icon); if xr.clicked() { close = true; } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index eae46fd81..12cc02f14 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1013,7 +1013,7 @@ pub fn section_header_resp(ui: &mut Ui, title: &str, buttons: &[(Icon, &str)], d if resp.hovered() { ui.painter().rect_filled(r, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), r.shrink(6.0), *icon, t.icon); + icons::paint(ui.painter(), icon_glyph_rect(r), *icon, t.icon); if resp.clicked() { out = Some(i); } @@ -1045,6 +1045,15 @@ pub fn tight_end_n(ui: &mut Ui, count: usize, add: impl FnOnce(&mut Ui) -> R) add(&mut child) } +/// Inset of the glyph inside a 24 pt icon button: a 14 pt glyph, the size every icon button in the +/// panels draws at (section header buttons included). +pub const ICON_BUTTON_INSET: f32 = 5.0; + +/// The rect a 24 pt icon button paints its glyph in. +pub fn icon_glyph_rect(button: Rect) -> Rect { + button.shrink(ICON_BUTTON_INSET) +} + /// An icon button at `rect` (hover highlight, tooltip); true when clicked. pub fn icon_button_at(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> bool { let t = Tokens::get(ui.ctx()); @@ -1052,7 +1061,7 @@ pub fn icon_button_at(ui: &mut Ui, id: Id, rect: Rect, icon: Icon, tip: &str) -> if resp.hovered() { ui.painter().rect_filled(rect, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), rect.shrink(5.0), icon, t.icon); + icons::paint(ui.painter(), icon_glyph_rect(rect), icon, t.icon); resp.clicked() } diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 463be3b47..9412fb06f 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-04 · **Change:** minor (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 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: @@ -200,6 +200,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) | | 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. | | 2026-11-02 | minor | M1, #1836: pick select. Tab while the keyboard selection box shows, or Alt/Ctrl+Space again, puts a pink outline on the first selected layer (the page's top layer with nothing selected); Tab and Shift+Tab move it between sibling layers as the Layers panel lists them, Enter into a frame's top child, Shift+Enter out to the parent (never the page), Space selects the layer under it, Escape or a press on the canvas ends it; the chord again while the pick cursor shows brings the box back (Accessibility at Figma). The control channel's `ui.key` sends the box's and the pick cursor's keys to them too. One documented row more (135, 134 bound); four gesture rows. Tests: `layers_keyboard_tests::tab_switches_the_selection_box_to_pick_select`. | +| 2026-11-10 | patch | M10, #2227: the Design panel's section header buttons (Appearance, Typography, Fill, Stroke, Effects) draw their icons at the size of the panel's other 24 pt icon buttons, 14 pt, not 12 pt; the Close and Swap buttons of the assets and prototype panels share the inset. Test: `header_icon_size_tests`. Numbers unchanged. | | 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. | | 2026-11-02 | minor | M8, #1875: a fill per region. With regions of a vector selected in edit mode (a press inside one with the Move tool, `vector.edit.select {regions}`), the Fill section lists the regions' own fills (the layer's for a region that has none; mixed content when they differ) and its rows and the colour picker edit them: `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer go to the selected regions' stacks (`Region.fills`, what the renderer already draws), the layer's fills and fill style untouched; `regions: [index, ...]` names them on any vector layer (Edit vector layers). Tests: `regions_tests::the_fill_commands_edit_the_selected_regions`. Numbers unchanged. | | 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. | From 19c8e01811824d8f5f99730e38b57c323d84557f Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 00:08:06 -0500 Subject: [PATCH 06/12] M10: Design panel: 24 pt icon buttons draw 16 pt glyphs, as a fill row does (#2227) A live check showed the header's eye still smaller than a fill row's: `icons::button`, which draws the fill rows, paints the icons' native 16 px glyph in a 24 pt button, while the shared inset gave 14. The inset is now 4, so section headers, `icon_button_at` and the panels' Close and Swap buttons draw 16 pt glyphs like the rest. The test compares the header's eye with both `icon_button_at` and `icons::button`. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- crates/ui-egui/src/header_icon_size_tests.rs | 21 +++++++++++++------- crates/ui-egui/src/widgets.rs | 7 ++++--- docs/ui-parity.md | 2 +- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/crates/ui-egui/src/header_icon_size_tests.rs b/crates/ui-egui/src/header_icon_size_tests.rs index 35db827df..917276c2a 100644 --- a/crates/ui-egui/src/header_icon_size_tests.rs +++ b/crates/ui-egui/src/header_icon_size_tests.rs @@ -3,7 +3,7 @@ use egui::{Context, Rect, pos2, vec2}; -use crate::icons::Icon; +use crate::icons::{self, Icon}; use crate::widgets::{icon_button_at, icon_glyph_rect, section_header}; /// Bounding box of everything painted in one frame of `add`. @@ -24,22 +24,29 @@ fn painted_box(add: impl FnOnce(&mut egui::Ui)) -> Rect { .fold(Rect::NOTHING, |a, b| a.union(b)) } +/// The header's eye, `icon_button_at`'s and a fill row's (`icons::button`, the reporter's +/// reference) are drawn at one size. #[test] -fn header_eye_draws_as_large_as_an_icon_buttons_eye() { +fn header_eye_draws_as_large_as_a_fill_rows_eye() { let header = painted_box(|ui| { section_header(ui, "Appearance", &[(Icon::Eye, "Hide")], false); }); - let button = painted_box(|ui| { + let at = painted_box(|ui| { let r = Rect::from_min_size(pos2(20.0, 20.0), vec2(24.0, 24.0)); icon_button_at(ui, ui.scope_id().with("b"), r, Icon::Eye, "Hide"); }); - assert!(header.is_finite() && button.is_finite(), "{header:?} {button:?}"); - assert!((header.width() - button.width()).abs() < 0.5, "widths {} vs {}", header.width(), button.width()); - assert!((header.height() - button.height()).abs() < 0.5, "heights {} vs {}", header.height(), button.height()); + let row = painted_box(|ui| { + icons::button(ui, Icon::Eye, 24.0, false, "Toggle visibility"); + }); + for (name, b) in [("icon_button_at", at), ("icons::button", row)] { + assert!(header.is_finite() && b.is_finite(), "{header:?} {b:?}"); + assert!((header.width() - b.width()).abs() < 0.5, "{name}: widths {} vs {}", header.width(), b.width()); + assert!((header.height() - b.height()).abs() < 0.5, "{name}: heights {} vs {}", header.height(), b.height()); + } } #[test] fn glyph_rect_is_the_shared_inset_of_the_button() { let b = Rect::from_min_size(pos2(0.0, 0.0), vec2(24.0, 24.0)); - assert_eq!(icon_glyph_rect(b).size(), vec2(14.0, 14.0)); + assert_eq!(icon_glyph_rect(b).size(), vec2(16.0, 16.0)); } diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 12cc02f14..79d0777f1 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1045,9 +1045,10 @@ pub fn tight_end_n(ui: &mut Ui, count: usize, add: impl FnOnce(&mut Ui) -> R) add(&mut child) } -/// Inset of the glyph inside a 24 pt icon button: a 14 pt glyph, the size every icon button in the -/// panels draws at (section header buttons included). -pub const ICON_BUTTON_INSET: f32 = 5.0; +/// Inset of the glyph inside a 24 pt icon button: a 16 pt glyph, the icons' native size and what +/// `icons::button` draws (a fill row's eye), so every 24 pt icon button in the panels matches it +/// (section header buttons included). +pub const ICON_BUTTON_INSET: f32 = 4.0; /// The rect a 24 pt icon button paints its glyph in. pub fn icon_glyph_rect(button: Rect) -> Rect { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9412fb06f..b3f6506ac 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -200,7 +200,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) | | 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. | | 2026-11-02 | minor | M1, #1836: pick select. Tab while the keyboard selection box shows, or Alt/Ctrl+Space again, puts a pink outline on the first selected layer (the page's top layer with nothing selected); Tab and Shift+Tab move it between sibling layers as the Layers panel lists them, Enter into a frame's top child, Shift+Enter out to the parent (never the page), Space selects the layer under it, Escape or a press on the canvas ends it; the chord again while the pick cursor shows brings the box back (Accessibility at Figma). The control channel's `ui.key` sends the box's and the pick cursor's keys to them too. One documented row more (135, 134 bound); four gesture rows. Tests: `layers_keyboard_tests::tab_switches_the_selection_box_to_pick_select`. | -| 2026-11-10 | patch | M10, #2227: the Design panel's section header buttons (Appearance, Typography, Fill, Stroke, Effects) draw their icons at the size of the panel's other 24 pt icon buttons, 14 pt, not 12 pt; the Close and Swap buttons of the assets and prototype panels share the inset. Test: `header_icon_size_tests`. Numbers unchanged. | +| 2026-11-10 | patch | M10, #2227: the Design panel's section header buttons (Appearance, Typography, Fill, Stroke, Effects) draw their icons at the size of the panel's other 24 pt icon buttons, 16 pt (the icons' native size, as a fill row's eye), not 12 pt; `icon_button_at` goes from 14 to 16 pt with them; the Close and Swap buttons of the assets and prototype panels share the inset. Test: `header_icon_size_tests`. Numbers unchanged. | | 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. | | 2026-11-02 | minor | M8, #1875: a fill per region. With regions of a vector selected in edit mode (a press inside one with the Move tool, `vector.edit.select {regions}`), the Fill section lists the regions' own fills (the layer's for a region that has none; mixed content when they differ) and its rows and the colour picker edit them: `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer go to the selected regions' stacks (`Region.fills`, what the renderer already draws), the layer's fills and fill style untouched; `regions: [index, ...]` names them on any vector layer (Edit vector layers). Tests: `regions_tests::the_fill_commands_edit_the_selected_regions`. Numbers unchanged. | | 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. | From 985231858c5f63806793e1dd81d82341871147bf Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 00:38:03 -0500 Subject: [PATCH 07/12] M10: Design panel: Swap orientation and the quick actions use the shared icon size (#2227) The frame's Swap orientation button and the selection's quick-action buttons (24 pt) still painted their glyphs at the old 14 pt inset beside Fit to contents' 16 pt; they use `icon_glyph_rect` now. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- crates/ui-egui/src/panels/right.rs | 4 ++-- docs/ui-parity.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index bba57c778..722d52e9f 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -453,7 +453,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if sresp.hovered() { ui.painter().rect_filled(sr, CornerRadius::same(5), t.hover); } - icons::paint(ui.painter(), sr.shrink(5.0), Icon::Rotate, t.icon); + icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(sr), Icon::Rotate, t.icon); if sresp.clicked() { app.run("frame.swapOrientation", json!({"ids": sel})); } @@ -505,7 +505,7 @@ fn header(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { ("bool.union", Some(op)) => crate::layers::op_icon(op), _ => icon, }; - icons::paint(ui.painter(), br.shrink(5.0), icon, t.icon); + icons::paint(ui.painter(), crate::widgets::icon_glyph_rect(br), icon, t.icon); if cmd == "bool.union" { menu_popup(&bresp).width(180.0).show(|ui| { for (op, c) in [ diff --git a/docs/ui-parity.md b/docs/ui-parity.md index b3f6506ac..dd24d0d0c 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -200,7 +200,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) | | 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. | | 2026-11-02 | minor | M1, #1836: pick select. Tab while the keyboard selection box shows, or Alt/Ctrl+Space again, puts a pink outline on the first selected layer (the page's top layer with nothing selected); Tab and Shift+Tab move it between sibling layers as the Layers panel lists them, Enter into a frame's top child, Shift+Enter out to the parent (never the page), Space selects the layer under it, Escape or a press on the canvas ends it; the chord again while the pick cursor shows brings the box back (Accessibility at Figma). The control channel's `ui.key` sends the box's and the pick cursor's keys to them too. One documented row more (135, 134 bound); four gesture rows. Tests: `layers_keyboard_tests::tab_switches_the_selection_box_to_pick_select`. | -| 2026-11-10 | patch | M10, #2227: the Design panel's section header buttons (Appearance, Typography, Fill, Stroke, Effects) draw their icons at the size of the panel's other 24 pt icon buttons, 16 pt (the icons' native size, as a fill row's eye), not 12 pt; `icon_button_at` goes from 14 to 16 pt with them; the Close and Swap buttons of the assets and prototype panels share the inset. Test: `header_icon_size_tests`. Numbers unchanged. | +| 2026-11-10 | patch | M10, #2227: the Design panel's section header buttons (Appearance, Typography, Fill, Stroke, Effects) draw their icons at the size of the panel's other 24 pt icon buttons, 16 pt (the icons' native size, as a fill row's eye), not 12 pt; `icon_button_at` goes from 14 to 16 pt with them; the Close and Swap buttons of the assets and prototype panels and the Design panel's Swap orientation and quick-action buttons share the inset. Test: `header_icon_size_tests`. Numbers unchanged. | | 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. | | 2026-11-02 | minor | M8, #1875: a fill per region. With regions of a vector selected in edit mode (a press inside one with the Move tool, `vector.edit.select {regions}`), the Fill section lists the regions' own fills (the layer's for a region that has none; mixed content when they differ) and its rows and the colour picker edit them: `fill.set`, `fill.add`, `fill.remove`, `fill.move` and `fill.paste` on the edited layer go to the selected regions' stacks (`Region.fills`, what the renderer already draws), the layer's fills and fill style untouched; `regions: [index, ...]` names them on any vector layer (Edit vector layers). Tests: `regions_tests::the_fill_commands_edit_the_selected_regions`. Numbers unchanged. | | 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. | From 8e6c342f13a8da5e78c60c713b82681761451899 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 02:01:32 -0500 Subject: [PATCH 08/12] M4: a rounded vector's fill and outline follow every corner after a Pen bridge (#1951) The reporter's file has a corner cut off by a segment drawn first, with the corner point then deleted with Delete. The region's loop is left as the open chain of the other four segments, and the cutting segment, which joins the chain's two ends, is in no loop. The stroke follows the network, so it rounds both ends of that segment; the fill and the selection outline follow the loop. Cause: VectorNetwork::rounded_smoothed rebuilds each loop by inserting a corner's arcs where the loop arrives at the point, so the point the loop starts at is never cut (in a closed loop it is arrived at last), and the open loop's closing edge ran straight to it. closing_segment now finds the segment joining an open loop's two ends when no loop holds it, and rounding walks it as the loop's last segment, so every corner of the fill and outline matches the stroke. Closed loops and the cases #1991 fixed are untouched. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/doc/src/tests.rs | 29 +++++++++++++++++++++++++++++ crates/doc/src/vector.rs | 24 ++++++++++++++++++++++-- docs/target-app-parity.md | 5 +++-- 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 75dbe7cf1..0d4efd6ec 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -902,6 +902,35 @@ fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() { } } +/// The reporter's network (#1951): a corner cut off by a segment drawn before the corner point was +/// deleted, so the region's loop is the open chain of the other four segments and the cutting +/// segment, joining the chain's two ends, is in no loop. Rounded, the fill and the region trace +/// the stroke's outline, cut by an arc at the chain's ends as at every other point. +#[test] +fn a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment() { + use figcraft_geom::BezPath; + use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; + let corners = [Point::new(0.0, 0.0), Point::new(525.0, 525.0), Point::new(0.0, 525.0), Point::new(333.0, 0.0), Point::new(525.0, 183.0)]; + let mut net = VectorNetwork::from_points(&corners, false); + net.segments = [(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)].iter().map(|&(a, b)| Segment::line(a, b)).collect(); + net.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, paint_space: None }); + for v in &mut net.vertices { + v.radius = 125.0; + } + let keeps_off = |path: &BezPath, p: Point| path.segments().map(|s| s.nearest(p, 1e-9).distance_sq.sqrt()).fold(f64::INFINITY, f64::min); + let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::(); + let stroke = net.stroke_path(); + let fill = net.fill_path(); + let region = net.rounded().unwrap().region_path(0).unwrap(); + for (i, p) in corners.iter().enumerate() { + assert!(keeps_off(&stroke, *p) > 2.0, "the stroke is cut at point {i}"); + assert!(keeps_off(&fill, *p) > 2.0, "the fill runs into point {i} ({:.2} from it)", keeps_off(&fill, *p)); + assert!(keeps_off(®ion, *p) > 2.0, "the region runs into point {i} ({:.2} from it)", keeps_off(®ion, *p)); + } + assert!((length(&fill) - length(&stroke)).abs() < 1e-6, "fill {} and stroke {} trace the same outline", length(&fill), length(&stroke)); + assert!((length(®ion) - length(&stroke)).abs() < 1e-6, "region {} and stroke {} trace the same outline", length(®ion), length(&stroke)); +} + #[test] fn stored_variable_ids_resolve_only_their_exact_collection() { let mut vars = crate::proto_expr_tests::vars(); diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index f53ad4408..b1b45423b 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -478,9 +478,10 @@ impl VectorNetwork { (Some(first), _) => first.a, _ => 0, }; - let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len())); + let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()) + 1); let mut cur = start_v; - for &x in lp.iter() { + let closing = self.closing_segment(lp, start_v); + for &x in lp.iter().chain(closing.iter()) { rebuilt.push(x); let Some(s) = self.segments.get(x) else { continue }; cur = if s.a == cur { s.b } else { s.a }; @@ -497,6 +498,25 @@ impl VectorNetwork { Some(out) } + /// The segment that joins the two ends of an open loop `lp` (walked from `start_v`) when it is + /// in no loop of its own: a corner cut off by a segment drawn before the corner point was + /// deleted leaves the loop open there, and the stroke follows that segment. Rounding walks it + /// as the loop's last segment, so the points at the loop's ends are cut by arcs as the stroke's + /// are (#1951). + fn closing_segment(&self, lp: &[usize], start_v: usize) -> Option { + let mut cur = start_v; + for &x in lp { + let s = self.segments.get(x)?; + cur = if s.a == cur { s.b } else { s.a }; + } + if cur == start_v { + return None; + } + let in_loop = |i: usize| self.regions.iter().flat_map(|r| r.loops.iter().flatten()).any(|&x| x == i); + let ends = |s: &Segment| (s.a == cur && s.b == start_v) || (s.b == cur && s.a == start_v); + self.segments.iter().position(ends).filter(|&i| !in_loop(i)) + } + /// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments /// run on smoothly or fold back. With `smoothing`, a point between two straight segments that /// are both `on_loop` gets a smoothed corner. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 031521f51..cf4955d1f 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-06 · **Change:** minor (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-06 · **Change:** minor (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, also when the loop was left open at a segment drawn before the corner was deleted (#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 @@ -246,7 +246,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and merge (joining the two ends of a path broken open by a deleted point closes it again, so its - fill follows the new segment, #1951), delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder + fill follows the new segment, and a rounded loop left open where a segment joins its ends is cut by arcs at them too, #1951), delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder (⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept), variable width strokes (width profile presets with previews in the stroke settings, the Variable width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete; @@ -655,6 +655,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | 2026-11-03 | minor | Traditional Chinese (Taiwan) (`zh-Hant`) draft catalog: 1,975 UI strings, 641 command labels and 31 menu wordings, clean-room from the English source; zh-TW, zh-HK, zh-MO and zh-Hant read it, explicit Hans stays Simplified; Noto Sans CJK TC then Taiwan system faces; no bundled font; native review pending. Numbers unchanged | | 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-11 | patch | M4, #1951 (reporter's file, v0.17.0): a corner cut off by a segment drawn first, the corner point then deleted with Delete, leaves the region's loop as the open chain of the other segments, the cutting segment (which joins the chain's two ends) in no loop; rounded, the loop's first point was never cut by an arc (rounding only adds arcs at points the loop arrives at) and the closing edge ran straight, so the fill and the selection outline missed corners the stroke rounded. `VectorNetwork::rounded_smoothed` now walks the segment joining an open loop's ends, when no loop holds it, as the loop's last segment (`closing_segment`). Evidence: `doc::tests::a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment` (fails on main: fill 1810.85 vs stroke 1818.58); #1991's tests unchanged. | | 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-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. | From a75d8bf04ff501c1cdb740bb64d90ff36e11ec13 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 02:16:29 -0500 Subject: [PATCH 09/12] M4: Delete closes a region's loop through the segment that joins its ends (#1951) A Pen bridge between two degree-2 points leaves the region alone, and Delete on the point between them then rotated the loop open with the bridge in no loop, so even an unrounded fill ran straight across a curved bridge. reopen_loops now pushes the one segment in no loop that joins an open chain's ends onto the loop's end (winding kept, loop by loop), as connect does. This also covers removing segments and the eraser. closing_segment is public, derives its own start point, walks strictly (a loop out of walking order is left alone) and gives up when two segments qualify. The render fix stays as the fallback for files already saved in that state. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 reviewed; Sonnet 5.5 wrote at medium --- crates/doc/src/tests.rs | 15 +++++- crates/doc/src/vector.rs | 48 ++++++++++++++------ crates/engine/src/canvas/vectoredit/ops.rs | 34 ++++++++++++++ crates/engine/src/canvas/vectoredit/tests.rs | 21 +++++++++ docs/target-app-parity.md | 5 +- 5 files changed, 104 insertions(+), 19 deletions(-) diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 0d4efd6ec..9d922d6c8 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -908,12 +908,23 @@ fn a_chamfered_rectangles_rounded_fill_is_cut_at_every_point() { /// the stroke's outline, cut by an arc at the chain's ends as at every other point. #[test] fn a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment() { + open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![3, 1, 2, 0]); +} + +/// The same network with the joining segment listed first, and with the loop walked the other way. +#[test] +fn an_open_loop_follows_the_segment_joining_its_ends_listed_first_or_walked_backwards() { + open_loop_follows_the_segment_joining_its_ends([(3, 4), (0, 3), (1, 2), (2, 0), (4, 1)], vec![4, 2, 3, 1]); + open_loop_follows_the_segment_joining_its_ends([(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)], vec![0, 2, 1, 3]); +} + +fn open_loop_follows_the_segment_joining_its_ends(segments: [(usize, usize); 5], open_loop: Vec) { use figcraft_geom::BezPath; use figcraft_geom::kurbo::{ParamCurveArclen, ParamCurveNearest}; let corners = [Point::new(0.0, 0.0), Point::new(525.0, 525.0), Point::new(0.0, 525.0), Point::new(333.0, 0.0), Point::new(525.0, 183.0)]; let mut net = VectorNetwork::from_points(&corners, false); - net.segments = [(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)].iter().map(|&(a, b)| Segment::line(a, b)).collect(); - net.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, paint_space: None }); + net.segments = segments.iter().map(|&(a, b)| Segment::line(a, b)).collect(); + net.regions.push(Region { loops: vec![open_loop], winding: WindingRule::NonZero, fills: None, paint_space: None }); for v in &mut net.vertices { v.radius = 125.0; } diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index b1b45423b..1538de2a6 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -472,15 +472,13 @@ impl VectorNetwork { // order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many // rounded corners isn't quadratic. for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { - let Some(&first_idx) = lp.first() else { continue }; - let start_v = match (self.segments.get(first_idx), lp.get(1).and_then(|&i| self.segments.get(i))) { - (Some(first), Some(second)) if first.a == second.a || first.a == second.b => first.b, - (Some(first), _) => first.a, - _ => 0, - }; + if lp.is_empty() { + continue; + } + let start_v = self.loop_start(lp).unwrap_or(0); let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()) + 1); let mut cur = start_v; - let closing = self.closing_segment(lp, start_v); + let closing = self.closing_segment(lp); for &x in lp.iter().chain(closing.iter()) { rebuilt.push(x); let Some(s) = self.segments.get(x) else { continue }; @@ -498,23 +496,43 @@ impl VectorNetwork { Some(out) } - /// The segment that joins the two ends of an open loop `lp` (walked from `start_v`) when it is - /// in no loop of its own: a corner cut off by a segment drawn before the corner point was - /// deleted leaves the loop open there, and the stroke follows that segment. Rounding walks it - /// as the loop's last segment, so the points at the loop's ends are cut by arcs as the stroke's - /// are (#1951). - fn closing_segment(&self, lp: &[usize], start_v: usize) -> Option { + /// The point loop `lp` is walked from: the end of its first segment that its second does not + /// share. + fn loop_start(&self, lp: &[usize]) -> Option { + let first = self.segments.get(*lp.first()?)?; + match lp.get(1).and_then(|&i| self.segments.get(i)) { + Some(second) if first.a == second.a || first.a == second.b => Some(first.b), + _ => Some(first.a), + } + } + + /// The segment that joins the two ends of an open loop `lp` when it is in no loop of its own + /// (and the only such segment): a corner cut off by a segment drawn before the corner point was + /// deleted leaves the loop open there, and the stroke follows that segment. Only a loop that + /// walks in order as one continuous chain has one. Rounding walks it as the loop's last + /// segment, so the points at the loop's ends are cut by arcs as the stroke's are, and deleting + /// points or segments puts it into the loop (#1951). + pub fn closing_segment(&self, lp: &[usize]) -> Option { + let start_v = self.loop_start(lp)?; let mut cur = start_v; for &x in lp { let s = self.segments.get(x)?; - cur = if s.a == cur { s.b } else { s.a }; + cur = if s.a == cur { + s.b + } else if s.b == cur { + s.a + } else { + return None; + }; } if cur == start_v { return None; } let in_loop = |i: usize| self.regions.iter().flat_map(|r| r.loops.iter().flatten()).any(|&x| x == i); let ends = |s: &Segment| (s.a == cur && s.b == start_v) || (s.b == cur && s.a == start_v); - self.segments.iter().position(ends).filter(|&i| !in_loop(i)) + let mut joining = self.segments.iter().enumerate().filter(|(i, s)| ends(s) && !in_loop(*i)).map(|(i, _)| i); + let first = joining.next()?; + joining.next().is_none().then_some(first) } /// How point `v` (its `incident` segments) is rounded, if it is: not where the two segments diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index 32b8eef44..733885de1 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -468,6 +468,22 @@ fn reopen_loops(net: &mut VectorNetwork) { } } } + // An open chain whose two ends a segment in no loop already joins (a bridge drawn between two + // points before the point between them was deleted) closes through that segment, at the end so + // the loop's winding is kept, as `connect` closes it (#1951). One loop at a time, so two loops + // can't both take one segment. + let places: Vec<(usize, usize)> = net.regions.iter().enumerate().flat_map(|(ri, r)| (0..r.loops.len()).map(move |li| (ri, li))).collect(); + for (ri, li) in places { + let Some(lp) = net.regions.get(ri).and_then(|r| r.loops.get(li)) else { continue }; + if lp.len() < 2 { + continue; + } + if let Some(closing) = net.closing_segment(lp) + && let Some(lp) = net.regions.get_mut(ri).and_then(|r| r.loops.get_mut(li)) + { + lp.push(closing); + } + } } /// Cut a segment at `t` into two open ends at the same spot. Returns the two end vertices. @@ -1107,6 +1123,24 @@ mod tests { assert!(d.vertices.iter().all(|v| v.x.is_finite() && v.y.is_finite())); } + /// A curved bridge between two points of a ring, then the point between them deleted (Delete, + /// no healing): the region's loop closes through the bridge, so the fill follows its curve and + /// is as long as the stroke (#1951). + #[test] + fn deleting_a_point_behind_a_curved_bridge_keeps_the_bridge_in_the_loop() { + use figcraft_geom::BezPath; + use figcraft_geom::kurbo::ParamCurveArclen; + let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)); + let mut n = VectorNetwork::from_points(&pts, true); + connect(&mut n, 1, 3, (25.0, 25.0), (-25.0, -25.0)).unwrap(); + assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "the ring kept"); + delete_vertices(&mut n, &[2], false); + assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (5, 5), "the loop holds the bridge: {:?}", n.regions); + let length = |p: &BezPath| p.segments().map(|s| s.arclen(1e-9)).sum::(); + let (fill, stroke) = (length(&n.fill_path()), length(&n.stroke_path())); + assert!((fill - stroke).abs() < 1e-6, "fill {fill} and stroke {stroke} follow the same outline"); + } + #[test] fn lasso_polygon() { let pts = [Point::new(5.0, 5.0), Point::new(50.0, 50.0), Point::new(15.0, 5.0)]; diff --git a/crates/engine/src/canvas/vectoredit/tests.rs b/crates/engine/src/canvas/vectoredit/tests.rs index 3812c1b7b..ebe849761 100644 --- a/crates/engine/src/canvas/vectoredit/tests.rs +++ b/crates/engine/src/canvas/vectoredit/tests.rs @@ -659,6 +659,27 @@ fn a_rounded_chamfer_made_other_ways_fills_along_its_arcs() { fill_traces_stroke(&s, id, "drawn with the pen"); } +/// The reporter's sequence (#1951): a bridge drawn between the two new points while the corner is +/// still there (both have two segments, so the region keeps its ring), then the corner deleted with +/// Delete. The region's loop must close through the bridge, so the rounded fill traces the stroke. +#[test] +fn deleting_the_corner_behind_a_pen_bridge_closes_the_loop_through_the_bridge() { + let mut s = Session::new(); + let id = rect(&mut s); + ex(&mut s, "vector.edit.addVertex", json!({"segment": 0, "t": 0.5})); + ex(&mut s, "vector.edit.addVertex", json!({"segment": 1, "t": 0.5})); + let n = net(&s, id); + let (top, right) = (vertex_at(&n, 50.0, 0.0), vertex_at(&n, 100.0, 50.0)); + ex(&mut s, "vector.edit.connect", json!({"a": top, "b": right})); + let n = net(&s, id); + assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (7, 6), "bridged, the ring kept: {:?}", n.regions); + let corner = vertex_at(&n, 100.0, 0.0); + ex(&mut s, "vector.edit.select", json!({"vertices": [corner]})); + ex(&mut s, "vector.edit.delete", json!({})); + ex(&mut s, "vector.edit.cornerRadius", json!({"radius": 43, "vertices": [0, 1, 2, 3, 4]})); + fill_traces_stroke(&s, id, "bridged, then the corner deleted"); +} + /// The rounded chamfered rectangle `id` (five points on the origin's 100×100 box, the top-right /// corner cut off between (50, 0) and (100, 50)): one region with every segment in its loop, and a /// fill that keeps off every sharp point and traces the same outline as the stroke. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cf4955d1f..8d8cb2f05 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-06 · **Change:** minor (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, also when the loop was left open at a segment drawn before the corner was deleted (#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 (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, also when the loop was left open at a segment drawn before the corner was deleted, which Delete now closes through that segment (#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 @@ -246,7 +246,7 @@ Hours in this narrative are the builders' estimates; the tables above supersede closed area (faces with holes), point/segment selection, handle mirroring (none/angle/angle and length) with a Point panel (position, corner radius, mirroring), smooth/sharp points, join and merge (joining the two ends of a path broken open by a deleted point closes it again, so its - fill follows the new segment, and a rounded loop left open where a segment joins its ends is cut by arcs at them too, #1951), delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder + fill follows the new segment, and a rounded loop left open where a segment joins its ends is cut by arcs at them too, and Delete closes such a loop through that segment, #1951), delete vs delete-and-heal, nudging, one undo step per gesture, eraser (⇧E), shape builder (⇧M: the regions of overlapping shapes, click or drag to merge, Alt to remove, curves kept), variable width strokes (width profile presets with previews in the stroke settings, the Variable width tool ⇧W: drag the path to add a width point, slide points, drag their handles, Delete; @@ -656,6 +656,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-11 | patch | M4, #1951 (reporter's file, v0.17.0): a corner cut off by a segment drawn first, the corner point then deleted with Delete, leaves the region's loop as the open chain of the other segments, the cutting segment (which joins the chain's two ends) in no loop; rounded, the loop's first point was never cut by an arc (rounding only adds arcs at points the loop arrives at) and the closing edge ran straight, so the fill and the selection outline missed corners the stroke rounded. `VectorNetwork::rounded_smoothed` now walks the segment joining an open loop's ends, when no loop holds it, as the loop's last segment (`closing_segment`). Evidence: `doc::tests::a_rounded_loop_left_open_where_a_segment_joins_its_ends_follows_that_segment` (fails on main: fill 1810.85 vs stroke 1818.58); #1991's tests unchanged. | +| 2026-11-11 | patch | M4, #1951: Delete (and removing segments, and the eraser) closes a region's loop through the segment that joins its ends: a bridge drawn between two points of a ring leaves the region alone (`connect` rightly keeps the ring), and deleting the point between them rotated the loop open, the bridge in no loop, so even an unrounded fill ran straight across a curved bridge. `reopen_loops` now pushes the one segment in no loop that joins an open chain's ends onto the loop's end, keeping its winding, as #1991's connect does; `closing_segment` (now public) walks strictly, ignores segments already in loops and gives up when two qualify. The render fix stays as the fallback for files saved in that state. Evidence: `vectoredit::tests::deleting_the_corner_behind_a_pen_bridge_closes_the_loop_through_the_bridge` (fails before: the loop kept 4 of 5 segments), `ops::tests::deleting_a_point_behind_a_curved_bridge_keeps_the_bridge_in_the_loop`, `doc::tests::an_open_loop_follows_the_segment_joining_its_ends_listed_first_or_walked_backwards`. | | 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-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. | From 35d46208cff4a6555e1add5edf2a5d5ddbcfa1a8 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 03:48:24 -0500 Subject: [PATCH 10/12] M4: Shared open loops all close through their segment; one index per rounding pass (#1951) Two regions over one open chain both got the closing segment when rounded, but reopen_loops gave it to the first loop only, so after an unrelated deletion the second region's curved closing segment dropped out of its fill. The pass now takes the segment for every loop that walks the same chain (same segment set); a different chain still can't share it. closing_segment no longer scans every segment and every loop per call: rounded_smoothed and reopen_loops build one LoopIndex (endpoint pair to segments, and which segments are in a loop) per pass and pass it in. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at medium --- crates/doc/src/vector.rs | 35 ++++-- crates/engine/src/canvas/vectoredit/ops.rs | 118 +++++++++++++++++++-- 2 files changed, 137 insertions(+), 16 deletions(-) diff --git a/crates/doc/src/vector.rs b/crates/doc/src/vector.rs index 1538de2a6..fd6d48072 100644 --- a/crates/doc/src/vector.rs +++ b/crates/doc/src/vector.rs @@ -184,6 +184,13 @@ fn redirect_last_segment(net: &mut VectorNetwork, loop_segs: &[usize], from: usi } } +/// Which segments are in a loop, and which segments join each pair of points: the lookups +/// [`VectorNetwork::closing_segment`] reads, built once per pass by [`VectorNetwork::loop_index`]. +pub struct LoopIndex { + in_loop: Vec, + by_ends: std::collections::BTreeMap<(usize, usize), Vec>, +} + impl VectorNetwork { /// A network from a polyline / polygon. pub fn from_points(points: &[Point], closed: bool) -> Self { @@ -471,6 +478,7 @@ impl VectorNetwork { // The curves go between the two segments wherever a loop walks through the point, in the // order the loop meets them. Each loop is rebuilt in one pass, so a polygon with many // rounded corners isn't quadratic. + let index = self.loop_index(); for lp in out.regions.iter_mut().flat_map(|r| r.loops.iter_mut()) { if lp.is_empty() { continue; @@ -478,7 +486,7 @@ impl VectorNetwork { let start_v = self.loop_start(lp).unwrap_or(0); let mut rebuilt = Vec::with_capacity(lp.len() + by_vertex.len().min(lp.len()) + 1); let mut cur = start_v; - let closing = self.closing_segment(lp); + let closing = self.closing_segment(lp, &index); for &x in lp.iter().chain(closing.iter()) { rebuilt.push(x); let Some(s) = self.segments.get(x) else { continue }; @@ -506,13 +514,29 @@ impl VectorNetwork { } } + /// The lookups [`Self::closing_segment`] needs, built once for a pass over every loop. + pub fn loop_index(&self) -> LoopIndex { + let mut in_loop = vec![false; self.segments.len()]; + for &x in self.regions.iter().flat_map(|r| r.loops.iter().flatten()) { + if let Some(f) = in_loop.get_mut(x) { + *f = true; + } + } + let mut by_ends: std::collections::BTreeMap<(usize, usize), Vec> = std::collections::BTreeMap::new(); + for (i, s) in self.segments.iter().enumerate() { + by_ends.entry((s.a.min(s.b), s.a.max(s.b))).or_default().push(i); + } + LoopIndex { in_loop, by_ends } + } + /// The segment that joins the two ends of an open loop `lp` when it is in no loop of its own /// (and the only such segment): a corner cut off by a segment drawn before the corner point was /// deleted leaves the loop open there, and the stroke follows that segment. Only a loop that /// walks in order as one continuous chain has one. Rounding walks it as the loop's last /// segment, so the points at the loop's ends are cut by arcs as the stroke's are, and deleting - /// points or segments puts it into the loop (#1951). - pub fn closing_segment(&self, lp: &[usize]) -> Option { + /// points or segments puts it into the loop (#1951). `index` is this network's [`Self::loop_index`], + /// shared by every loop of one pass. + pub fn closing_segment(&self, lp: &[usize], index: &LoopIndex) -> Option { let start_v = self.loop_start(lp)?; let mut cur = start_v; for &x in lp { @@ -528,9 +552,8 @@ impl VectorNetwork { if cur == start_v { return None; } - let in_loop = |i: usize| self.regions.iter().flat_map(|r| r.loops.iter().flatten()).any(|&x| x == i); - let ends = |s: &Segment| (s.a == cur && s.b == start_v) || (s.b == cur && s.a == start_v); - let mut joining = self.segments.iter().enumerate().filter(|(i, s)| ends(s) && !in_loop(*i)).map(|(i, _)| i); + let mut joining = + index.by_ends.get(&(cur.min(start_v), cur.max(start_v)))?.iter().copied().filter(|&i| !index.in_loop.get(i).copied().unwrap_or(false)); let first = joining.next()?; joining.next().is_none().then_some(first) } diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index 733885de1..f4609dc1e 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -470,17 +470,28 @@ fn reopen_loops(net: &mut VectorNetwork) { } // An open chain whose two ends a segment in no loop already joins (a bridge drawn between two // points before the point between them was deleted) closes through that segment, at the end so - // the loop's winding is kept, as `connect` closes it (#1951). One loop at a time, so two loops - // can't both take one segment. - let places: Vec<(usize, usize)> = net.regions.iter().enumerate().flat_map(|(ri, r)| (0..r.loops.len()).map(move |li| (ri, li))).collect(); - for (ri, li) in places { - let Some(lp) = net.regions.get(ri).and_then(|r| r.loops.get(li)) else { continue }; - if lp.len() < 2 { - continue; + // the loop's winding is kept, as `connect` closes it (#1951). Every loop that walks the same + // chain (two regions over one ring) takes it, as the render does; a different chain can't + // share it. The lookups are built once, so a segment one loop took doesn't hide it from the + // next. + let index = net.loop_index(); + let mut closings: Vec<(usize, usize, usize, Vec)> = Vec::new(); + for (ri, r) in net.regions.iter().enumerate() { + for (li, lp) in r.loops.iter().enumerate() { + if lp.len() < 2 { + continue; + } + if let Some(closing) = net.closing_segment(lp, &index) { + let mut chain = lp.clone(); + chain.sort_unstable(); + if closings.iter().all(|(_, _, c, other)| *c != closing || *other == chain) { + closings.push((ri, li, closing, chain)); + } + } } - if let Some(closing) = net.closing_segment(lp) - && let Some(lp) = net.regions.get_mut(ri).and_then(|r| r.loops.get_mut(li)) - { + } + for (ri, li, closing, _) in closings { + if let Some(lp) = net.regions.get_mut(ri).and_then(|r| r.loops.get_mut(li)) { lp.push(closing); } } @@ -1209,4 +1220,91 @@ mod tests { assert!(ends.iter().any(|x| (x - (centre.x - 0.5)).abs() < 0.01), "{ends:?}"); assert!(ends.iter().any(|x| (x - (centre.x + 0.5)).abs() < 0.01), "{ends:?}"); } + + /// An open chain of four segments over five points whose two ends a fifth segment (in no + /// loop) joins, as a bridge drawn before the point between them was deleted. + fn bridged_open_chain() -> VectorNetwork { + let corners = [ + Point::new(0.0, 0.0), + Point::new(525.0, 525.0), + Point::new(0.0, 525.0), + Point::new(333.0, 0.0), + Point::new(525.0, 183.0), + Point::new(900.0, 900.0), + ]; + let mut n = VectorNetwork::from_points(&corners, false); + n.segments = [(0, 3), (1, 2), (2, 0), (4, 1), (3, 4)].iter().map(|&(a, b)| Segment::line(a, b)).collect(); + n.regions.clear(); + for v in &mut n.vertices { + v.radius = 125.0; + } + n + } + + #[test] + fn regions_over_one_open_chain_all_close_through_the_joining_segment() { + use figcraft_geom::BezPath; + use figcraft_geom::kurbo::ParamCurveArclen; + let mut n = bridged_open_chain(); + for _ in 0..2 { + n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); + } + delete_vertices(&mut n, &[5], false); + for (i, r) in n.regions.iter().enumerate() { + assert_eq!(r.loops[0], vec![3, 1, 2, 0, 4], "region {i} closes through the segment: {:?}", n.regions); + } + let length = |path: &BezPath| path.segments().map(|s| s.arclen(1e-9)).sum::(); + let stroke = length(&n.stroke_path()); + let rounded = n.rounded().unwrap(); + for i in 0..2 { + let fill = length(&rounded.region_path(i).unwrap()); + assert!((fill - stroke).abs() < 1e-6, "region {i}: fill {fill} and stroke {stroke} trace the same outline"); + } + } + + #[test] + fn a_segment_joining_two_different_chains_goes_to_the_first_only() { + let mut n = bridged_open_chain(); + n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); + n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); + reopen_loops(&mut n); + assert_eq!(n.regions[0].loops[0].len(), 5); + assert_eq!(n.regions[1].loops[0].len(), 5, "the same segment set is the same chain"); + let mut m = bridged_open_chain(); + m.segments.push(Segment::line(1, 2)); + m.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); + m.regions.push(Region { loops: vec![vec![3, 5, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); + reopen_loops(&mut m); + let closed = m.regions.iter().filter(|r| r.loops[0].len() == 5).count(); + assert!(closed <= 1, "different chains don't share the segment: {:?}", m.regions); + } + + #[test] + fn many_open_loops_with_no_closing_segment_round_as_before() { + let mut n = VectorNetwork::from_points(&[Point::new(0.0, 0.0), Point::new(10.0, 0.0)], false); + n.segments.clear(); + n.vertices.clear(); + for k in 0..40 { + let x = f64::from(k) * 100.0; + let base = n.vertices.len(); + for p in [(x, 0.0), (x + 50.0, 0.0), (x + 50.0, 50.0), (x, 50.0)] { + n.vertices.push(Vertex { radius: 8.0, ..Vertex::new(p.0, p.1) }); + } + let first = n.segments.len(); + // Three sides of a square, an open loop with nothing to close it. + for (a, b) in [(0, 1), (1, 2), (2, 3)] { + n.segments.push(Segment::line(base + a, base + b)); + } + n.regions.push(Region { + loops: vec![vec![first, first + 1, first + 2]], + winding: WindingRule::NonZero, + fills: None, + ..Default::default() + }); + } + let r = n.rounded().unwrap(); + assert_eq!(r.regions.len(), 40); + // Each loop gained only its two inner corners' arcs (two corners, one arc each). + assert!(r.regions.iter().all(|reg| reg.loops[0].len() == 5), "{:?}", r.regions[0]); + } } From 153fa08d077a6a94a9c5eb44c3878176f15329df Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 04:38:33 -0500 Subject: [PATCH 11/12] M4: reopen_loops looks up each closing segment's chain in a map (#1951) Checking a loop's closing segment against every closing already accepted was quadratic in the number of open loops. A map from closing segment to the chain that took it first answers it in one lookup; behaviour is unchanged. A test closes 50 independent chains through their own segments. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- crates/engine/src/canvas/vectoredit/ops.rs | 33 +++++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index f4609dc1e..f8b9d7d66 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -475,7 +475,9 @@ fn reopen_loops(net: &mut VectorNetwork) { // share it. The lookups are built once, so a segment one loop took doesn't hide it from the // next. let index = net.loop_index(); - let mut closings: Vec<(usize, usize, usize, Vec)> = Vec::new(); + // The chain that first took each closing segment, so a later loop is checked in one lookup. + let mut owner: std::collections::BTreeMap> = std::collections::BTreeMap::new(); + let mut closings: Vec<(usize, usize, usize)> = Vec::new(); for (ri, r) in net.regions.iter().enumerate() { for (li, lp) in r.loops.iter().enumerate() { if lp.len() < 2 { @@ -484,13 +486,13 @@ fn reopen_loops(net: &mut VectorNetwork) { if let Some(closing) = net.closing_segment(lp, &index) { let mut chain = lp.clone(); chain.sort_unstable(); - if closings.iter().all(|(_, _, c, other)| *c != closing || *other == chain) { - closings.push((ri, li, closing, chain)); + if *owner.entry(closing).or_insert_with(|| chain.clone()) == chain { + closings.push((ri, li, closing)); } } } } - for (ri, li, closing, _) in closings { + for (ri, li, closing) in closings { if let Some(lp) = net.regions.get_mut(ri).and_then(|r| r.loops.get_mut(li)) { lp.push(closing); } @@ -1262,6 +1264,29 @@ mod tests { } } + /// Many independent open chains, each with its own closing segment, all close through it. + #[test] + fn many_open_chains_each_close_through_their_own_segment() { + let one = bridged_open_chain(); + let (nv, ns) = (one.vertices.len(), one.segments.len()); + let mut n = VectorNetwork::default(); + for (i, dx) in (0..50usize).zip((0..50u32).map(|k| f64::from(k) * 1000.0)) { + n.vertices.extend(one.vertices.iter().map(|v| Vertex { x: v.x + dx, ..*v })); + n.segments.extend(one.segments.iter().map(|s| Segment { a: s.a + i * nv, b: s.b + i * nv, ..*s })); + let base = i * ns; + n.regions.push(Region { + loops: vec![vec![base + 3, base + 1, base + 2, base]], + winding: WindingRule::NonZero, + fills: None, + ..Default::default() + }); + } + reopen_loops(&mut n); + for (i, r) in n.regions.iter().enumerate() { + assert_eq!(r.loops[0].last(), Some(&(i * ns + 4)), "chain {i} closes through its own segment"); + } + } + #[test] fn a_segment_joining_two_different_chains_goes_to_the_first_only() { let mut n = bridged_open_chain(); From 06ffeca8017c54bb3b381850672b405b1537ad59 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Wed, 11 Nov 2026 05:55:33 -0500 Subject: [PATCH 12/12] M4: Every loop a bridge closes takes it; only a real removal closes loops (#1951) The closing pass moves out of reopen_loops (back to main's body) into close_bridged_loops, run only by remove_segments and delete_vertices when the segment or point count changed. Every loop takes what closing_segment returns against one snapshot, so two fills that shared a deleted border both close through the bridge, as rounding already draws them. The owner map is gone. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Fable 5.1 decided; Sonnet 5.5 wrote at medium --- crates/engine/src/canvas/vectoredit/ops.rs | 114 +++++++++++++++------ 1 file changed, 83 insertions(+), 31 deletions(-) diff --git a/crates/engine/src/canvas/vectoredit/ops.rs b/crates/engine/src/canvas/vectoredit/ops.rs index f8b9d7d66..222c6f4ff 100644 --- a/crates/engine/src/canvas/vectoredit/ops.rs +++ b/crates/engine/src/canvas/vectoredit/ops.rs @@ -312,6 +312,7 @@ fn remove_vertex_index(net: &mut VectorNetwork, vi: usize) { /// Remove segments (fixing region loops); points left without any segment go too. pub fn remove_segments(net: &mut VectorNetwork, segs: &[usize]) { + let before = net.segments.len(); let mut touched: Vec = segs.iter().filter_map(|s| net.segments.get(*s)).flat_map(|s| [s.a, s.b]).collect(); remove_segments_only(net, segs); touched.sort_unstable(); @@ -322,6 +323,9 @@ pub fn remove_segments(net: &mut VectorNetwork, segs: &[usize]) { } } reopen_loops(net); + if net.segments.len() != before { + close_bridged_loops(net); + } } fn remove_segments_only(net: &mut VectorNetwork, segs: &[usize]) { @@ -349,6 +353,7 @@ fn remove_segments_only(net: &mut VectorNetwork, segs: &[usize]) { /// Delete vertices, joining their neighbours where a vertex had exactly two segments (`heal`). pub fn delete_vertices(net: &mut VectorNetwork, idx: &[usize], heal: bool) { + let before = net.vertices.len(); let mut idx: Vec = idx.to_vec(); idx.sort_unstable(); idx.dedup(); @@ -408,6 +413,9 @@ pub fn delete_vertices(net: &mut VectorNetwork, idx: &[usize], heal: bool) { } net.sanitize(); reopen_loops(net); + if net.vertices.len() != before { + close_bridged_loops(net); + } } /// Handles for the curve that heals over deleted point `vi` between `(vertex, handle)` ends `o1` @@ -468,32 +476,21 @@ fn reopen_loops(net: &mut VectorNetwork) { } } } - // An open chain whose two ends a segment in no loop already joins (a bridge drawn between two - // points before the point between them was deleted) closes through that segment, at the end so - // the loop's winding is kept, as `connect` closes it (#1951). Every loop that walks the same - // chain (two regions over one ring) takes it, as the render does; a different chain can't - // share it. The lookups are built once, so a segment one loop took doesn't hide it from the - // next. +} + +/// After points or segments were removed: an open loop whose two ends a segment in no loop already +/// joins (a bridge drawn between two points before what lay between them was deleted) closes +/// through that segment, at the loop's end so its winding is kept, as `connect` closes it (#1951). +/// Every loop takes what [`VectorNetwork::closing_segment`] gives it against one snapshot of the +/// network, which is what rounding walks: two fills that shared the removed border share the +/// bridge, as they shared the border. Only a removal can open a loop at two points a segment +/// already joins (a cut's ends are new points), so only `delete_vertices` and `remove_segments` +/// run this, and only when they removed something. +fn close_bridged_loops(net: &mut VectorNetwork) { let index = net.loop_index(); - // The chain that first took each closing segment, so a later loop is checked in one lookup. - let mut owner: std::collections::BTreeMap> = std::collections::BTreeMap::new(); - let mut closings: Vec<(usize, usize, usize)> = Vec::new(); - for (ri, r) in net.regions.iter().enumerate() { - for (li, lp) in r.loops.iter().enumerate() { - if lp.len() < 2 { - continue; - } - if let Some(closing) = net.closing_segment(lp, &index) { - let mut chain = lp.clone(); - chain.sort_unstable(); - if *owner.entry(closing).or_insert_with(|| chain.clone()) == chain { - closings.push((ri, li, closing)); - } - } - } - } - for (ri, li, closing) in closings { - if let Some(lp) = net.regions.get_mut(ri).and_then(|r| r.loops.get_mut(li)) { + let closings: Vec> = net.regions.iter().flat_map(|r| r.loops.iter()).map(|lp| net.closing_segment(lp, &index)).collect(); + for (lp, closing) in net.regions.iter_mut().flat_map(|r| r.loops.iter_mut()).zip(closings) { + if let Some(closing) = closing { lp.push(closing); } } @@ -1281,27 +1278,82 @@ mod tests { ..Default::default() }); } - reopen_loops(&mut n); + close_bridged_loops(&mut n); for (i, r) in n.regions.iter().enumerate() { assert_eq!(r.loops[0].last(), Some(&(i * ns + 4)), "chain {i} closes through its own segment"); } } #[test] - fn a_segment_joining_two_different_chains_goes_to_the_first_only() { + fn a_segment_joining_two_different_chains_closes_both() { let mut n = bridged_open_chain(); n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); n.regions.push(Region { loops: vec![vec![0, 2, 1, 3]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); - reopen_loops(&mut n); + delete_vertices(&mut n, &[5], false); assert_eq!(n.regions[0].loops[0].len(), 5); assert_eq!(n.regions[1].loops[0].len(), 5, "the same segment set is the same chain"); let mut m = bridged_open_chain(); m.segments.push(Segment::line(1, 2)); m.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); m.regions.push(Region { loops: vec![vec![3, 5, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); - reopen_loops(&mut m); - let closed = m.regions.iter().filter(|r| r.loops[0].len() == 5).count(); - assert!(closed <= 1, "different chains don't share the segment: {:?}", m.regions); + let drawn = m.rounded().unwrap(); + delete_vertices(&mut m, &[5], false); + assert_eq!(m.regions[0].loops[0], vec![3, 1, 2, 0, 4], "{:?}", m.regions); + assert_eq!(m.regions[1].loops[0], vec![3, 5, 2, 0, 4], "different chains share the segment too: {:?}", m.regions); + let after = m.rounded().unwrap(); + for i in 0..2 { + assert_eq!(after.region_path(i).unwrap(), drawn.region_path(i).unwrap(), "region {i} is stored as rounding drew it"); + } + } + + /// Two fills that shared a border, a bridge drawn between the border's ends, then the border's + /// middle point deleted: both fills close through the bridge (#1951). + #[test] + fn two_fills_that_shared_a_deleted_border_both_close_through_the_bridge() { + use figcraft_geom::Shape; + let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 100.0), (50.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)); + let mut n = VectorNetwork::from_points(&pts, true); + n.vertices.push(Vertex::new(50.0, 50.0)); + n.segments.push(Segment::line(1, 6)); + n.segments.push(Segment::line(6, 4)); + n.regions.clear(); + for lp in [vec![0, 6, 7, 4, 5], vec![1, 2, 3, 7, 6]] { + n.regions.push(Region { loops: vec![lp], winding: WindingRule::NonZero, fills: None, ..Default::default() }); + } + connect(&mut n, 1, 4, (30.0, 30.0), (30.0, -30.0)).unwrap(); + delete_vertices(&mut n, &[6], false); + assert_eq!(n.regions[0].loops[0], vec![4, 5, 0, 6]); + assert_eq!(n.regions[1].loops[0], vec![1, 2, 3, 6], "the second fill closes through the bridge too"); + let area = |i: usize| n.region_path(i).unwrap().area().abs(); + assert!((area(0) + area(1) - 10_000.0).abs() < 1e-6, "the fills meet along the bridge"); + } + + #[test] + fn removing_nothing_leaves_an_open_loop_alone() { + let mut n = bridged_open_chain(); + n.regions.push(Region { loops: vec![vec![3, 1, 2, 0]], winding: WindingRule::NonZero, fills: None, ..Default::default() }); + let before = n.clone(); + remove_segments(&mut n, &[]); + assert_eq!(n, before); + remove_segments(&mut n, &[99]); + assert_eq!(n, before); + delete_vertices(&mut n, &[], false); + assert_eq!(n, before); + delete_vertices(&mut n, &[99], true); + assert_eq!(n, before); + delete_vertices(&mut n, &[5], false); + assert_eq!(n.regions[0].loops[0], vec![3, 1, 2, 0, 4]); + } + + /// The same bridge as the curved-bridge test, reached by removing segments. + #[test] + fn removing_segments_behind_a_curved_bridge_keeps_the_bridge_in_the_loop() { + let pts = [(0.0, 0.0), (50.0, 0.0), (100.0, 0.0), (100.0, 50.0), (100.0, 100.0), (0.0, 100.0)].map(|(x, y)| Point::new(x, y)); + let mut n = VectorNetwork::from_points(&pts, true); + connect(&mut n, 1, 3, (25.0, 25.0), (-25.0, -25.0)).unwrap(); + remove_segments(&mut n, &[1, 2]); + assert_eq!((n.segments.len(), n.regions[0].loops[0].len()), (5, 5)); + assert_eq!(n.regions[0].loops[0], vec![1, 2, 3, 0, 4]); } #[test]