From d86a7ad9d9fed164abc347f93e62899b9d0c7385 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Tue, 27 Oct 2026 12:55:47 +0300 Subject: [PATCH 01/34] M0.9: inspect layer trees with selected fields or a compact outline (#996) node.get, the control channel's inspect methods and the MCP get_node / inspect_document tools take fields: [node keys], applied at every level, or outline: true (id, name, type, characters cut to 120 characters, mainComponentName, visible). Ids and structure are always kept, properties that aren't selected aren't computed, and the default output is unchanged. Part of #996. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra --- crates/engine/src/automation/inspect.rs | 415 +++++++++++++----- crates/engine/src/cmd/edit.rs | 5 +- crates/engine/src/inspect_fields_tests.rs | 479 +++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/tests.rs | 6 + crates/mcp/src/headless.rs | 6 +- crates/mcp/src/tools.rs | 43 +- crates/mcp/tests/it/attached.rs | 18 + crates/mcp/tests/it/inspect_fields.rs | 205 +++++++++ crates/mcp/tests/it/main.rs | 2 + crates/ui-egui/src/control.rs | 6 +- crates/ui-egui/src/inspect_fields_tests.rs | 48 +++ crates/ui-egui/src/lib.rs | 2 + docs/control-protocol.md | 26 +- docs/mcp.md | 24 +- docs/roadmap.md | 3 +- 16 files changed, 1158 insertions(+), 132 deletions(-) create mode 100644 crates/engine/src/inspect_fields_tests.rs create mode 100644 crates/mcp/tests/it/inspect_fields.rs create mode 100644 crates/ui-egui/src/inspect_fields_tests.rs diff --git a/crates/engine/src/automation/inspect.rs b/crates/engine/src/automation/inspect.rs index 7dccd7067..872f3c53e 100644 --- a/crates/engine/src/automation/inspect.rs +++ b/crates/engine/src/automation/inspect.rs @@ -5,280 +5,461 @@ use serde_json::{Value, json}; use crate::Session; +/// Top-level node keys accepted by `fields`, including the structure kept in every tree. +pub const NODE_KEYS: &[&str] = &[ + "absolute", + "absoluteBounds", + "annotations", + "arc", + "background", + "blend", + "board", + "booleanOperation", + "boundVariables", + "brush", + "characters", + "childCount", + "children", + "clip", + "codeLinks", + "componentProperties", + "componentPropertyDefinitions", + "constraints", + "cornerRadius", + "cornerSmoothing", + "dashes", + "description", + "devChanged", + "devNote", + "devResources", + "devStatus", + "effects", + "exports", + "fills", + "flows", + "fontFamily", + "fontSize", + "fontStyle", + "fontVariations", + "gridCell", + "grids", + "hangingList", + "hangingPunctuation", + "height", + "id", + "innerRadius", + "interactions", + "isMask", + "layout", + "leadingTrim", + "letterSpacing", + "lineHeight", + "lineIndentations", + "lineTypes", + "listSpacing", + "listStartOffsets", + "listStyle", + "locked", + "mainComponent", + "mainComponentName", + "maskType", + "maxHeight", + "maxLines", + "maxWidth", + "measurements", + "minHeight", + "minWidth", + "miterAngle", + "name", + "opacity", + "operandOperation", + "overrides", + "pluginData", + "pointCount", + "rotation", + "sectionContentsHidden", + "segments", + "sizing", + "slot", + "strokeAlign", + "strokeJoin", + "strokeSides", + "strokeWeight", + "strokes", + "styledRuns", + "styles", + "textAlign", + "textAutoResize", + "textDecoration", + "textDecorationColor", + "textDecorationOffset", + "textDecorationSkipInk", + "textDecorationStyle", + "textDecorationThickness", + "textPath", + "textStyleRanges", + "type", + "vertices", + "visible", + "width", + "widthProfile", + "x", + "y", +]; + +/// Which layer properties to build. Missing options keep the full, sparse inspection output. +#[derive(Clone, Debug, Default)] +pub struct InspectOptions { + fields: Option>, + outline: bool, +} + +impl InspectOptions { + /// Parse the same options for engine commands, control methods and MCP tools. + pub fn parse(p: &Value) -> Result { + let outline = match p.get("outline") { + None => false, + Some(Value::Bool(v)) => *v, + Some(_) => return Err("`outline` must be a boolean".into()), + }; + if outline && p.get("fields").is_some() { + return Err("`outline: true` cannot be combined with `fields`".into()); + } + let fields = match p.get("fields") { + None if outline => Some(["id", "name", "type", "characters", "mainComponentName", "visible"].into_iter().collect()), + None => None, + Some(Value::Array(values)) => { + let mut fields = std::collections::BTreeSet::new(); + for value in values { + let key = value.as_str().ok_or("`fields` must be an array of strings")?; + let key = NODE_KEYS + .iter() + .copied() + .find(|k| *k == key) + .ok_or_else(|| format!("unknown field `{key}`; valid keys: {}", NODE_KEYS.join(", ")))?; + fields.insert(key); + } + Some(fields) + } + Some(_) => return Err("`fields` must be an array of strings".into()), + }; + Ok(Self { fields, outline }) + } + + fn includes(&self, key: &str) -> bool { + self.fields.as_ref().is_none_or(|fields| fields.contains(key)) + } + + fn explicit(&self, key: &str) -> bool { + self.fields.as_ref().is_some_and(|fields| fields.contains(key)) + } +} + /// A node and (to `depth`) its children, with the properties a designer would check. pub fn node_json(doc: &Document, id: NodeId, depth: usize) -> Value { + node_json_with_options(doc, id, depth, &InspectOptions::default()) +} + +/// A node's selected properties, applied at every level of its subtree. +pub fn node_json_with_options(doc: &Document, id: NodeId, depth: usize, options: &InspectOptions) -> Value { let Some(n) = doc.node(id) else { return Value::Null }; - let c = doc.frame_relative(id).as_coeffs(); - let mut v = json!({ - "id": n.id, - "name": n.name, - "type": n.kind.type_name(), - }); + let mut v = json!({"id": n.id}); let Some(o) = v.as_object_mut() else { return v }; + // Keep unselected properties lazy, including paint, text and layout JSON. + macro_rules! put { + ($key:expr, $value:expr $(,)?) => { + if options.includes($key) { + o.insert($key.into(), $value); + } + }; + } + put!("name", json!(n.name)); + put!("type", json!(n.kind.type_name())); + if options.explicit("visible") || (!n.is_page() && !n.visible) { + put!("visible", json!(n.visible)); + } if !n.is_page() { - o.insert("x".into(), json!(round(c[4]))); - o.insert("y".into(), json!(round(c[5]))); - o.insert("width".into(), json!(round(n.width))); - o.insert("height".into(), json!(round(n.height))); - let rot = figcraft_geom::rotation_degrees(n.transform); - if rot.abs() > 1e-6 { - o.insert("rotation".into(), json!(round(rot))); + if options.includes("x") || options.includes("y") { + let c = doc.frame_relative(id).as_coeffs(); + put!("x", json!(round(c[4]))); + put!("y", json!(round(c[5]))); } - if let Some(b) = doc.world_bounds(id) { - o.insert("absoluteBounds".into(), json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())})); + put!("width", json!(round(n.width))); + put!("height", json!(round(n.height))); + if options.includes("rotation") { + let rot = figcraft_geom::rotation_degrees(n.transform); + if rot.abs() > 1e-6 { + put!("rotation", json!(round(rot))); + } } - if !n.visible { - o.insert("visible".into(), json!(false)); + if let Some(b) = options.includes("absoluteBounds").then(|| doc.world_bounds(id)).flatten() { + put!("absoluteBounds", json!({"x": round(b.x0), "y": round(b.y0), "width": round(b.width()), "height": round(b.height())})); } if n.locked { - o.insert("locked".into(), json!(true)); + put!("locked", json!(true)); } if n.opacity < 1.0 { - o.insert("opacity".into(), json!(n.opacity)); + put!("opacity", json!(n.opacity)); } if !matches!(n.blend, figcraft_color::BlendMode::Normal | figcraft_color::BlendMode::PassThrough) { - o.insert("blend".into(), json!(n.blend)); + put!("blend", json!(n.blend)); } if !n.fills.is_empty() { - o.insert("fills".into(), paints(&n.fills)); + put!("fills", paints(&n.fills)); } if !n.strokes.is_empty() { - o.insert("strokes".into(), paints(&n.strokes)); - o.insert("strokeWeight".into(), json!(n.stroke.weight)); - o.insert("strokeAlign".into(), json!(n.stroke.align)); + put!("strokes", paints(&n.strokes)); + put!("strokeWeight", json!(n.stroke.weight)); + put!("strokeAlign", json!(n.stroke.align)); if n.stroke.sides.is_some() { - o.insert("strokeSides".into(), json!(n.stroke.sides)); + put!("strokeSides", json!(n.stroke.sides)); } if !n.stroke.dashes.is_empty() { - o.insert("dashes".into(), json!(n.stroke.dashes)); + put!("dashes", json!(n.stroke.dashes)); } if n.stroke.join != StrokeJoin::Miter { - o.insert("strokeJoin".into(), json!(n.stroke.join)); + put!("strokeJoin", json!(n.stroke.join)); } else if n.stroke.miter_limit != 4.0 { - o.insert("miterAngle".into(), json!((n.stroke.miter_angle() * 100.0).round() / 100.0)); + put!("miterAngle", json!((n.stroke.miter_angle() * 100.0).round() / 100.0)); } - if let Some(p) = n.stroke.profile() { + if let Some(p) = n.stroke.profile().filter(|_| options.includes("widthProfile")) { let points = crate::cmd::props::width_points_json(Some(p)); - o.insert("widthProfile".into(), json!({"preset": p.preset().map(|x| x.id()), "points": points})); + put!("widthProfile", json!({"preset": p.preset().map(|x| x.id()), "points": points})); } if let Some(b) = &n.stroke.brush { - o.insert("brush".into(), camel(serde_json::to_value(b).unwrap_or(Value::Null))); + put!("brush", camel(serde_json::to_value(b).unwrap_or(Value::Null))); } } if !n.effects.is_empty() { - o.insert("effects".into(), camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); + put!("effects", camel(serde_json::to_value(&n.effects).unwrap_or(Value::Null))); } if n.corners.radii.iter().any(|r| *r > 0.0) { - o.insert( - "cornerRadius".into(), + put!( + "cornerRadius", match n.corners.uniform() { Some(r) => json!(r), None => json!(n.corners.radii), }, ); if n.corners.smoothing > 0.0 { - o.insert("cornerSmoothing".into(), json!(n.corners.smoothing)); + put!("cornerSmoothing", json!(n.corners.smoothing)); } } if let Some(l) = &n.layout { - o.insert("layout".into(), camel(serde_json::to_value(l).unwrap_or(Value::Null))); + put!("layout", camel(serde_json::to_value(l).unwrap_or(Value::Null))); } let parent_al = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); if n.layout.is_some() || parent_al || matches!(n.kind, NodeKind::Text(_)) { - o.insert("sizing".into(), json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); + put!("sizing", json!({"horizontal": n.child_layout.sizing_h, "vertical": n.child_layout.sizing_v})); } let cl = &n.child_layout; if cl.absolute { - o.insert("absolute".into(), json!(true)); + put!("absolute", json!(true)); } for (k, v) in [("minWidth", cl.min_w), ("maxWidth", cl.max_w), ("minHeight", cl.min_h), ("maxHeight", cl.max_h)] { if let Some(v) = v { - o.insert(k.into(), json!(v)); + put!(k, json!(v)); } } - if let Some(cell) = parent_al + if let Some(cell) = (parent_al && options.includes("gridCell")) .then(|| n.parent.and_then(|p| figcraft_layout::grid_geometry(doc, p))) .flatten() .and_then(|g| g.cells.into_iter().find(|c| c.id == n.id)) { - o.insert( - "gridCell".into(), + put!( + "gridCell", json!({"column": cell.column, "row": cell.row, "columnSpan": cell.column_span, "rowSpan": cell.row_span, "anchored": cl.grid_column.is_some() || cl.grid_row.is_some(), "alignH": cl.grid_align_h, "alignV": cl.grid_align_v}), ); } if (!parent_al || cl.absolute) && n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.kind.is_frame_like()) { - o.insert("constraints".into(), serde_json::to_value(n.constraints).unwrap_or(Value::Null)); + put!("constraints", serde_json::to_value(n.constraints).unwrap_or(Value::Null)); } if n.kind.is_frame_like() { - o.insert("clip".into(), json!(n.clip)); + put!("clip", json!(n.clip)); } if !n.dev_status.is_none() { - o.insert("devStatus".into(), json!(n.dev_status.id())); + put!("devStatus", json!(n.dev_status.id())); } if n.dev_changed { - o.insert("devChanged".into(), json!(true)); + put!("devChanged", json!(true)); } if !n.dev_note.is_empty() { - o.insert("devNote".into(), json!(n.dev_note)); + put!("devNote", json!(n.dev_note)); } if let NodeKind::Section { contents_hidden } = n.kind { - o.insert("sectionContentsHidden".into(), json!(contents_hidden)); + put!("sectionContentsHidden", json!(contents_hidden)); } if n.is_mask { - o.insert("isMask".into(), json!(true)); - o.insert("maskType".into(), json!(n.mask_type.id())); + put!("isMask", json!(true)); + put!("maskType", json!(n.mask_type.id())); } if !n.grids.is_empty() { - o.insert("grids".into(), camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null))); + put!("grids", camel(serde_json::to_value(&n.grids).unwrap_or(Value::Null))); } if !n.exports.is_empty() { - o.insert("exports".into(), serde_json::to_value(&n.exports).unwrap_or(Value::Null)); + put!("exports", serde_json::to_value(&n.exports).unwrap_or(Value::Null)); } if let Some(b) = &n.board { - o.insert("board".into(), serde_json::to_value(b).unwrap_or(Value::Null)); + put!("board", serde_json::to_value(b).unwrap_or(Value::Null)); } if !n.annotations.is_empty() { - o.insert("annotations".into(), serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); + put!("annotations", serde_json::to_value(&n.annotations).unwrap_or(Value::Null)); } if !n.code_links.is_empty() { - o.insert("codeLinks".into(), camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); + put!("codeLinks", camel(serde_json::to_value(&n.code_links).unwrap_or(Value::Null))); } if !n.measurements.is_empty() { - o.insert("measurements".into(), serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); + put!("measurements", serde_json::to_value(&n.measurements).unwrap_or(Value::Null)); } - let dev = crate::dev_resources::list_json(doc, n.id); - if dev.as_array().is_some_and(|a| !a.is_empty()) { - o.insert("devResources".into(), dev); + if options.includes("devResources") { + let dev = crate::dev_resources::list_json(doc, n.id); + if dev.as_array().is_some_and(|a| !a.is_empty()) { + put!("devResources", dev); + } } if !n.interactions.is_empty() { - o.insert("interactions".into(), serde_json::to_value(&n.interactions).unwrap_or(Value::Null)); + put!("interactions", serde_json::to_value(&n.interactions).unwrap_or(Value::Null)); } if !n.styles.is_empty() { - o.insert("styles".into(), serde_json::to_value(&n.styles).unwrap_or(Value::Null)); + put!("styles", serde_json::to_value(&n.styles).unwrap_or(Value::Null)); } if !n.bound.is_empty() { - o.insert("boundVariables".into(), json!(n.bound)); + put!("boundVariables", json!(n.bound)); } if !n.overrides.is_empty() { - o.insert("overrides".into(), json!(n.overrides)); + put!("overrides", json!(n.overrides)); } - if let Some(slot) = crate::components::slot_name(doc, n.id) { - o.insert("slot".into(), json!(slot)); + if let Some(slot) = options.includes("slot").then(|| crate::components::slot_name(doc, n.id)).flatten() { + put!("slot", json!(slot)); } // An operand combining by an operation of its own. - if let Some(op) = n.bool_op.filter(|_| crate::booleans::group_of(doc, n.id).is_some()) { - o.insert("operandOperation".into(), json!(op)); + if let Some(op) = n.bool_op.filter(|_| options.includes("operandOperation") && crate::booleans::group_of(doc, n.id).is_some()) { + put!("operandOperation", json!(op)); } } // Layer data written with `node.set {props: {pluginData}}` (pages too). if !n.plugin_data.is_empty() { - o.insert("pluginData".into(), json!(n.plugin_data)); + put!("pluginData", json!(n.plugin_data)); } match &n.kind { NodeKind::Text(t) => { - o.insert("characters".into(), json!(t.characters)); - o.insert("fontFamily".into(), json!(t.style.font_family)); - o.insert("fontStyle".into(), json!(t.style.font_style)); - o.insert("fontSize".into(), json!(t.style.font_size)); + put!( + "characters", + if options.outline { + let mut chars = t.characters.chars(); + let mut text: String = chars.by_ref().take(120).collect(); + if chars.next().is_some() { + text.push_str("..."); + } + json!(text) + } else { + json!(t.characters) + } + ); + put!("fontFamily", json!(t.style.font_family)); + put!("fontStyle", json!(t.style.font_style)); + put!("fontSize", json!(t.style.font_size)); if !t.style.variations.is_empty() { - o.insert("fontVariations".into(), json!(t.style.variations)); + put!("fontVariations", json!(t.style.variations)); } - o.insert("lineHeight".into(), json!(t.style.line_height.label())); - o.insert("letterSpacing".into(), json!(t.style.letter_spacing.label())); - o.insert("textAlign".into(), json!(t.align_h)); - o.insert("textAutoResize".into(), json!(t.auto_resize)); + put!("lineHeight", json!(t.style.line_height.label())); + put!("letterSpacing", json!(t.style.letter_spacing.label())); + put!("textAlign", json!(t.align_h)); + put!("textAutoResize", json!(t.auto_resize)); if t.max_lines > 0 { - o.insert("maxLines".into(), json!(t.max_lines)); + put!("maxLines", json!(t.max_lines)); } if let Some(tp) = &t.path { - o.insert("textPath".into(), json!(tp)); + put!("textPath", json!(tp)); } if t.style.decoration != TextDecoration::None { - o.insert("textDecoration".into(), json!(t.style.decoration)); + put!("textDecoration", json!(t.style.decoration)); } if let Some(l) = t.style.decoration_look.as_deref() { - o.insert("textDecorationStyle".into(), json!(l.style)); - o.insert("textDecorationThickness".into(), json!(l.thickness.label())); - o.insert("textDecorationOffset".into(), json!(l.offset.label())); - o.insert("textDecorationSkipInk".into(), json!(l.skip_ink)); - o.insert("textDecorationColor".into(), l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); + put!("textDecorationStyle", json!(l.style)); + put!("textDecorationThickness", json!(l.thickness.label())); + put!("textDecorationOffset", json!(l.offset.label())); + put!("textDecorationSkipInk", json!(l.skip_ink)); + put!("textDecorationColor", l.color.map_or(json!("auto"), |c| json!(format!("#{}", c.to_hex())))); } // Lists: one style for the layer, or each paragraph's type, nesting level and start. if t.paragraphs.is_empty() { if t.list != figcraft_doc::ListStyle::None { - o.insert("listStyle".into(), json!(t.list)); + put!("listStyle", json!(t.list)); } } else { - o.insert("lineTypes".into(), t.paragraphs.iter().map(|p| json!(p.list)).collect()); - o.insert("lineIndentations".into(), t.paragraphs.iter().map(|p| json!(p.indent)).collect()); + put!("lineTypes", t.paragraphs.iter().map(|p| json!(p.list)).collect()); + put!("lineIndentations", t.paragraphs.iter().map(|p| json!(p.indent)).collect()); if t.paragraphs.iter().any(|p| p.start_offset > 0) { - o.insert("listStartOffsets".into(), t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); + put!("listStartOffsets", t.paragraphs.iter().map(|p| json!(p.start_offset)).collect()); } } if t.list_spacing != 0.0 { - o.insert("listSpacing".into(), json!(t.list_spacing)); + put!("listSpacing", json!(t.list_spacing)); } for (k, on) in [("hangingPunctuation", t.hanging_punctuation), ("hangingList", t.hanging_list)] { if on { - o.insert(k.into(), json!(true)); + put!(k, json!(true)); } } if t.leading_trim != figcraft_doc::LeadingTrim::None { - o.insert("leadingTrim".into(), json!(t.leading_trim)); + put!("leadingTrim", json!(t.leading_trim)); } if !t.runs.is_empty() { - o.insert("styledRuns".into(), serde_json::to_value(&t.runs).unwrap_or(Value::Null)); + put!("styledRuns", serde_json::to_value(&t.runs).unwrap_or(Value::Null)); } // Chars linked to text styles: [start, end, style id] (the layer as a whole: `styles.text`). - let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); - if !linked.is_empty() { - o.insert("textStyleRanges".into(), Value::Array(linked)); + if options.includes("textStyleRanges") { + let linked: Vec = t.spans().into_iter().filter_map(|(a, b, s)| s.style_id.as_ref().map(|id| json!([a, b, id]))).collect(); + if !linked.is_empty() { + put!("textStyleRanges", Value::Array(linked)); + } } } NodeKind::Ellipse { start, sweep, inner } if (*sweep).abs() < std::f64::consts::TAU - 1e-9 || *inner > 0.0 => { - o.insert("arc".into(), json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner})); + put!("arc", json!({"start": start.to_degrees(), "sweep": sweep.to_degrees(), "inner": inner})); } NodeKind::Polygon { count } => { - o.insert("pointCount".into(), json!(count)); + put!("pointCount", json!(count)); } NodeKind::Star { count, inner_ratio } => { - o.insert("pointCount".into(), json!(count)); - o.insert("innerRadius".into(), json!(inner_ratio)); + put!("pointCount", json!(count)); + put!("innerRadius", json!(inner_ratio)); } NodeKind::BooleanOp { op } => { - o.insert("booleanOperation".into(), json!(op)); + put!("booleanOperation", json!(op)); } NodeKind::Instance { component, props } => { - o.insert("mainComponent".into(), json!(component)); - o.insert("mainComponentName".into(), json!(doc.node(*component).map(|c| c.name.clone()))); + put!("mainComponent", json!(component)); + put!("mainComponentName", json!(doc.node(*component).map(|c| c.name.clone()))); if !props.is_empty() { - o.insert("componentProperties".into(), json!(props)); + put!("componentProperties", json!(props)); } } NodeKind::Component { description, props } => { if !description.is_empty() { - o.insert("description".into(), json!(description)); + put!("description", json!(description)); } if !props.is_empty() { - o.insert("componentPropertyDefinitions".into(), serde_json::to_value(props).unwrap_or(Value::Null)); + put!("componentPropertyDefinitions", serde_json::to_value(props).unwrap_or(Value::Null)); } } NodeKind::Vector { network } => { - o.insert("vertices".into(), json!(network.vertices.len())); - o.insert("segments".into(), json!(network.segments.len())); + put!("vertices", json!(network.vertices.len())); + put!("segments", json!(network.segments.len())); } NodeKind::Page { background, flows, .. } => { - o.insert("background".into(), json!(background.to_css_hex())); + put!("background", json!(background.to_css_hex())); if !flows.is_empty() { - o.insert("flows".into(), serde_json::to_value(flows).unwrap_or(Value::Null)); + put!("flows", serde_json::to_value(flows).unwrap_or(Value::Null)); } } _ => {} } if !n.children.is_empty() { if depth > 0 { - o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json(doc, *c, depth - 1)).collect())); + o.insert("children".into(), Value::Array(n.children.iter().map(|c| node_json_with_options(doc, *c, depth - 1, options)).collect())); } else { o.insert("childCount".into(), json!(n.children.len())); } @@ -315,10 +496,20 @@ fn paints(p: &[figcraft_color::Paint]) -> Value { /// The whole document (current page in depth, other pages summarised). pub fn document(s: &Session, depth: usize, all_pages: bool) -> Value { + document_with_options(s, depth, all_pages, &InspectOptions::default()) +} + +/// The document metadata and a tree built with only the selected layer properties. +pub fn document_with_options(s: &Session, depth: usize, all_pages: bool, options: &InspectOptions) -> Value { let Ok(ds) = s.ds() else { return json!({"documents": 0}) }; let doc = &ds.doc; let pages: Vec = - doc.pages.iter().map(|p| if all_pages || *p == ds.page { node_json(doc, *p, depth) } else { node_json(doc, *p, 0) }).collect(); + doc.pages + .iter() + .map(|p| { + if all_pages || *p == ds.page { node_json_with_options(doc, *p, depth, options) } else { node_json_with_options(doc, *p, 0, options) } + }) + .collect(); json!({ "name": doc.name, "kind": doc.kind.id(), diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index 7ec63c698..8e9200996 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -228,9 +228,10 @@ pub fn specs() -> Vec { ok() }), spec!("node.set", "Set properties", super::props::KEYS, true, set), - spec!("node.get", "Get layer", "{id}", false, |s, p| { + spec!("node.get", "Get layer", "{id, fields?: [node keys], outline?: bool (not with fields)}", false, |s, p| { let i = req_id("node.get", p, "id")?; - Ok(crate::inspect::node_json(s.doc()?, i, 1)) + let options = crate::inspect::InspectOptions::parse(p).map_err(|e| bad("node.get", e))?; + Ok(crate::inspect::node_json_with_options(s.doc()?, i, 1, &options)) }), spec!( "node.reparent", diff --git a/crates/engine/src/inspect_fields_tests.rs b/crates/engine/src/inspect_fields_tests.rs new file mode 100644 index 000000000..3f619ac15 --- /dev/null +++ b/crates/engine/src/inspect_fields_tests.rs @@ -0,0 +1,479 @@ +//! Compact inspection keeps the tree useful without building every layer property. + +use figcraft_doc::{NodeId, NodeKind}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::inspect::{InspectOptions, document, document_with_options, node_json, node_json_with_options}; + +fn run(s: &mut Session, command: &str, params: Value) -> Value { + s.execute(command, ¶ms).unwrap_or_else(|e| panic!("{command}: {e}")) +} + +fn create(s: &mut Session, params: Value) -> NodeId { + crate::params::id_of(&run(s, "node.create", params)["id"]).unwrap() +} + +fn tree() -> (Session, NodeId, NodeId) { + let mut s = Session::new(); + let frame = create(&mut s, json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200})); + let text = create(&mut s, json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "x": 10, "y": 20})); + create(&mut s, json!({"type": "rectangle", "parent": frame, "name": "Hidden", "props": {"visible": false, "fill": "#123456"}})); + (s, frame, text) +} + +fn node(s: &Session, id: NodeId, depth: usize, params: Value) -> Value { + node_json_with_options(s.doc().unwrap(), id, depth, &InspectOptions::parse(¶ms).unwrap()) +} + +fn keys(v: &Value, allowed: &[&str]) { + for (key, value) in v.as_object().unwrap() { + assert!(allowed.contains(&key.as_str()), "unexpected {key}: {v}"); + if key == "children" { + for child in value.as_array().unwrap() { + keys(child, allowed); + } + } + } +} + +fn golden_tree() -> (Session, NodeId) { + let mut s = Session::new(); + let frame = create( + &mut s, + json!({"type": "frame", "name": "Card", "x": 100, "y": 200, "width": 300, "height": 200, + "props": {"layout": "horizontal", "padding": 8, "gap": 4, "fill": "#123456", "stroke": "#abcdef", "strokeWeight": 2, + "effects": [{"type": "dropShadow", "offset": [2, 3], "radius": 4}], "cornerRadius": 12}}), + ); + create( + &mut s, + json!({"type": "text", "parent": frame, "name": "Title", "text": "Hello", "width": 80, "height": 24, + "props": {"fontFamily": "Synthetic", "fontSize": 18, "fontStyle": "Bold", "letterSpacing": 1, "lineHeight": 24, + "textAlign": "center", "textDecoration": "underline", "fill": "#fedcba"}}), + ); + create( + &mut s, + json!({"type": "rectangle", "parent": frame, "name": "Hidden", "width": 20, "height": 30, + "props": {"visible": false, "locked": true, "fill": "#654321"}}), + ); + let main = create(&mut s, json!({"type": "component", "name": "Button", "x": 400, "y": 200, "width": 80, "height": 40})); + run(&mut s, "instance.create", json!({"component": main, "x": 500, "y": 200})); + run(&mut s, "page.add", json!({"name": "Second"})); + (s, frame) +} + +#[test] +fn empty_options_keep_the_main_tree_and_document_output() { + let (s, frame) = golden_tree(); + // Captured with this builder on origin/main (43af145a12eacd1e87f5f5e6984c49b7da040288). + let expected = json!({ + "colorProfile": "srgb", + "currentPage": 7, + "dirty": true, + "history": { + "redo": 0, + "undo": [ + "Add page", + "Create instance", + "Create layer", + "Create layer", + "Create layer", + "Create layer" + ] + }, + "images": 0, + "kind": "design", + "name": "Untitled", + "pages": [ + { + "background": "#F5F5F5", + "children": [ + { + "absoluteBounds": { + "height": 200.0, + "width": 300.0, + "x": 100.0, + "y": 200.0 + }, + "children": [ + { + "absoluteBounds": { + "height": 24.0, + "width": 80.0, + "x": 110.0, + "y": 210.0 + }, + "characters": "Hello", + "fills": [ + { + "color": "#FEDCBA", + "type": "solid" + } + ], + "fontFamily": "Synthetic", + "fontSize": 18.0, + "fontStyle": "Bold", + "height": 24.0, + "id": 3, + "letterSpacing": "1", + "lineHeight": "24", + "name": "Title", + "sizing": { + "horizontal": "fixed", + "vertical": "hug" + }, + "textAlign": "center", + "textAutoResize": "height", + "textDecoration": "underline", + "type": "TEXT", + "width": 80.0, + "x": 10.0, + "y": 10.0 + }, + { + "absoluteBounds": { + "height": 30.0, + "width": 20.0, + "x": 100.0, + "y": 200.0 + }, + "fills": [ + { + "color": "#654321", + "type": "solid" + } + ], + "height": 30.0, + "id": 4, + "locked": true, + "name": "Hidden", + "sizing": { + "horizontal": "fixed", + "vertical": "fixed" + }, + "type": "RECTANGLE", + "visible": false, + "width": 20.0, + "x": 0.0, + "y": 0.0 + } + ], + "clip": true, + "cornerRadius": 12.0, + "effects": [ + { + "blend": "normal", + "color": { + "a": 0.25, + "b": 0.0, + "g": 0.0, + "r": 0.0 + }, + "offset": [ + 2.0, + 3.0 + ], + "radius": 4.0, + "showBehind": false, + "spread": 0.0, + "type": "dropShadow", + "visible": true + } + ], + "fills": [ + { + "color": "#123456", + "type": "solid" + } + ], + "height": 200.0, + "id": 2, + "layout": { + "columnTracks": [], + "contentAlign": "auto", + "crossAlign": "min", + "crossGap": 10.0, + "gap": 4.0, + "gridColumns": 2, + "gridRows": 2, + "mainAlign": "min", + "mode": "horizontal", + "padding": [ + 8.0, + 8.0, + 8.0, + 8.0 + ], + "reverseZ": false, + "rowTracks": [], + "strokesIncluded": true, + "wrap": false + }, + "name": "Card", + "sizing": { + "horizontal": "fixed", + "vertical": "fixed" + }, + "strokeAlign": "inside", + "strokeWeight": 2.0, + "strokes": [ + { + "color": "#ABCDEF", + "type": "solid" + } + ], + "type": "FRAME", + "width": 300.0, + "x": 100.0, + "y": 200.0 + }, + { + "absoluteBounds": { + "height": 40.0, + "width": 80.0, + "x": 400.0, + "y": 200.0 + }, + "clip": true, + "fills": [ + { + "color": "#FFFFFF", + "type": "solid" + } + ], + "height": 40.0, + "id": 5, + "name": "Button", + "type": "COMPONENT", + "width": 80.0, + "x": 400.0, + "y": 200.0 + }, + { + "absoluteBounds": { + "height": 40.0, + "width": 80.0, + "x": 500.0, + "y": 200.0 + }, + "clip": true, + "fills": [ + { + "color": "#FFFFFF", + "type": "solid" + } + ], + "height": 40.0, + "id": 6, + "mainComponent": 5, + "mainComponentName": "Button", + "name": "Button", + "type": "INSTANCE", + "width": 80.0, + "x": 500.0, + "y": 200.0 + } + ], + "id": 1, + "name": "Page 1", + "type": "PAGE" + }, + { + "background": "#F5F5F5", + "id": 7, + "name": "Second", + "type": "PAGE" + } + ], + "path": null, + "selection": [], + "styles": [], + "textEdit": null, + "variables": 0 + }); + let expected_node = &expected["pages"][0]["children"][0]; + assert_eq!(node_json(s.doc().unwrap(), frame, 4).to_string(), expected_node.to_string()); + assert_eq!(document(&s, 4, true).to_string(), expected.to_string()); + for params in [json!({}), json!({"outline": false})] { + let options = InspectOptions::parse(¶ms).unwrap(); + assert_eq!(node_json_with_options(s.doc().unwrap(), frame, 4, &options).to_string(), expected_node.to_string()); + assert_eq!(document_with_options(&s, 4, true, &options).to_string(), expected.to_string()); + } +} + +#[test] +fn fields_filter_recursively_including_pages_without_changing_metadata_or_history() { + let (s, _, _) = tree(); + let before = document(&s, 4, true); + let selection = s.selection(); + let history = s.undo_depth(); + let options = InspectOptions::parse(&json!({"fields": ["name", "characters", "name"], "outline": false})).unwrap(); + let compact = document_with_options(&s, 4, true, &options); + let mut metadata = before.clone(); + metadata["pages"] = compact["pages"].clone(); + assert_eq!(compact, metadata); + keys(&compact["pages"][0], &["id", "name", "characters", "children", "childCount"]); + assert_eq!(compact["pages"][0]["children"][0]["children"][0]["characters"], "Hello"); + assert_eq!(s.selection(), selection); + assert_eq!(s.undo_depth(), history); + assert_eq!(document(&s, 4, true), before); +} + +#[test] +fn an_empty_field_list_keeps_ids_and_structure_at_depth_boundaries() { + let (s, frame, _) = tree(); + let compact = node(&s, frame, 4, json!({"fields": []})); + keys(&compact, &["id", "children", "childCount"]); + assert_eq!(compact["children"].as_array().unwrap().len(), 2); + assert_eq!(node(&s, frame, 0, json!({"fields": []})), json!({"id": frame, "childCount": 2})); + let options = InspectOptions::parse(&json!({"fields": []})).unwrap(); + let summary = document_with_options(&s, 0, false, &options); + assert_eq!(summary["pages"][0], json!({"id": s.page().unwrap(), "childCount": 1})); +} + +#[test] +fn geometry_keys_are_independently_selectable() { + let (s, frame, text) = tree(); + let fields = ["x", "y", "width", "height", "absoluteBounds"]; + let compact = node(&s, frame, 2, json!({"fields": fields})); + keys(&compact, &["id", "children", "childCount", "x", "y", "width", "height", "absoluteBounds"]); + assert_eq!((compact["x"].as_f64(), compact["y"].as_f64()), (Some(100.0), Some(200.0))); + assert_eq!(compact["children"][0]["x"], 10.0); + assert_eq!(compact["children"][0]["absoluteBounds"]["x"], 110.0); + for key in fields { + let one = node(&s, text, 0, json!({"fields": [key]})); + assert_eq!(one.as_object().unwrap().len(), 2, "{one}"); + assert_eq!(one[key], compact["children"][0][key]); + } +} + +#[test] +fn outline_truncates_only_after_120_unicode_scalars() { + let (mut s, _, text) = tree(); + for unit in ["a", "界", "\u{1f980}"] { + for len in [0, 119, 120, 121] { + let content = unit.repeat(len); + s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().characters = content.clone(); + let outline = node(&s, text, 0, json!({"outline": true})); + let want = if len > 120 { format!("{}...", unit.repeat(120)) } else { content.clone() }; + assert_eq!(outline["characters"], want); + assert_eq!(node(&s, text, 0, json!({"fields": ["characters"]}))["characters"], content); + keys(&outline, &["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount"]); + } + } +} + +#[test] +fn selecting_visible_makes_it_explicit_for_every_node_including_pages() { + let (s, frame, _) = tree(); + for params in [json!({"fields": ["visible"]}), json!({"outline": true})] { + let options = InspectOptions::parse(¶ms).unwrap(); + let tree = document_with_options(&s, 4, true, &options); + assert_eq!(tree["pages"][0]["visible"], true); + let card = &tree["pages"][0]["children"][0]; + assert_eq!(card["visible"], true); + assert_eq!(card["children"][0]["visible"], true); + assert_eq!(card["children"][1]["visible"], false); + } + let no_visible = node(&s, frame, 4, json!({"fields": ["name"]})); + assert!(no_visible["children"][1].get("visible").is_none()); +} + +#[test] +fn component_names_are_selected_without_other_instance_properties_even_with_a_missing_main() { + let mut s = Session::new(); + let main = create(&mut s, json!({"type": "component", "name": "Button"})); + let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap(); + for params in [json!({"fields": ["mainComponentName"]}), json!({"outline": true})] { + let compact = node(&s, instance, 0, params); + assert_eq!(compact["mainComponentName"], "Button"); + assert!(compact.get("mainComponent").is_none()); + } + if let NodeKind::Instance { component, .. } = &mut s.doc_mut().unwrap().get_mut(instance).unwrap().kind { + *component = NodeId(u64::MAX); + } + let compact = node(&s, instance, 0, json!({"outline": true})); + assert_eq!(compact.get("mainComponentName"), Some(&Value::Null)); +} + +#[test] +fn page_properties_and_other_page_summaries_respect_fields() { + let (mut s, _, _) = tree(); + let first = s.page().unwrap(); + run(&mut s, "node.set", json!({"ids": [first], "props": {"pluginData": {"source": "synthetic"}}})); + run(&mut s, "page.add", json!({"name": "Second"})); + let options = InspectOptions::parse(&json!({"fields": ["pluginData", "background"]})).unwrap(); + let compact = document_with_options(&s, 4, false, &options); + assert_eq!(compact["pages"].as_array().unwrap().len(), 2); + let page = &compact["pages"][0]; + keys(page, &["id", "pluginData", "background", "childCount", "children"]); + assert_eq!(page["pluginData"]["source"], "synthetic"); + assert_eq!(page["childCount"], 1); + assert!(page["background"].is_string()); + assert!(page.get("name").is_none()); +} + +#[test] +fn the_catalogue_accepts_emitted_keys_and_structure_keys() { + let (s, frame, _) = tree(); + let full = node_json(s.doc().unwrap(), frame, 2); + fn check(s: &Session, full: &Value) { + let id = crate::params::id_of(&full["id"]).unwrap(); + for (key, value) in full.as_object().unwrap() { + let selected = node(s, id, 2, json!({"fields": [key]})); + if key == "children" { + assert_eq!(selected[key].as_array().map(Vec::len), value.as_array().map(Vec::len)); + } else { + assert_eq!(&selected[key], value, "{key}"); + } + } + for child in full["children"].as_array().into_iter().flatten() { + check(s, child); + } + } + check(&s, &full); + let structural = node(&s, frame, 0, json!({"fields": ["id", "children", "childCount"]})); + assert_eq!(structural, json!({"id": frame, "childCount": 2})); +} + +#[test] +fn invalid_options_are_errors_and_unknown_names_list_the_catalogue() { + for params in [ + json!({"fields": null}), + json!({"fields": "name"}), + json!({"fields": {"name": true}}), + json!({"fields": [false]}), + json!({"fields": [null]}), + json!({"fields": ["name", 1]}), + json!({"outline": null}), + json!({"outline": "true"}), + json!({"outline": 1}), + json!({"outline": []}), + json!({"outline": true, "fields": []}), + json!({"outline": true, "fields": ["name"]}), + ] { + assert!(InspectOptions::parse(¶ms).is_err(), "{params}"); + } + for key in ["Name", "absolute_bounds", "noSuchField", "truncated"] { + let error = InspectOptions::parse(&json!({"fields": [key]})).unwrap_err(); + assert!(error.contains(key), "{error}"); + for valid in crate::inspect::NODE_KEYS { + assert!(error.contains(valid), "missing {valid}: {error}"); + } + } +} + +#[test] +fn node_get_uses_options_but_keeps_its_existing_depth_and_selection() { + let (mut s, frame, _) = tree(); + let selection = s.selection(); + let history = s.undo_depth(); + let default = node_json(s.doc().unwrap(), frame, 1); + assert_eq!(run(&mut s, "node.get", json!({"id": frame})), default); + let compact = run(&mut s, "node.get", json!({"id": frame, "fields": [], "depth": 0})); + assert!(compact["children"].is_array(), "the engine command still inspects to depth 1"); + keys(&compact, &["id", "children", "childCount"]); + assert!(s.execute("node.get", &json!({"id": frame, "outline": true, "fields": []})).is_err()); + assert_eq!(s.selection(), selection); + assert_eq!(s.undo_depth(), history); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 60f240cb0..2ea51f176 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -304,6 +304,8 @@ mod import_journal_tests; #[cfg(test)] mod inspect_behaviour_tests; #[cfg(test)] +mod inspect_fields_tests; +#[cfg(test)] mod instance_modes_tests; #[cfg(test)] mod instance_resizing_tests; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 811724f2d..729611e51 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -454,6 +454,12 @@ fn every_command_survives_junk_params() { // would write to it.) json!({"ids": [2, 3], "type": "vector", "path": [5], "width": -1e308, "props": {"path": "M0 0 L1e308 0 L-1e308 1e308", "height": "NaN", "x": 1e308}}), json!({"id": 2, "type": "vector", "path": {"d": 1}, "props": {"path": "M NaN 0 Z", "width": 1e-308}}), + // Compact inspection: fields and outline with malformed types and unknown keys. + json!({"id": 2, "fields": "name", "outline": true}), + json!({"id": 2, "fields": ["name", 5]}), + json!({"id": 2, "fields": ["Name", "noSuchField"]}), + json!({"id": 2, "fields": null, "outline": []}), + json!({"id": 2, "outline": "yes"}), // Batch step names and reference paths as junk. json!({"commands": [{"command": "select.none", "as": "a"}, {"command": "select.none", "as": [1]}, {"command": "select.none", "as": "a"}]}), json!({"commands": [{"command": "select.all", "as": "\u{0}"}, {"command": "node.set", "params": {"ids": [ diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index fcdb21a35..a685b8fb6 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -250,7 +250,8 @@ impl Backend for Headless { )), "document.inspect" => { let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize; - Ok(figcraft_engine::inspect::document(&self.session, depth, b(&p, "allPages"))) + let options = figcraft_engine::inspect::InspectOptions::parse(&p)?; + Ok(figcraft_engine::inspect::document_with_options(&self.session, depth, b(&p, "allPages"), &options)) } "node.get" => { let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required (a node id)")?; @@ -259,7 +260,8 @@ impl Backend for Headless { if !doc.contains(id) { return Err(format!("no such node {id}")); } - Ok(figcraft_engine::inspect::node_json(doc, id, depth)) + let options = figcraft_engine::inspect::InspectOptions::parse(&p)?; + Ok(figcraft_engine::inspect::node_json_with_options(doc, id, depth, &options)) } "ui.inspect" => Ok(self.inspect_ui()), "ui.pointer" => self.pointer(&p), diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index 1b23191e9..42c68d395 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -64,6 +64,15 @@ fn id_schema(desc: &str) -> Value { fn ids_schema(desc: &str) -> Value { json!({"type": "array", "description": desc, "items": {"anyOf": [{"type": "integer"}, {"type": "string"}]}}) } +fn fields_schema() -> Value { + json!({"type": "array", "items": {"type": "string", "enum": figcraft_engine::inspect::NODE_KEYS}, + "description": "Exact, case-sensitive top-level node keys to include at every level, pages too (e.g. name, type, x, fills, characters, pluginData). Omit for all properties; [] keeps only ids and structure. id, children, childCount and the transport's truncated marker are always kept. Selecting visible emits an explicit boolean for every node. Unknown keys are an error."}) +} +fn outline_schema() -> Value { + boolean( + "Compact tree: id, name, type, characters (first 120 characters, then ... only if longer), mainComponentName and explicit visible booleans, plus structure. Cannot combine true with fields; false permits fields.", + ) +} fn obj(props: Value, required: &[&str]) -> Value { let mut o = json!({"type": "object", "properties": props}); if !required.is_empty() { @@ -177,6 +186,8 @@ pub fn tool_definitions() -> Vec { obj( json!({ "depth": int("Tree depth for the current page (default 4)"), + "fields": fields_schema(), + "outline": outline_schema(), "allPages": boolean("Expand every page, not only the current one"), "maxBytes": int( "Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id" @@ -194,6 +205,8 @@ pub fn tool_definitions() -> Vec { json!({ "id": id_schema("Layer id"), "depth": int("Child depth (default 1)"), + "fields": fields_schema(), + "outline": outline_schema(), "offset": int("First child to report (default 0), for reading a node with more children than fit in one message"), "maxBytes": int( "Largest response to send, in bytes of the whole JSON-RPC message (default 8388608, which fits the usual 10 MiB client buffer); 1024 of it is kept for the envelope and the request id" @@ -625,19 +638,31 @@ fn wire_size(value: &Value) -> usize { /// The document at `depth`, and what its text block would take on the wire. The tree is sent as a /// text block, so it is measured pretty-printed *and* escaped -- escaping adds about a tenth. -fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool) -> Result<(Value, usize), String> { - let value = b.call("document.inspect", json!({"depth": depth, "allPages": all}))?; +fn inspect_at(b: &mut dyn Backend, depth: u64, all: bool, options: &Value) -> Result<(Value, usize), String> { + let mut params = options.clone(); + params["depth"] = json!(depth); + params["allPages"] = json!(all); + let value = b.call("document.inspect", params)?; let wire = wire_size(&value); Ok((value, wire)) } /// One layer's subtree at `depth`, and what its text block would take on the wire. -fn node_at(b: &mut dyn Backend, id: u64, depth: u64) -> Result<(Value, usize), String> { - let value = b.call("node.get", json!({"id": id, "depth": depth}))?; +fn node_at(b: &mut dyn Backend, id: u64, depth: u64, options: &Value) -> Result<(Value, usize), String> { + let mut params = options.clone(); + params["id"] = json!(id); + params["depth"] = json!(depth); + let value = b.call("node.get", params)?; let wire = wire_size(&value); Ok((value, wire)) } +fn inspection_options(a: &Map) -> Result { + let options = Value::Object(a.iter().filter(|(k, _)| matches!(k.as_str(), "fields" | "outline")).map(|(k, v)| (k.clone(), v.clone())).collect()); + figcraft_engine::inspect::InspectOptions::parse(&options)?; + Ok(options) +} + fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result { let depth = n_arg(a, "depth")?.unwrap_or(4.0).clamp(0.0, 64.0) as u64; let all = match a.get("allPages") { @@ -645,9 +670,10 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result *x, Some(_) => return Err("`allPages` must be a boolean".into()), }; + let options = inspection_options(a)?; let (max_bytes, budget) = budget(a)?; - let (value, wire) = inspect_at(b, depth, all)?; + let (value, wire) = inspect_at(b, depth, all, &options)?; if wire <= budget { return Ok(ToolResult::json(&value)); } @@ -665,7 +691,7 @@ fn inspect_document(b: &mut dyn Backend, a: &Map) -> Result = None; while lo <= hi { let mid = lo + (hi - lo) / 2; - let (v, w) = inspect_at(b, mid, all)?; + let (v, w) = inspect_at(b, mid, all, &options)?; if w + MARKER_RESERVE <= budget { best = Some((mid, v, w)); if mid == hi { @@ -714,9 +740,10 @@ fn get_node(b: &mut dyn Backend, a: &Map) -> Result) -> Result(created["content"][0]["text"].as_str().unwrap()).unwrap()["id"].clone(); + let r = call(&mut s, "get_node", json!({"id": id, "outline": true})); + assert_eq!(r["isError"], false, "{r}"); + let outline: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(outline["characters"], format!("{}...", "界".repeat(120))); + assert_eq!(outline["visible"], true); + assert!(outline.get("fontSize").is_none()); + let r = call(&mut s, "inspect_document", json!({"fields": [], "outline": false})); + assert_eq!(r["isError"], false, "{r}"); + let compact: Value = serde_json::from_str(r["content"][0]["text"].as_str().unwrap()).unwrap(); + assert_eq!(compact["pages"][0]["children"][0], json!({"id": id})); + assert!(compact["pages"][0].get("name").is_none()); +} + #[test] fn attached_to_nothing_fails_cleanly() { let port = TcpListener::bind("127.0.0.1:0").unwrap().local_addr().unwrap().port(); diff --git a/crates/mcp/tests/it/inspect_fields.rs b/crates/mcp/tests/it/inspect_fields.rs new file mode 100644 index 000000000..5d930ba22 --- /dev/null +++ b/crates/mcp/tests/it/inspect_fields.rs @@ -0,0 +1,205 @@ +//! Compact trees are built by the backend, including every size-budget retry. + +use crate::common::Client; +use figcraft_mcp::{Backend, Headless, call_tool}; +use serde_json::{Value, json}; + +fn tree(mut create: impl FnMut(Value) -> Value) -> Value { + let root = create(json!({"type": "frame", "name": "Product list", "width": 1200, "height": 2400})); + for i in 0..24 { + let card = create(json!({"type": "frame", "parent": root, "name": format!("Product card {i}"), "width": 280, "height": 160, + "props": {"fill": "#F0F0F0", "cornerRadius": 12, "pluginData": {"source": "synthetic".repeat(40)}}})); + for j in 0..6 { + create(json!({"type": "text", "parent": card, "name": format!("Description {j}"), "text": "Synthetic product description. ".repeat(8), + "x": 12, "y": j * 20, "width": 240})); + } + } + root +} + +fn compact_keys(node: &Value, outline: bool) { + let allowed: &[&str] = if outline { + &["id", "name", "type", "characters", "mainComponentName", "visible", "children", "childCount", "truncated"] + } else { + &["id", "name", "children", "childCount", "truncated"] + }; + for key in node.as_object().unwrap().keys() { + assert!(allowed.contains(&key.as_str()), "unexpected {key}: {node}"); + } + for child in node["children"].as_array().into_iter().flatten() { + compact_keys(child, outline); + } +} + +#[test] +fn tool_schemas_describe_compact_inspection_options() { + let mut c = Client::new(); + let tools = c.rpc("tools/list", json!({})); + for name in ["inspect_document", "get_node"] { + let tool = tools["result"]["tools"].as_array().unwrap().iter().find(|t| t["name"] == name).unwrap(); + let props = &tool["inputSchema"]["properties"]; + assert_eq!(props["fields"]["type"], "array"); + assert_eq!(props["fields"]["items"]["type"], "string"); + assert!(props["fields"]["items"]["enum"].as_array().unwrap().contains(&json!("absoluteBounds"))); + assert_eq!(props["outline"]["type"], "boolean"); + assert!(props["fields"]["description"].as_str().unwrap().contains("case-sensitive")); + assert!(props["outline"]["description"].as_str().unwrap().contains("120")); + } +} + +#[test] +fn a_realistic_nested_tree_is_much_smaller_with_fields_or_outline() { + let mut c = Client::new(); + let root = tree(|params| c.create(params)); + for (tool, mut args) in [("inspect_document", json!({"depth": 6})), ("get_node", json!({"id": root, "depth": 6}))] { + let full = c.ok(tool, args.clone()); + let full_bytes = full.to_string().len(); + args["outline"] = json!(true); + let outline = c.ok(tool, args.clone()); + assert!(outline.to_string().len() * 2 < full_bytes, "{tool}: outline {} vs full {full_bytes}", outline.to_string().len()); + args.as_object_mut().unwrap().remove("outline"); + args["fields"] = json!([]); + let ids = c.ok(tool, args); + assert!(ids.to_string().len() * 5 < full_bytes, "{tool}: ids {} vs full {full_bytes}", ids.to_string().len()); + if tool == "inspect_document" { + assert_eq!(outline["history"], full["history"]); + assert_eq!(ids["selection"], full["selection"]); + compact_keys(&outline["pages"][0], true); + } else { + compact_keys(&outline, true); + } + } +} + +struct Recording { + inner: Headless, + calls: Vec<(String, Value)>, +} + +impl Backend for Recording { + fn call(&mut self, method: &str, params: Value) -> Result { + self.calls.push((method.into(), params.clone())); + self.inner.call(method, params) + } + fn has_ui(&self) -> bool { + false + } + fn describe(&self) -> String { + "recording headless".into() + } +} + +#[test] +fn fields_and_outline_survive_every_depth_retry() { + let mut backend = Recording { inner: Headless::new(), calls: Vec::new() }; + let root = tree(|params| { + let result = call_tool(&mut backend, "create_node", ¶ms); + assert!(!result.is_error, "{}", result.first_text()); + serde_json::from_str::(result.first_text()).unwrap()["id"].clone() + }); + for options in [json!({"fields": ["name"], "outline": false}), json!({"outline": true})] { + for (tool, method) in [("inspect_document", "document.inspect"), ("get_node", "node.get")] { + backend.calls.clear(); + let mut args = options.clone(); + args["id"] = root.clone(); + args["depth"] = json!(6); + args["maxBytes"] = json!(6000); + let result = call_tool(&mut backend, tool, &args); + assert!(!result.is_error, "{}", result.first_text()); + let value: Value = serde_json::from_str(result.first_text()).unwrap(); + assert!(value["truncated"].is_object(), "{tool}: {value}"); + assert!(backend.calls.len() > 1, "must exercise retries: {tool}"); + for (called, params) in &backend.calls { + assert_eq!(called, method); + for key in ["fields", "outline"] { + assert_eq!(params.get(key), options.get(key), "{tool}: {params}"); + } + } + let outline = options["outline"] == true; + if tool == "inspect_document" { + compact_keys(&value["pages"][0], outline); + } else { + compact_keys(&value, outline); + } + } + } +} + +#[test] +fn compact_children_keep_window_markers_and_page_in_order() { + let mut c = Client::new(); + let root = tree(|params| c.create(params)); + let full = c.ok("get_node", json!({"id": root, "depth": 1, "fields": []})); + let expected = full["children"].as_array().unwrap(); + let wire = serde_json::to_string(&serde_json::to_string_pretty(&full).unwrap()).unwrap().len(); + let limit = wire + 1024 - 1; + assert!(limit > 2048, "room for both the envelope and the marker"); + let mut seen = Vec::new(); + let mut offset = Some(0); + let mut pages = 0; + while let Some(at) = offset { + let page = c.ok("get_node", json!({"id": root, "depth": 1, "fields": [], "offset": at, "maxBytes": limit})); + assert!(page["truncated"]["children"].is_object(), "{page}"); + let wire = serde_json::to_string(&serde_json::to_string_pretty(&page).unwrap()).unwrap().len(); + assert!(wire + 1024 <= limit, "page at {at} exceeds maxBytes: {wire} + envelope > {limit}"); + assert_eq!(page["truncated"]["children"]["from"], at); + seen.extend(page["children"].as_array().unwrap().iter().cloned()); + offset = page["truncated"]["children"]["nextOffset"].as_u64(); + pages += 1; + assert!(pages <= expected.len()); + } + assert!(pages > 1, "the fixture must require a child window"); + assert_eq!(&seen, expected); +} + +fn json_example(section: &str) -> &str { + section.split("```json\n").nth(1).unwrap().split("```").next().unwrap() +} + +#[test] +fn compact_examples_run_verbatim_from_the_docs() { + let mut c = Client::new(); + for _ in 0..2 { + c.create(json!({"type": "rectangle"})); + } + let docs = include_str!("../../../../docs/mcp.md"); + let section = docs.split("### Compact layer trees\n").nth(1).unwrap(); + for line in json_example(section).lines() { + let reply: Value = serde_json::from_str(&c.server.handle_line(line).unwrap()).unwrap(); + assert_eq!(reply["result"]["isError"], false, "{reply}"); + } + let mut h = Headless::new(); + for _ in 0..2 { + h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap(); + } + let docs = include_str!("../../../../docs/control-protocol.md"); + let section = docs.split("### `node.get ").nth(1).unwrap(); + for line in json_example(section).lines() { + let request: Value = serde_json::from_str(line).unwrap(); + h.call(request["method"].as_str().unwrap(), request["params"].clone()).unwrap(); + } +} + +#[test] +fn invalid_compact_options_fail_in_tools_and_headless_control() { + let mut c = Client::new(); + let id = c.create(json!({"type": "rectangle"})); + let mut h = Headless::new(); + let created = h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap(); + assert_eq!(created["id"], id); + for mut args in [ + json!({"fields": [1]}), + json!({"fields": null}), + json!({"fields": ["Name"]}), + json!({"outline": "yes"}), + json!({"outline": true, "fields": []}), + ] { + args["id"] = id.clone(); + for tool in ["inspect_document", "get_node"] { + assert!(!c.err(tool, args.clone()).is_empty()); + } + for method in ["document.inspect", "node.get"] { + assert!(h.call(method, args.clone()).is_err(), "{method}: {args}"); + } + } +} diff --git a/crates/mcp/tests/it/main.rs b/crates/mcp/tests/it/main.rs index 23d23ec99..420fb4c59 100644 --- a/crates/mcp/tests/it/main.rs +++ b/crates/mcp/tests/it/main.rs @@ -15,6 +15,8 @@ mod export_xml; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod garbage; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod inspect_fields; +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod inspect_response_limit; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod overlay_anchor; diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index afef387d3..f2669035d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -950,12 +950,14 @@ pub fn handle(app: &mut FigApp, ctx: &Context, method: &str, p: &Value) -> Resul )), "document.inspect" => { let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(8).min(64) as usize; - Ok(figcraft_engine::inspect::document(&app.session, depth, b(p, "allPages"))) + let options = figcraft_engine::inspect::InspectOptions::parse(p)?; + Ok(figcraft_engine::inspect::document_with_options(&app.session, depth, b(p, "allPages"), &options)) } "node.get" => { let id = p.get("id").and_then(figcraft_engine::params::id_of).ok_or("`id` required")?; let depth = p.get("depth").and_then(Value::as_u64).unwrap_or(1) as usize; - app.session.doc().map(|d| figcraft_engine::inspect::node_json(d, id, depth)).map_err(|e| e.to_string()) + let options = figcraft_engine::inspect::InspectOptions::parse(p)?; + app.session.doc().map(|d| figcraft_engine::inspect::node_json_with_options(d, id, depth, &options)).map_err(|e| e.to_string()) } "window.new" => { let index = p.get("index").and_then(Value::as_u64).map(|i| i as usize); diff --git a/crates/ui-egui/src/inspect_fields_tests.rs b/crates/ui-egui/src/inspect_fields_tests.rs new file mode 100644 index 000000000..102e232b3 --- /dev/null +++ b/crates/ui-egui/src/inspect_fields_tests.rs @@ -0,0 +1,48 @@ +//! Attached control replies build compact layer trees before they leave the app. + +use figcraft_engine::Session; +use serde_json::{Value, json}; + +use crate::layers_drag_tests::Ui; + +fn call(u: &mut Ui, method: &str, params: Value) -> Result { + crate::control::handle(&mut u.app, &u.ctx.clone(), method, ¶ms) +} + +#[test] +fn control_inspection_filters_nodes_and_pages_before_replying() { + let mut u = Ui::new(Session::new()); + let frame = call(&mut u, "engine.execute", json!({"command": "node.create", "params": {"type": "frame", "name": "Card"}})).unwrap()["id"].clone(); + let text = + call(&mut u, "engine.execute", json!({"command": "node.create", "params": {"type": "text", "parent": frame, "text": "界".repeat(121)}})) + .unwrap()["id"] + .clone(); + let default = call(&mut u, "document.inspect", json!({"depth": 3})).unwrap(); + let selection = u.app.session.selection(); + let history = u.app.session.undo_depth(); + let compact = call(&mut u, "document.inspect", json!({"depth": 3, "fields": []})).unwrap(); + let page = &compact["pages"][0]; + assert!(page.get("name").is_none()); + assert_eq!(page["children"][0]["children"][0], json!({"id": text})); + let shallow = call(&mut u, "node.get", json!({"id": frame, "depth": 0, "fields": []})).unwrap(); + assert_eq!(shallow, json!({"id": frame, "childCount": 1})); + let outline = call(&mut u, "node.get", json!({"id": frame, "depth": 1, "outline": true})).unwrap(); + assert_eq!(outline["children"][0]["characters"], format!("{}...", "界".repeat(120))); + assert_eq!(outline["visible"], true); + assert!(outline.get("fills").is_none()); + assert_eq!(u.app.session.selection(), selection); + assert_eq!(u.app.session.undo_depth(), history); + assert_eq!(call(&mut u, "document.inspect", json!({"depth": 3})).unwrap(), default); +} + +#[test] +fn control_inspection_rejects_invalid_options_consistently() { + let mut u = Ui::new(Session::new()); + for method in ["document.inspect", "node.get"] { + for mut params in [json!({"fields": ["Name"]}), json!({"fields": [2]}), json!({"outline": "true"}), json!({"outline": true, "fields": []})] { + params["id"] = json!(u.app.session.page().unwrap()); + let error = call(&mut u, method, params).unwrap_err(); + assert!(error.contains("fields") || error.contains("field") || error.contains("outline"), "{error}"); + } + } +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1eb714e9a..ae84e2311 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -96,6 +96,8 @@ mod ime_tests; #[cfg(test)] mod import_report_tests; #[cfg(test)] +mod inspect_fields_tests; +#[cfg(test)] mod layers_behaviour_tests; #[cfg(test)] mod layers_drag_tests; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 448a9979d..4def3afac 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -123,14 +123,15 @@ it and duplicate it: Every command: `[{id, label, params, menu, shortcut, undoable}]`. -### `document.inspect {depth?, allPages?}` +### `document.inspect {depth?, allPages?, fields?, outline?}` The active document as JSON: name, path, dirty, currentPage, selection, pages (the current page's layer tree to `depth`, default 8; others summarised unless `allPages`), styles, variable count, images, the colour profile (`colorProfile`: `srgb` or `displayP3`), the text being edited and the -undo history. +undo history. `fields` and `outline` select layer properties at every level, pages included, without +changing document metadata (see below). -### `node.get {id, depth?}` +### `node.get {id, depth?, fields?, outline?}` One layer (children to `depth`, default 1). Fields: id, name, type, x, y, width, height, rotation, absoluteBounds, visible, locked, opacity, blend, fills, strokes, strokeWeight, effects, cornerRadius, @@ -147,6 +148,25 @@ starting from the layer: `[{to} | {side}]`), `pluginData` (layer data written wi only when there is some). The auto layout props that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-know). +Both inspection methods accept `fields: [string]`: exact, case-sensitive top-level node keys such +as `name`, `type`, `x`, `fills`, `characters` or `pluginData`, applied recursively to pages too. +`[]` keeps only ids and structure. `id`, `children` and depth-boundary `childCount` are always kept. +Unknown keys are errors listing the valid keys; wrong types are +errors too. + +`outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus +structure. `characters` stops at 120 characters, with ASCII `...` appended only when +longer. When `visible` is selected (by either option), every node has an explicit boolean; +without options the output stays sparse. `outline: true` with `fields` is an error; +`outline: false` with `fields` is allowed. Document metadata is unchanged. The engine command +`node.get` (not this control method) takes the same options but no `depth`: it always returns one +level of children. + +```json +{"id":5,"method":"document.inspect","params":{"depth":6,"outline":true}} +{"id":6,"method":"node.get","params":{"id":3,"depth":2,"fields":["name","width","height"]}} +``` + ### `ui.inspect {}` UI state: panels and tabs (`panels`: the `left` and `right` panels' widths as asked for and whether the left one is closed, `leftCollapsed`), the cursor the canvas shows (`cursor`: a CSS cursor name such as `default`, `crosshair`, `ew-resize`, `grab`, `copy`, `not-allowed`, `zoom-in`, or one FigCraft draws: `rotate `, `pen`, `pen-close`, `pen-add`, `pen-bend`, `eyedropper`, `comment`; `null` while the pointer is elsewhere or over something with a cursor of its own, a ruler guide or a gradient handle; the engine's own hint is `tool.overlay.cursor`), current tool (`tool`: its id, overlay hints and, for a selected diff --git a/docs/mcp.md b/docs/mcp.md index 05dfd1e61..3027d61df 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -312,8 +312,8 @@ Or in a project's `.mcp.json`: | `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | -| `inspect_document {depth, allPages}` | The document as JSON (layer tree, styles, selection, history). | -| `get_node {id, depth}` | One layer as JSON; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | +| `inspect_document {depth, allPages, fields?, outline?}` | The document as JSON (layer tree, styles, selection, history); `fields` selects layer properties, `outline: true` gives a compact tree (below). | +| `get_node {id, depth, fields?, outline?}` | One layer as JSON; takes the same `fields` and `outline` options; `pluginData` (`{key: value}`, values as strings) when the layer or page has layer data written with `set_properties {props: {pluginData}}`. | | `select {ids}` | Replace the selection (`[]` deselects). | | `export {ids, format, scale \| size, path?, bleed?, cropMarks?, bleedMarks?, cmyk?, contentsOnly?, includeBoundingBox?, svgOutlineText?, svgIdAttribute?, svgSimplifyStroke?, vdCompat?}` | png, jpg, svg, webp, pdf, xml (an Android Vector Drawable: `svgIdAttribute` writes `android:name`, `vdCompat` is `bestEffort` (default: approximated with a warning), `exact` (left out with a warning) or `ignore` (no warnings: overlapping layer opacity dropped, even-odd fills kept as `fillType="evenOdd"`, diamond gradients left out), and the result lists `warnings` for what was changed or left out), or `fig`: the whole file as `.fig`, experimental (see Exporting `.fig` files below); `size` is `2x`, `512w` or `512h`. The image covers what the layers paint (shadows towards their offset, strokes, unclipped children, a mask group's mask; hidden layers take no room; a slice is its area with everything under it, or only the frame or group it sits in while `contentsOnly` is on); `contentsOnly: false` also draws every other layer showing in that area, `includeBoundingBox` exports the layers' own boxes; SVG: `svgOutlineText: false` keeps ``, `svgIdAttribute` writes unique ids from layer names, `svgSimplifyStroke: false` writes every stroke as an outline. The same options go in export settings (`export.add` / `export.update`). PDF: frames one page each (sized in points), anything else together on one page; print options: `bleed` (points or a length like `"3mm"`, `"0.125in"`), crop and bleed marks, CMYK colour (see Exports below), also taken by `file.export`, `file.exportLayers`, `file.exportFramesPdf` and stored per export setting by `export.add` / `export.update`; `run_command` `file.exportFramesPdf {page?, path?}` writes every top-level frame of a page. Without `path`, images come back as MCP image content, SVG as text, XML as text followed by `{format, bytes, warnings}`. Layers' own export settings: `run_command` with `export.add` / `export.update {index, size?, suffix?, format?}` / `export.remove` and `file.exportLayers {ids?, dir?, assets?}` (every layer once per setting, named `.`, a slash in a layer name making a folder; `assets: true` exports the Inspect tab's assets instead: icons as SVG, images as PNG). | | `screenshot {ids?, scale?, maxSize?, window?}` | PNG of the page (with background) or of layers, scaled to fit `maxSize` (default 1600). `window: true` captures the app window in front when attached, a window id (see Windows below) that window. | @@ -324,6 +324,26 @@ Or in a project's `.mcp.json`: | `type_text {text, id?}` | Type into the text being edited, or replace the contents of text layer `id`. | | `inspect_ui {}` | Tool, view and status (and panels, timings and the app's `windows` when attached). | +### Compact layer trees + +`inspect_document` and `get_node` accept `fields`, an array of exact, case-sensitive top-level node +keys, applied to every layer and page. For example, `fields: ["name", "type", "width", "height"]` +leaves out paints and text properties; `fields: []` keeps only ids and structure. `id`, `children`, +depth-boundary `childCount` and the transport's `truncated` marker are always kept (`truncated` is +not a selectable field). Unknown keys or wrong types +are errors; an unknown key's error lists the valid keys. Document metadata stays unchanged. + +`outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus +structure. Text is cut after 120 characters, with ASCII `...` only when longer. Selecting +`visible`, explicitly or through outline, reports a boolean even for visible layers and pages; +without these options the output stays sparse. `outline: true` cannot be combined with `fields`; +`outline: false` can. Both options keep the existing depth and response-size limits. + +```json +{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"inspect_document","arguments":{"depth":6,"outline":true}}} +{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_node","arguments":{"id":3,"depth":2,"fields":["name","width","height"]}}} +``` + ### Starter templates An agent can start from a template instead of an empty page. `run_command {command: "template.list"}` diff --git a/docs/roadmap.md b/docs/roadmap.md index 14d734ba5..afb4e471c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-27 · **Change:** minor (compact layer trees with fields and outline (#996, part 1); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Compact layer trees (M0.9, part of #996): `fields` selects node properties recursively and `outline` keeps names, types, short text, main component names and visibility, plus ids and structure, through the engine, control channel and MCP. Default output and response-size limits unchanged. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged | From bfb01901fbd21e3bce325fb4ae5eca95c38ecbd7 Mon Sep 17 00:00:00 2001 From: Markus Reimr Date: Thu, 29 Oct 2026 08:53:13 +0100 Subject: [PATCH 02/34] M10: clarify German gradient, OpenType and startup labels --- ROADMAP.md | 4 +++- crates/ui-egui/locales/de.toml | 18 +++++++++--------- docs/localization-parity.md | 3 ++- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index b650f08e7..70daf0905 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-10-29 · **Change:** minor (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-10-29 · **Change:** minor (German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; 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 @@ -132,6 +132,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). ## Progress log +- **2026-10-29** · M10: correct nine German labels in `de.toml`: gradient stops use Verlaufsstopp, OpenType ss01–ss05 use Stilsatz, and the Home-screen preference names the Startbildschirm consistently in the UI and Actions palette. This is a partial terminology pass; German remains a draft awaiting a full native-speaker review and maintainer approval. Numbers unchanged. - **2026-10-28** · M3, #922: an instance scaled with the Scale tool (or `transform.scale`) keeps its layers' scaled size and points when its main is rebuilt. The scaled layers' new size and place were read as what constraints give (an ancestor resized in the same edit), so no override was kept, and adding a layer to the main put a 14 px icon's 24 px triangle back. The Scale tool now records them as `size` and `position` overrides on layers inside instances; strokes already kept theirs. The report's own sequence (clearing effects on the component holding the icon) no longer reproduces on main. Regressions for a scaled instance, one scaled with the tool (with undo) and a scaled icon nested in a component and its instances. Numbers unchanged. - **2026-10-28** · M6, #1059: `.fig` import copies each image carried inside the message once per blob, shared by every hash that names it, instead of once per hash: a file pointing many hashes at one large blob no longer multiplies it in memory (100 hashes on a 250 MB blob were 25 GB). Regression test. Numbers unchanged. - **2026-10-28** · M1, #1476: page ruler guides survive save and reopen. A page kept its guides in the page kind while the layer's own `guides` field (frame guides, #70) took that key when a file was read, so a reopened page's guides vanished, and saving it wrote `guides` twice, which then refused to open. Every layer now keeps its guides in the one field, and a page with two `guides` lists opens with both. Regressions in the doc and format crates. Numbers unchanged. @@ -355,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | | 2026-10-29 | minor | The Design panel's sections on one grid (M4, #858): 16 pt left and 8 pt right margins, two 88 pt columns with 8 pt gutters and a 24 pt button column, 24 pt rows under 10 pt captions, 4 pt between a row's two buttons; section titles stay 11 pt semibold; the panel's edge is drawn inside it, so its right margin no longer loses 1 pt to a frame stroke. Numbers unchanged | | 2026-10-29 | minor | The toolbar on one 32 pt grid (M4, #1210): the pill's cells, padding, gaps and divider share one grid in every state, and the Resources button is gone, as in UI3; the Assets panel opens from the left rail, Shift+I, Alt+2 and View ▸ Panels ▸ Assets. Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `crates/ui-egui/locales/ar.toml`, i18n catalog tests and [translation notes](docs/translating.md#arabic-draft) | diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index fa5a02d81..9c62bada0 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1068,7 +1068,7 @@ status = "draft — needs native review" "Remove row" = "Zeile entfernen" "Remove slot" = "Slot entfernen" "Remove starting point" = "Startpunkt entfernen" -"Remove stop" = "Farbunterbrechung entfernen" +"Remove stop" = "Verlaufsstopp entfernen" "Remove text from path" = "Text vom Pfad lösen" "Remove the else and its actions" = "„Sonst“ und seine Aktionen entfernen" "Remove this condition and its actions" = "Diese Bedingung und ihre Aktionen entfernen" @@ -1280,7 +1280,7 @@ status = "draft — needs native review" "Start ascending from / stop descending at" = "Aufwärts zählen ab / abwärts zählen bis" "Start from a blank canvas" = "Mit einer leeren Arbeitsfläche beginnen" "Start on path" = "Anfang auf dem Pfad" -"Start on the Home screen" = "Mit Start beginnen" +"Start on the Home screen" = "Mit dem Startbildschirm starten" "Start point" = "Startpunkt" "Start sharing" = "Teilen starten" "Start vote" = "Abstimmung starten" @@ -1297,7 +1297,7 @@ status = "draft — needs native review" "Stop sharing" = "Teilen beenden" "Stop spotlighting" = "Spotlight beenden" "Stop timer" = "Timer stoppen" -"Stop {n} {hex}" = "Farbunterbrechung {n} {hex}" +"Stop {n} {hex}" = "Verlaufsstopp {n} {hex}" "Straight" = "Gerade" "Stretch" = "Strecken" "Strikethrough" = "Durchgestrichen" @@ -1308,11 +1308,11 @@ status = "draft — needs native review" "Strokes" = "Konturen" "Style" = "Stil" "Styles" = "Stile" -"Stylistic set 1" = "Stilistischer Satz 1" -"Stylistic set 2" = "Stilistischer Satz 2" -"Stylistic set 3" = "Stilistischer Satz 3" -"Stylistic set 4" = "Stilistischer Satz 4" -"Stylistic set 5" = "Stilistischer Satz 5" +"Stylistic set 1" = "Stilsatz 1" +"Stylistic set 2" = "Stilsatz 2" +"Stylistic set 3" = "Stilsatz 3" +"Stylistic set 4" = "Stilsatz 4" +"Stylistic set 5" = "Stilsatz 5" "Subtract" = "Subtrahieren" "Subtracts" = "Subtraktionen" "Suffix" = "Suffix" @@ -2040,7 +2040,7 @@ status = "draft — needs native review" "pref.reduceMotion" = "Bewegung reduzieren" "pref.renameDuplicates" = "Duplizierte Ebenen umbenennen" "pref.showDimensions" = "Abmessungen an Objekten anzeigen" -"pref.startOnHome" = "Mit Start beginnen" +"pref.startOnHome" = "Mit dem Startbildschirm starten" "pref.userName" = "Ihr Name in Kommentaren" "proto.action.add" = "Prototyp-Aktion hinzufügen" "proto.action.move" = "Prototyp-Aktion neu anordnen" diff --git a/docs/localization-parity.md b/docs/localization-parity.md index d06ebd425..7d35cb49f 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian @@ -123,6 +123,7 @@ Hindi and Arabic. Traditional Chinese is not planned (no catalog; its users see | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft. Nine existing translations corrected; keys, placeholders, coverage, grade and estimates unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI entries, 620 command labels and 31 menu wordings, with count-neutral plurals. AI-assisted, native review pending. Desktop uses the existing Arabic fallback and bidi; web requires an embedded Arabic face. No panel mirroring or parity-grade increase claimed | | 2026-10-24 | minor | Thai (`th`) draft catalog: `th.toml` translates every UI string (1,791), 589 command labels and the 31 menu wordings, machine-drafted and marked as a draft awaiting native review (9 newer command labels read English, as in the other drafts). The desktop UI now adds an installed Thai face (Noto Sans Thai, Leelawadee UI, Tahoma, Sukhumvit Set, Thonburi, Ayuthaya, Sarabun, Loma, Garuda or Waree) when a layer or page name or the UI language needs it, so Thai names no longer show as tofu squares. No plural forms for Thai. Grade and hours unchanged | | 2026-10-24 | minor | Vietnamese (`vi`) draft catalog (#1026), assessed as a machine draft awaiting review like the others; string counts re-measured: 2,352 entries. | From 462b32a6421c12197e78abb0b2b44f5055bdeb62 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Thu, 29 Oct 2026 12:31:09 +0300 Subject: [PATCH 03/34] #1420: test a field from each of the six inspect helpers Co-Authored-By: Claude Opus 5.5 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra --- crates/engine/src/inspect_fields_tests.rs | 27 +++++++++++++++++++++-- docs/roadmap.md | 2 +- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/inspect_fields_tests.rs b/crates/engine/src/inspect_fields_tests.rs index ba72cba94..92bb6b74c 100644 --- a/crates/engine/src/inspect_fields_tests.rs +++ b/crates/engine/src/inspect_fields_tests.rs @@ -415,8 +415,31 @@ fn page_properties_and_other_page_summaries_respect_fields() { #[test] fn the_catalogue_accepts_emitted_keys_and_structure_keys() { - let (s, frame, _) = tree(); - let full = node_json(s.doc().unwrap(), frame, 2); + let (mut s, frame, text) = tree(); + run(&mut s, "node.set", json!({"ids": [frame], "props": {"layout": "grid", "fill": "#123456", "stroke": "#abcdef"}})); + s.doc_mut().unwrap().get_mut(frame).unwrap().dev_note = "Ready for review".into(); + s.doc_mut().unwrap().get_mut(frame).unwrap().dev_changed = true; + let main = create(&mut s, json!({"type": "component", "name": "Button"})); + let instance = crate::params::id_of(&run(&mut s, "instance.create", json!({"component": main}))["id"]).unwrap(); + s.doc_mut().unwrap().get_mut(text).unwrap().kind.text_mut().unwrap().style.style_id = Some("synthetic-style".into()); + // Exercise each of the six property helpers, including their more expensive values. + for (id, key, sibling) in [ + (frame, "absoluteBounds", "width"), + (frame, "fills", "strokes"), + (text, "gridCell", "sizing"), + (frame, "devNote", "devChanged"), + (text, "textStyleRanges", "fontFamily"), + (instance, "mainComponentName", "mainComponent"), + ] { + let full = node_json(s.doc().unwrap(), id, 0); + assert!(full.get(key).is_some(), "fixture must emit {key}: {full}"); + assert!(full.get(sibling).is_some(), "fixture must emit {sibling}: {full}"); + let selected = node(&s, id, 0, json!({"fields": [key]})); + assert_eq!(selected[key], full[key]); + assert!(selected.get(sibling).is_none(), "unrequested {sibling}: {selected}"); + keys(&selected, &["id", "children", "childCount", key]); + } + let full = node_json(s.doc().unwrap(), s.page().unwrap(), 3); fn check(s: &Session, full: &Value) { let id = crate::params::id_of(&full["id"]).unwrap(); for (key, value) in full.as_object().unwrap() { diff --git a/docs/roadmap.md b/docs/roadmap.md index 50e80c924..3a0437575 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -48,7 +48,7 @@ blockers. | Milestone | Status | Left | Hours | |---|---|---|---| | M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | -| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — | +| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform; compact inspection with `fields` and `outline`, part of #996) | — | — | | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | From bd7a277c75d5a5d6b6fa3b04bb201c57bc2b7edd Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Thu, 29 Oct 2026 12:43:53 +0300 Subject: [PATCH 04/34] #1420: clearer option errors and text cut-off in the inspect docs Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6 Astra --- docs/control-protocol.md | 16 ++++++++-------- docs/mcp.md | 10 ++++++---- docs/roadmap.md | 2 +- 3 files changed, 15 insertions(+), 13 deletions(-) diff --git a/docs/control-protocol.md b/docs/control-protocol.md index c2def720e..4b1f586af 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -178,16 +178,16 @@ that `node.set` accepts are listed in [mcp.md](mcp.md#conventions-agents-must-kn Both inspection methods accept `fields: [string]`: exact, case-sensitive top-level node keys such as `name`, `type`, `x`, `fills`, `characters` or `pluginData`, applied recursively to pages too. `[]` keeps only ids and structure. `id`, `children` and depth-boundary `childCount` are always kept. -Unknown keys are errors listing the valid keys; wrong types are -errors too. +An unknown key is an error that lists the valid keys; a `fields` that is not an array of strings, +or an `outline` that is not a boolean, is an error too. `outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus -structure. `characters` stops at 120 characters, with ASCII `...` appended only when -longer. When `visible` is selected (by either option), every node has an explicit boolean; -without options the output stays sparse. `outline: true` with `fields` is an error; -`outline: false` with `fields` is allowed. Document metadata is unchanged. The engine command -`node.get` (not this control method) takes the same options but no `depth`: it always returns one -level of children. +structure. `characters` is cut after 120 characters (Unicode scalars), with ASCII `...` appended +only when longer. When `visible` is selected (by either option), every node has an explicit +boolean; without options the output stays sparse. `outline: true` with `fields` is an error; +`outline: false` with `fields` is allowed. Document metadata is unchanged. Through +`engine.execute`, the engine command `node.get` takes the same options but no `depth`; it always +returns one level of children. ```json {"id":5,"method":"document.inspect","params":{"depth":6,"outline":true}} diff --git a/docs/mcp.md b/docs/mcp.md index ff0cea425..803cbe822 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -357,12 +357,14 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match. keys, applied to every layer and page. For example, `fields: ["name", "type", "width", "height"]` leaves out paints and text properties; `fields: []` keeps only ids and structure. `id`, `children`, depth-boundary `childCount` and the transport's `truncated` marker are always kept (`truncated` is -not a selectable field). Unknown keys or wrong types -are errors; an unknown key's error lists the valid keys. Document metadata stays unchanged. +not a selectable field). An unknown key is an error that lists the valid keys; a `fields` that is +not an array of strings, or an `outline` that is not a boolean, is an error too. Document metadata +stays unchanged. `outline: true` selects `id`, `name`, `type`, `characters`, `mainComponentName` and `visible`, plus -structure. Text is cut after 120 characters, with ASCII `...` only when longer. Selecting -`visible`, explicitly or through outline, reports a boolean even for visible layers and pages; +structure. `characters` is cut after 120 characters (Unicode scalars), with ASCII `...` appended +only when longer. Selecting `visible`, explicitly or through outline, reports a boolean even for +visible layers and pages; without these options the output stays sparse. `outline: true` cannot be combined with `fields`; `outline: false` can. Both options keep the existing depth and response-size limits. diff --git a/docs/roadmap.md b/docs/roadmap.md index 3a0437575..f139ae912 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -48,7 +48,7 @@ blockers. | Milestone | Status | Left | Hours | |---|---|---|---| | M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | -| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform; compact inspection with `fields` and `outline`, part of #996) | — | — | +| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform). Added since: compact inspection with `fields` and `outline` (part of #996) | — | — | | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | From 9573b8b2d219aa74817cedfd5878bd5cd7b8509a Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 05:52:02 +0300 Subject: [PATCH 05/34] M8: a grouped command whose closure panics leaves the document as before (#1085) A panic in a Session::grouped closure outside any command skipped the group rollback: the documents stayed half edited, the history limit unbounded, live documents held and the group depth raised. checkpointed now catches the unwind, runs the same rollback as a failed group and resumes the panic, so the caller's guard still reports it. Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra --- crates/engine/src/group_panic_tests.rs | 110 +++++++++++++++++++++++++ crates/engine/src/lib.rs | 2 + crates/engine/src/session/undo.rs | 13 ++- docs/architecture.md | 3 +- docs/roadmap.md | 3 +- 5 files changed, 127 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/group_panic_tests.rs diff --git a/crates/engine/src/group_panic_tests.rs b/crates/engine/src/group_panic_tests.rs new file mode 100644 index 000000000..aaa1a8bf4 --- /dev/null +++ b/crates/engine/src/group_panic_tests.rs @@ -0,0 +1,110 @@ +//! A panic in a grouped closure, outside any command, ends the group like a failure (#1085): the +//! documents, selection, undo and redo, history limit and journal are as they were, and the panic +//! goes on to the caller. + +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::sync::Arc; + +use serde_json::json; + +use crate::{HistoryEntry, NodeId, Session}; + +fn rect(s: &mut Session, x: i64) -> NodeId { + s.execute("shape.rectangle", &json!({"x": x, "y": 0, "width": 10, "height": 10})).unwrap()["id"].as_u64().map(NodeId).unwrap() +} + +fn labels(entries: &[HistoryEntry]) -> Vec { + entries.iter().map(|e| e.label.clone()).collect() +} + +#[test] +fn a_grouped_closure_that_panics_leaves_the_session_as_before() { + let mut s = Session::new(); + let first = rect(&mut s, 0); + let second = rect(&mut s, 20); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("select.set", &json!({"ids": [first]})).unwrap(); + let ds = s.ds().unwrap(); + let (doc, selection, page) = (ds.doc.clone(), ds.selection.clone(), ds.page); + let (undo, redo, limit) = (labels(&ds.history.undo), labels(&ds.history.redo), ds.history.limit); + assert_eq!(redo.len(), 1); + let journal = s.journal.clone(); + let depth = s.undo_depth(); + + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("Edit then panic", |s| -> crate::Result<()> { + s.execute("node.set", &json!({"id": first, "props": {"name": "Renamed"}}))?; + rect(s, 40); + s.execute("select.set", &json!({"ids": []}))?; + panic!("a bug in the closure"); + }) + })); + + assert!(unwound.is_err(), "the panic goes on to the caller"); + let ds = s.ds().unwrap(); + assert!(Arc::ptr_eq(&ds.doc, &doc)); + assert_eq!(*ds.doc, *doc); + assert_eq!(ds.selection, selection); + assert_eq!(ds.page, page); + assert_eq!(labels(&ds.history.undo), undo); + assert_eq!(labels(&ds.history.redo), redo); + assert_eq!(ds.history.limit, limit); + assert_eq!(s.journal, journal); + assert_eq!(s.undo_depth(), depth); + assert_eq!(s.group_depth, 0); + assert!(s.closed_in_group.is_empty()); + + // The redo step survived, and the next edit pushes one step of its own. + s.execute("edit.redo", &json!({})).unwrap(); + assert!(s.doc().unwrap().contains(second)); + rect(&mut s, 60); + assert_eq!(s.undo_depth(), (depth.0 + 2, 0)); +} + +#[test] +fn a_panic_in_an_inner_group_rolls_back_through_the_outer_one() { + let mut s = Session::new(); + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("outer", |s| { + s.execute("comment.add", &json!({"x": 1, "y": 2, "text": "Rolled back"}))?; + s.grouped("inner", |s| -> crate::Result<()> { + s.execute("comment.add", &json!({"x": 3, "y": 4, "text": "Also rolled back"}))?; + panic!("a bug in the inner closure"); + }) + }) + })); + + assert!(unwound.is_err()); + assert!(s.doc().unwrap().comments.is_empty()); + assert!(s.pending_events.is_empty()); + assert_eq!(s.events.latest(), 0); + assert_eq!(s.group_depth, 0); + assert_eq!(s.undo_depth(), (0, 0)); + + // The session still groups normally afterwards. + s.grouped("again", |s| s.execute("comment.add", &json!({"x": 5, "y": 6, "text": "Kept"})).map(|_| ())).unwrap(); + assert_eq!(s.doc().unwrap().comments.len(), 1); + assert_eq!(s.undo_depth(), (1, 0)); +} + +#[test] +fn a_group_that_closes_and_opens_tabs_then_panics_brings_the_tabs_back() { + let mut s = Session::new(); + s.execute("file.new", &json!({"name": "Second"})).unwrap(); + let uids: Vec<_> = s.docs.iter().map(|ds| ds.uid).collect(); + let active = s.active; + + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("Close, open, panic", |s| -> crate::Result<()> { + s.execute("file.close", &json!({"index": 1, "force": true}))?; + s.execute("file.new", &json!({"name": "Temporary"}))?; + panic!("a bug after switching tabs"); + }) + })); + + assert!(unwound.is_err()); + assert_eq!(s.docs.iter().map(|ds| ds.uid).collect::>(), uids); + assert_eq!(s.active, active); + assert!(s.closed_in_group.is_empty()); + assert!(s.docs.iter().all(|ds| ds.history.limit != usize::MAX)); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index bf40c15f7..deac29e2c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -323,6 +323,8 @@ mod gesture_preview_tests; #[cfg(test)] mod gradient_shorthand_tests; #[cfg(test)] +mod group_panic_tests; +#[cfg(test)] mod guide_snap_tests; #[cfg(test)] mod hide_publishing_tests; diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index 285fc8a69..1a2ae80e9 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -250,7 +250,8 @@ impl Session { } /// Run `f` as one undo step called `label`: the commands it executes push no steps of their - /// own, and when it fails the documents stay at their group-start state. + /// own, and when it fails or panics the documents stay at their group-start state (a panic + /// then carries on unwinding). pub fn grouped(&mut self, label: &str, f: impl FnOnce(&mut Self) -> Result) -> Result { self.ds()?; self.checkpointed(Some(label), f) @@ -260,7 +261,12 @@ impl Session { /// A checkpoint also works between closing the last tab and opening another one. fn checkpointed(&mut self, label: Option<&str>, f: impl FnOnce(&mut Self) -> Result) -> Result { let before = self.group_checkpoint(); - let result = f(self); + // A panic in `f` ends the group like a failure, then goes on to the caller's guard (#1085); + // the stand-in error only drives the rollback and is never returned. + let (result, panic) = match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| f(self))) { + Ok(result) => (result, None), + Err(payload) => (Err(EngineError::Other("the group panicked".into())), Some(payload)), + }; self.group_depth -= 1; let at_root = self.group_depth == 0; for ds in &before.docs { @@ -291,6 +297,9 @@ impl Session { self.closed_in_group.clear(); self.flush_pending_events(); } + if let Some(payload) = panic { + std::panic::resume_unwind(payload); + } result } diff --git a/docs/architecture.md b/docs/architecture.md index 9faed5479..5c7300f82 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -75,7 +75,8 @@ content. Exports: PNG, JPG, WebP, SVG, PDF, code. There is no `.fig` reader yet Workspace lints deny `unwrap`, `expect`, `panic!` and friends in shipped code; `unsafe_code` is forbidden. `figcraft_engine::guard` turns a panicking command into an error and rolls the document -back; the desktop app also guards each UI frame. The browser build can't catch a panic (wasm32 aborts), so there a panic stops the page and its panic hook keeps the files with unsaved changes in local storage for the next page. Junk-parameter sweeps run every command with bad +back, and a panic in a `Session::grouped` closure outside any command rolls the group back like a +failure before the panic continues; the desktop app also guards each UI frame. The browser build can't catch a panic (wasm32 aborts), so there a panic stops the page and its panic hook keeps the files with unsaved changes in local storage for the next page. Junk-parameter sweeps run every command with bad input. ## Revision history diff --git a/docs/roadmap.md b/docs/roadmap.md index a5ec70b39..acabfca43 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1085 (the rest): a panic in a `Session::grouped` closure outside any command (plugin, widget, data fill and CLI groups) skipped the group rollback, leaving documents half edited, the history limit unbounded, live documents held and the group depth raised. `checkpointed` now catches the unwind, runs the same rollback as a failed group and resumes the panic, so the outer guard still reports it. Journal rollover and the browser (wasm aborts) are unchanged. 3 regression tests (`group_panic_tests`). | | 2026-10-30 | minor | M8, #1079: one total for what a `.figcraft` file unpacks on open (6 GiB; 2 GiB on 32-bit), shared by the document, images, library, versions and branches beyond their own caps; images past it stay in the file like those past the image cap (#1313). Tests: `unpacked_total_tests` | | 2026-10-30 | minor | M8, #1571 (part): laggy drags and high CPU while the pointer only moves. Measured on a synthetic file of about 1,900 layers (24 auto layout frames of 48 rows, 600 shapes, 24 images; release build, 300 to 600 pointer moves at 60 per second): every pointer move draws about three egui frames (the move, egui's follow-up, a finished render), and the canvas hit-tested the hover on each of them; a drag of an auto layout frame laid its contents out again on every move, though a move can't change them. Now a hover where nothing changed since the last (same place, file, page, revision, selection, tool and modes) isn't sent to the tools again, and a frame that only moved keeps its own layout (its parent's still runs). Hover: the UI pass 0.91 to 0.49 ms a frame, CPU 33% to 22% of a core; dragging 648 layers: the UI pass 3.8 to 3.2 ms, the engine's normalise 3.8 to 2.1 ms a move, CPU about the same (117% to 116%). `ui.inspect` reports `frameTimes` ({frames, meanMs, maxMs}). Not yet: the selection overlay and the Design panel recompute every frame for a large selection (about 2 ms each at 648 layers), the canvas texture is uploaded whole after each render, and egui draws its follow-up frames. Tests: `canvas_hover_tests.rs`, `auto_layout_move_tests.rs`. | | 2026-10-30 | minor | M8, #1077: `.fig` instance expansion near linear on crafted files: variable nodes are out of the layer tree, nested instances take only the override rows below them and share property values; 13.8 s → 21 ms and 40.5 s → 98 ms on crafted files. Tests: `expansion_cost_tests` | From 05a475dccf9d974159bcb35e7382a1b36ea6190c Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 06:48:00 +0300 Subject: [PATCH 06/34] M0.9: generate shell completions and man pages for figcraft-cli (part of #1396) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra Co-Authored-By: GPT-6.1 Terra --- Cargo.lock | 27 ++++ ROADMAP.md | 3 +- apps/figcraft-cli/Cargo.toml | 5 + .../examples/man-and-completions.rs | 44 ++++++ apps/figcraft-cli/tests/it/generated_docs.rs | 134 ++++++++++++++++++ apps/figcraft-cli/tests/it/main.rs | 1 + docs/cli.md | 31 +++- docs/roadmap.md | 5 +- 8 files changed, 246 insertions(+), 4 deletions(-) create mode 100644 apps/figcraft-cli/examples/man-and-completions.rs create mode 100644 apps/figcraft-cli/tests/it/generated_docs.rs diff --git a/Cargo.lock b/Cargo.lock index bc2dcbc15..aafe9a830 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -768,6 +768,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.5.67" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1248e420506b523e97ebd7b610656114abadbe73b04279a86768a12ce1860837" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.6.7" @@ -786,6 +795,16 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" +[[package]] +name = "clap_mangen" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "211d617eaa4b735c96c9e0228fcbdb5120ef623f2b8cb67ffb84c3e02dbc28a4" +dependencies = [ + "clap", + "roff", +] + [[package]] name = "clipboard-win" version = "5.4.3" @@ -1778,6 +1797,8 @@ name = "figcraft-cli" version = "0.12.1" dependencies = [ "clap", + "clap_complete", + "clap_mangen", "figcraft-doc", "figcraft-engine", "figcraft-format", @@ -4467,6 +4488,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "roff" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" + [[package]] name = "roxmltree" version = "0.20.0" diff --git a/ROADMAP.md b/ROADMAP.md index 1e2aa3855..c77f9649a 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-10-30 · **Change:** minor (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); 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); 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-10-30 · **Change:** minor (a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); 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); 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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged | | 2026-10-30 | minor | M8, #1082 (the rest): the SVG and PDF exporters drew a pattern fill's source from depth 0 again, and the renders they embed (a layer with effects drawn alone, a pattern cell with effects) started at 0 too, so an export could nest its own levels, the renderer's and a pattern's on top of each other. A pattern source now exports one level below the layer it fills, and what the renderer draws for a layer goes on from that layer's depth (`figcraft_render::at_layer_depth`), so one cap bounds the whole export; the render of what lies beneath a layer (background blur, glass) is of the page's own layers and still counts from the page. Canvas masks, mask content and boolean and outline walks already carried the depth or have their own small caps. Numbers unchanged. Tests: `render_depth_tests`, `figcraft-svg` and `figcraft-pdf` `pattern_depth_tests`. | | 2026-10-30 | minor | M8, #1082: a pattern fill drew its source as a fresh render from depth 0, so patterns in patterns could stack three renders of 256 levels; its source now goes on from the depth of the layer it fills, and the one cap bounds the whole render. Measured, a level takes about 6.5 KB of stack in a render (6.8 KB in a debug build), so the deepest allowed render needs about 1.7 MB: the canvas render threads, the save thread (it renders the thumbnail) and a band pool of their own now get 8 MiB stacks, like the desktop app's main thread (rayon's and other threads' default is 2 MiB). Numbers unchanged. Tests: `render_depth_tests` (the cap reached through a pattern; three 250-level chains of patterns in patterns rendered on a render thread's stack). | | 2026-10-30 | minor | M8, #1114 (explicit variables files only): sibling variables files were read with a 256 MiB cap and a regular-file check, but an explicit path given to `variable.import`, or as the `variables` of `file.open` and `file.import`, was read whole with no limit. That path now goes through the same capped read: an oversized file, a folder or a device fails with "not a file of at most 268435456 bytes" and the document, tabs and recent files stay as they were. The check reads the file's size before reading it, so a file that grows or is replaced in between is not bounded; primary document reads and `image.place` are left for the rest of #1114. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged | diff --git a/apps/figcraft-cli/Cargo.toml b/apps/figcraft-cli/Cargo.toml index 8bfb4373a..16c228681 100644 --- a/apps/figcraft-cli/Cargo.toml +++ b/apps/figcraft-cli/Cargo.toml @@ -24,3 +24,8 @@ figcraft-mcp = { workspace = true } serde_json = { workspace = true } clap = { workspace = true } thiserror = { workspace = true } + +[dev-dependencies] +# 4.6 breaks Bash subcommand completion for hyphenated binary names (clap-rs/clap#6421). +clap_complete = "~4.5.67" +clap_mangen = "0.3" diff --git a/apps/figcraft-cli/examples/man-and-completions.rs b/apps/figcraft-cli/examples/man-and-completions.rs new file mode 100644 index 000000000..c67827477 --- /dev/null +++ b/apps/figcraft-cli/examples/man-and-completions.rs @@ -0,0 +1,44 @@ +//! Writes figcraft-cli's shell completions and recursive man pages into a directory: +//! +//! ```sh +//! cargo run -p figcraft-cli --example man-and-completions -- +//! ``` +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +#[path = "../src/args.rs"] +#[expect(dead_code, reason = "Generation uses clap's command metadata, not the parsed fields or runtime parser.")] +mod args; + +use std::path::Path; +use std::process::ExitCode; + +use clap::{CommandFactory, ValueEnum}; +use clap_complete::Shell; + +fn main() -> ExitCode { + let mut args = std::env::args_os().skip(1); + let (Some(dir), None) = (args.next(), args.next()) else { + eprintln!("usage: man-and-completions "); + return ExitCode::from(2); + }; + let dir = Path::new(&dir); + match generate(dir) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("error: {}: {e}", dir.display()); + ExitCode::FAILURE + } + } +} + +pub(crate) fn generate(dir: &Path) -> std::io::Result<()> { + std::fs::create_dir_all(dir)?; + let mut command = args::Cli::command(); + let name = command.get_name().to_owned(); + for &shell in Shell::value_variants() { + clap_complete::generate_to(shell, &mut command, &name, dir)?; + } + // Completions build the command and add synthetic help subcommands. A fresh command keeps + // those out of the man pages (such as figcraft-cli-help-export.1). + clap_mangen::generate_to(args::Cli::command(), dir) +} diff --git a/apps/figcraft-cli/tests/it/generated_docs.rs b/apps/figcraft-cli/tests/it/generated_docs.rs new file mode 100644 index 000000000..e39c77b5d --- /dev/null +++ b/apps/figcraft-cli/tests/it/generated_docs.rs @@ -0,0 +1,134 @@ +//! Generated CLI documentation uses the parser's command definition, without running commands. + +use std::fs; +use std::path::PathBuf; + +#[path = "../../examples/man-and-completions.rs"] +#[expect(dead_code, reason = "These tests call generate directly; the example's main is smoke-tested separately.")] +mod generator; + +fn tmp(name: &str) -> std::io::Result { + let dir = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{name}", std::process::id())); + fs::create_dir_all(&dir)?; + Ok(dir) +} + +#[test] +fn completions_name_the_cli_its_subcommands_and_options() -> std::io::Result<()> { + let dir = tmp("completions")?; + generator::generate(&dir)?; + for name in ["figcraft-cli.bash", "figcraft-cli.elv", "figcraft-cli.fish", "_figcraft-cli.ps1", "_figcraft-cli"] { + let text = fs::read_to_string(dir.join(name))?; + for word in [ + "figcraft-cli", + "info", + "export", + "convert", + "render", + "run", + "commands", + "mcp", + "plugin", + "perf", + "publish", + "no-system-fonts", + "format", + "automation-read-root", + "server", + ] { + assert!(text.contains(word), "{name} does not contain {word}"); + } + assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI"); + } + fs::remove_dir_all(dir) +} + +#[test] +fn bash_dispatch_states_have_matching_option_blocks() -> std::io::Result<()> { + let dir = tmp("bash-completion-states")?; + generator::generate(&dir)?; + let text = fs::read_to_string(dir.join("figcraft-cli.bash"))?; + let states: Vec<_> = + text.lines().filter_map(|line| line.trim().strip_prefix("cmd=\"")?.strip_suffix('"')).filter(|state| !state.is_empty()).collect(); + assert!(states.len() > 1, "the completion has no subcommand dispatch states"); + for state in states { + let block = format!("{state})"); + assert!(text.lines().any(|line| line.trim() == block), "Bash dispatch state {state} has no option block"); + } + fs::remove_dir_all(dir) +} + +#[test] +fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::Result<()> { + let dir = tmp("man-pages")?; + generator::generate(&dir)?; + let mut pages = Vec::new(); + for entry in fs::read_dir(&dir)? { + let path = entry?.path(); + if path.extension().is_some_and(|ext| ext == "1") { + pages.push(path.file_name().unwrap_or_default().to_string_lossy().into_owned()); + } + } + pages.sort(); + assert_eq!( + pages, + [ + "figcraft-cli-commands.1", + "figcraft-cli-convert.1", + "figcraft-cli-export.1", + "figcraft-cli-info.1", + "figcraft-cli-mcp.1", + "figcraft-cli-perf.1", + "figcraft-cli-plugin-publish.1", + "figcraft-cli-plugin.1", + "figcraft-cli-render.1", + "figcraft-cli-run.1", + "figcraft-cli.1", + ] + ); + for name in &pages { + let text = fs::read_to_string(dir.join(name))?.replace("\\-", "-"); + assert!(text.contains(".SH NAME"), "{name} has no NAME section"); + assert!(text.contains(name.trim_end_matches(".1")), "{name} has the wrong command name"); + assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI"); + } + for (page, option) in [ + ("figcraft-cli.1", "--no-system-fonts"), + ("figcraft-cli-export.1", "--format"), + ("figcraft-cli-export.1", "--automation-read-root"), + ("figcraft-cli-plugin-publish.1", "--server"), + ] { + let text = fs::read_to_string(dir.join(page))?.replace("\\-", "-"); + assert!(text.contains(option), "{page} does not contain {option}"); + } + fs::remove_dir_all(dir) +} + +#[test] +fn generation_creates_the_output_directory_and_can_run_again() -> std::io::Result<()> { + let dir = tmp("generated-docs-repeat")?; + let output = dir.join("new").join("docs"); + assert!(!output.exists()); + generator::generate(&output)?; + let page = output.join("figcraft-cli.1"); + let first = fs::read(&page)?; + assert!(!first.is_empty()); + fs::write(&page, "old page")?; + generator::generate(&output)?; + assert_eq!(fs::read(page)?, first); + assert_eq!(fs::read_dir(output)?.count(), 16); + fs::remove_dir_all(dir) +} + +#[test] +fn generation_returns_filesystem_errors() -> std::io::Result<()> { + let dir = tmp("generated-docs-errors")?; + let file = dir.join("not-a-directory"); + fs::write(&file, "keep me")?; + assert!(generator::generate(&file).is_err()); + assert_eq!(fs::read_to_string(&file)?, "keep me"); + // A valid directory can still fail when writing one of the generated pages. + fs::create_dir(dir.join("figcraft-cli.1"))?; + assert!(generator::generate(&dir).is_err()); + fs::remove_dir_all(dir) +} diff --git a/apps/figcraft-cli/tests/it/main.rs b/apps/figcraft-cli/tests/it/main.rs index 2e628773d..ab0773b7d 100644 --- a/apps/figcraft-cli/tests/it/main.rs +++ b/apps/figcraft-cli/tests/it/main.rs @@ -6,6 +6,7 @@ mod cli; mod export_depth; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod fonts; +mod generated_docs; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod pdf; #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] diff --git a/docs/cli.md b/docs/cli.md index 4235ede8e..c437d190d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -29,7 +29,36 @@ Not yet as RFC-0012 asks: function the tests call in-process. - `FIGCRAFT_NO_SYSTEM_FONTS` and `FIGCRAFT_SERVER_KEY` are read where they're used, not declared on a flag, so `--help` names them only in the flag descriptions. -- There are no generated shell completions or man pages, and no ast-grep checks in CI yet. +- Packages don't install the generated shell completions or man pages yet. +- There are no ast-grep checks in CI yet. + +## Generating shell completions and man pages + +From the workspace root, run: + +```sh +cargo run -p figcraft-cli --example man-and-completions -- target/cli-docs +``` + +The example reuses the CLI's clap definitions without running a command, scanning fonts or opening +a window. It creates the output directory if needed and overwrites its generated files on a +second run. The output directory is its only argument. A missing or extra argument is a usage +error (exit 2); a filesystem error exits with 1. + +The directory contains completions for five shells: + +| Shell | File | +|---|---| +| Bash | `figcraft-cli.bash` | +| Elvish | `figcraft-cli.elv` | +| Fish | `figcraft-cli.fish` | +| PowerShell | `_figcraft-cli.ps1` | +| Zsh | `_figcraft-cli` | + +It also contains `figcraft-cli.1` and one man page per subcommand, including nested commands such +as `figcraft-cli-plugin-publish.1`. For example, `man target/cli-docs/figcraft-cli-export.1` shows +the export subcommand's options. Generating these files doesn't install them; package installation +is still outstanding in [#1396](https://github.com/storytold/figcraft/issues/1396). ## Fonts diff --git a/docs/roadmap.md b/docs/roadmap.md index 889eccb07..a9a87d9c1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (a development example generates figcraft-cli's shell completions and man pages from its clap definitions (part of #1396); 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -48,7 +48,7 @@ blockers. | Milestone | Status | Left | Hours | |---|---|---|---| | M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | -| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — | +| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform; a development example generates CLI completions and man pages, part of #1396) | Package installation of the generated files is still outstanding | — | | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged | | 2026-10-30 | minor | M8, #1082 (the rest): SVG and PDF pattern sources and the renders exports embed go on from the layer's depth: one cap for a whole export. Tests: `pattern_depth_tests` (SVG, PDF), `render_depth_tests` | | 2026-10-30 | minor | M8, #1082: pattern sources go on from the depth of the layer they fill (one cap for the whole render); render, save and band threads get 8 MiB stacks (a level takes about 6.5 KB). Tests: `render_depth_tests` | | 2026-10-30 | minor | M8, #1114 (explicit variables files): `variable.import` and the `variables` of `file.open` and `file.import` read a path with the same 256 MiB cap and regular-file check as the sibling `variables.json`, so an oversized file, a folder or a device is refused before the document changes; inline objects and JSON text, access roots and the browser's error are unchanged. Primary file reads and `image.place` are still uncapped. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged | From fcde83a5a6d45346a316f45aaae8f4588c131c1c Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 06:48:01 +0300 Subject: [PATCH 07/34] M8: .fig ZIP image reads share a total byte budget (part of #1076) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra Co-Authored-By: GPT-6.1 Terra --- ROADMAP.md | 5 +- crates/format/src/fig.rs | 65 +++++++++- crates/format/src/fig/tests.rs | 227 +++++++++++++++++++++++++++++++++ docs/file-format-parity.md | 7 +- docs/roadmap.md | 5 +- 5 files changed, 299 insertions(+), 10 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 1e2aa3855..dec61e3f5 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-10-30 · **Change:** minor (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); 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); 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-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); 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); 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); 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 @@ -58,7 +58,7 @@ native speakers' reviews of the nine language drafts need people. |---|---|---|---| | Features (depth) | 63% (core editor 72%) | 28–67 | [target-app-parity](docs/target-app-parity.md) | | UI/UX fidelity | 58% | 40–60 | [ui-parity](docs/ui-parity.md) | -| File formats | 60% (`.fig` read best effort with editable vectors; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) | +| File formats | 60% (`.fig` read best effort with editable vectors and a total ZIP image byte budget; experimental write, round trips through our own importer and written files opened in other software) | 5–10 | [file-format-parity](docs/file-format-parity.md) | | Collaboration | 13% (core in; live sessions with document sync, cursors and text carets, cursor chat and spotlight, the browser joins and publishes to a server, invite links open the app; a self-hosted server with edit and view links and team libraries; preview) | 46–92 | [collaboration-parity](docs/collaboration-parity.md) | | Stability | 55% | 20–40 | [gaps](docs/gaps.md) | | Performance | 60% | 30–60 | [hardware-parity](docs/hardware-parity.md) | @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | | 2026-10-30 | minor | M8, #1082 (the rest): the SVG and PDF exporters drew a pattern fill's source from depth 0 again, and the renders they embed (a layer with effects drawn alone, a pattern cell with effects) started at 0 too, so an export could nest its own levels, the renderer's and a pattern's on top of each other. A pattern source now exports one level below the layer it fills, and what the renderer draws for a layer goes on from that layer's depth (`figcraft_render::at_layer_depth`), so one cap bounds the whole export; the render of what lies beneath a layer (background blur, glass) is of the page's own layers and still counts from the page. Canvas masks, mask content and boolean and outline walks already carried the depth or have their own small caps. Numbers unchanged. Tests: `render_depth_tests`, `figcraft-svg` and `figcraft-pdf` `pattern_depth_tests`. | | 2026-10-30 | minor | M8, #1082: a pattern fill drew its source as a fresh render from depth 0, so patterns in patterns could stack three renders of 256 levels; its source now goes on from the depth of the layer it fills, and the one cap bounds the whole render. Measured, a level takes about 6.5 KB of stack in a render (6.8 KB in a debug build), so the deepest allowed render needs about 1.7 MB: the canvas render threads, the save thread (it renders the thumbnail) and a band pool of their own now get 8 MiB stacks, like the desktop app's main thread (rayon's and other threads' default is 2 MiB). Numbers unchanged. Tests: `render_depth_tests` (the cap reached through a pattern; three 250-level chains of patterns in patterns rendered on a render thread's stack). | | 2026-10-30 | minor | M8, #1114 (explicit variables files only): sibling variables files were read with a 256 MiB cap and a regular-file check, but an explicit path given to `variable.import`, or as the `variables` of `file.open` and `file.import`, was read whole with no limit. That path now goes through the same capped read: an oversized file, a folder or a device fails with "not a file of at most 268435456 bytes" and the document, tabs and recent files stay as they were. The check reads the file's size before reading it, so a file that grows or is replaced in between is not bounded; primary document reads and `image.place` are left for the rest of #1114. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged | diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index a296f681e..73bfdfc44 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -49,6 +49,7 @@ mod text_style_tests; #[cfg(test)] mod text_wrap_tests; +use std::cell::Cell; use std::collections::BTreeMap; use std::io::Read; use std::sync::Arc; @@ -65,6 +66,12 @@ const MAX_CANVAS: u64 = 256 * 1024 * 1024; const MAX_CHUNK: u64 = 512 * 1024 * 1024; /// Largest image member read. const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES; +/// Total decompressed ZIP image bytes read per import: the native image budget on 64-bit targets, +/// lower on 32-bit targets to leave address space for the canvas, layers and decoded pixels. +#[cfg(not(target_pointer_width = "32"))] +const MAX_IMAGE_BYTES: u64 = 4 * 1024 * 1024 * 1024; +#[cfg(target_pointer_width = "32")] +const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024; /// Most chunks read from `canvas.fig`. const MAX_CHUNKS: usize = 16; /// Most layers imported from a `.fig` file: one per [`PACKED_BYTES_PER_LAYER`] bytes of its @@ -103,7 +110,12 @@ pub fn try_import(bytes: &[u8], name: &str) -> Option Result<(Document, Vec), FormatError> { + import_with_image_limit(bytes, name, MAX_IMAGE_BYTES) +} + +fn import_with_image_limit(bytes: &[u8], name: &str, image_limit: u64) -> Result<(Document, Vec), FormatError> { let mut archive = Archive::open(bytes)?; + archive.image_budget.remaining.set(image_limit); // The budgets follow the compressed canvas, which the file itself bounds (its zip member may // inflate too). let packed = archive.canvas.len().min(bytes.len()); @@ -123,6 +135,10 @@ pub fn import(bytes: &[u8], name: &str) -> Result<(Document, Vec), Forma drop(msg); let images = |r: &str| archive.image(r).or_else(|| embedded.get(r).map(|b| b.to_vec())); let (mut doc, more) = crate::rest::import_with_networks(&json, &images, networks, max_nodes)?; + let skipped = archive.image_budget.skipped.get(); + if skipped > 0 { + warnings.push(format!("{skipped} ZIP image(s) skipped by the total ZIP image byte budget ({image_limit} bytes)")); + } if board { doc.kind = FileKind::Board; } @@ -216,18 +232,27 @@ fn blob<'k>(blobs: &'k [Kv], index: usize) -> Option<&'k [u8]> { blobs.get(index)?.get("bytes").and_then(Kv::bytes) } +/// Bytes left for ZIP image reads, including damaged images. The REST resolver caches references, +/// including failures, so each referenced member is read or counted as skipped only once. +struct ZipImageBudget { + remaining: Cell, + skipped: Cell, +} + /// The parts of a `.fig` archive the import reads. struct Archive<'b> { canvas: Vec, file_name: Option, /// `images/` members (or nothing for a bare canvas). zip: Option>>, + image_budget: ZipImageBudget, } impl<'b> Archive<'b> { fn open(bytes: &'b [u8]) -> Result { + let image_budget = ZipImageBudget { remaining: Cell::new(MAX_IMAGE_BYTES), skipped: Cell::new(0) }; if bytes.starts_with(b"fig-") { - return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None }); + return Ok(Archive { canvas: bytes.to_vec(), file_name: None, zip: None, image_budget }); } let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).map_err(|e| corrupt(e.to_string()))?; let canvas = crate::read_member(&mut z, "canvas.fig", MAX_CANVAS)?; @@ -235,17 +260,51 @@ impl<'b> Archive<'b> { .ok() .and_then(|m| serde_json::from_slice::(&m).ok()) .and_then(|m| m.get("file_name").and_then(serde_json::Value::as_str).map(str::to_string)); - Ok(Archive { canvas, file_name, zip: Some(z) }) + Ok(Archive { canvas, file_name, zip: Some(z), image_budget }) } /// Bytes of image `hash` (lowercase hex) from `images/`. fn image(&self, hash: &str) -> Option> { + let budget = &self.image_budget; if hash.is_empty() || hash.len() > 128 || !hash.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } // A clone shares the parsed directory and the bytes; the resolver only has shared access. let mut z = self.zip.clone()?; - crate::read_member(&mut z, &format!("images/{hash}"), MAX_IMAGE).ok() + let f = z.by_name(&format!("images/{hash}")).ok()?; + let size = f.size(); + if size > MAX_IMAGE { + return None; + } + let remaining = budget.remaining.get(); + if size > remaining { + budget.skipped.set(budget.skipped.get() + 1); + return None; + } + let checksum = f.crc32(); + let mut data = Vec::new(); + // Probe past the per-image cap only when the total budget has room. Even a dishonest + // size cannot make the read exceed the remaining total; failed reads keep their charge. + let limit = remaining.min(MAX_IMAGE + 1); + let read = f.take(limit).read_to_end(&mut data); + let used = data.len() as u64; + budget.remaining.set(remaining - used); + if used > size && used == remaining { + budget.skipped.set(budget.skipped.get() + 1); + } + if read.is_err() || used != size { + return None; + } + // `take` can stop exactly at the budget without asking ZIP for EOF (where it checks CRC). + // Check it here too, without spending an extra byte on an overflow probe. + if used == limit { + let mut crc = flate2::Crc::new(); + crc.update(&data); + if crc.sum() != checksum { + return None; + } + } + Some(data) } } diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 697f949de..273ced143 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -795,6 +795,233 @@ fn hashes_naming_one_blob_share_one_copy_of_it() { assert_eq!(*copies[&"63".repeat(20)], [7; 16]); } +mod image_budget { + use super::*; + + fn image(hash: u8) -> Vec { + let img = image::RgbaImage::from_pixel(2, 1, image::Rgba([hash, 0, 0, 255])); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).unwrap(); + out + } + + fn file(references: &[u8], members: &[(u8, Vec)]) -> Vec { + let mut nodes = vec![node(0, None, "DOCUMENT", "Document", vec![]), node(1, Some((0, "!")), "CANVAS", "Page", vec![])]; + for (i, hash) in references.iter().enumerate() { + nodes.push(node( + i as u64 + 2, + Some((1, &format!("{i:04}"))), + "RECTANGLE", + &format!("Image {i}"), + vec![ + ("size", xy(10.0, 10.0)), + ("fillPaints", arr(vec![o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(vec![*hash; 20]))]))])])), + ], + )); + } + let canvas = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])); + let mut z = zip::ZipWriter::new(std::io::Cursor::new(Vec::new())); + let deflated = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated); + z.start_file("canvas.fig", deflated).unwrap(); + z.write_all(&canvas).unwrap(); + for (hash, bytes) in members { + z.start_file(format!("images/{}", format!("{hash:02x}").repeat(20)), deflated).unwrap(); + z.write_all(bytes).unwrap(); + } + z.finish().unwrap().into_inner() + } + + fn bytes(doc: &Document, index: usize) -> &[u8] { + let node = named(doc, &format!("Image {index}")); + let figcraft_doc::color::PaintKind::Image(fill) = &node.fills[0].kind else { panic!("not an image fill") }; + &doc.images[&fill.image].bytes + } + + fn budget_warnings(warnings: &[String]) -> Vec<&str> { + warnings.iter().filter(|w| w.contains("total ZIP image byte budget")).map(String::as_str).collect() + } + + fn change_member_field(zip: &mut [u8], hash: u8, offset: usize, value: u32) { + let start = { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&*zip)).unwrap(); + z.by_name(&format!("images/{}", format!("{hash:02x}").repeat(20))).unwrap().central_header_start() as usize + }; + zip[start + offset..start + offset + 4].copy_from_slice(&value.to_le_bytes()); + } + + #[test] + fn exact_total_limit_keeps_all_images_and_each_import_gets_its_own_budget() { + let members = [(1, image(1)), (2, image(2))]; + let limit = members.iter().map(|(_, b)| b.len() as u64).sum(); + let zip = file(&[1, 2], &members); + for _ in 0..2 { + let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap(); + assert_eq!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), members[1].1); + assert!(warnings.is_empty(), "{warnings:?}"); + } + } + + #[test] + fn individually_acceptable_images_overflow_the_total_and_leave_placeholders() { + let members = [(1, image(1)), (2, image(2))]; + let limit = members.iter().map(|(_, b)| b.len() as u64).sum::() - 1; + assert!(members.iter().all(|(_, b)| b.len() as u64 <= limit)); + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap(); + assert_eq!(bytes(&doc, 0), members[0].1); + assert_ne!(bytes(&doc, 1), members[1].1); + assert!(!doc.images.contains_key(&crate::image_hash(&members[1].1))); + let notes = budget_warnings(&warnings); + assert_eq!(notes.len(), 1, "{warnings:?}"); + assert!(notes[0].contains("1 ZIP image") && notes[0].contains(&limit.to_string()), "{warnings:?}"); + } + + #[test] + fn repeated_references_do_not_charge_or_count_a_rejection_twice() { + let members = [(1, image(1)), (2, image(2))]; + let (doc, warnings) = import_with_image_limit(&file(&[1, 1, 2, 2, 1], &members), "Images", members[0].1.len() as u64).unwrap(); + for i in [0, 1, 4] { + assert_eq!(bytes(&doc, i), members[0].1); + } + assert_eq!(bytes(&doc, 2), bytes(&doc, 3)); + assert_ne!(bytes(&doc, 2), members[1].1); + let notes = budget_warnings(&warnings); + assert_eq!(notes.len(), 1, "{warnings:?}"); + assert!(notes[0].contains("1 ZIP image"), "{warnings:?}"); + } + + #[test] + fn distinct_members_with_identical_contents_still_use_the_read_budget() { + let png = image(1); + let members = [(1, png.clone()), (2, png.clone())]; + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap(); + assert_eq!(bytes(&doc, 0), png); + assert_ne!(bytes(&doc, 1), png); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn rejecting_a_member_before_reading_leaves_room_for_a_smaller_one() { + let png = image(1); + let members = [(1, vec![0; png.len() + 1]), (2, png.clone())]; + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", png.len() as u64).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), png); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn failed_crc_reads_still_consume_the_bytes_read() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + change_member_field(&mut zip, 1, 16, 0); + let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64 + 1).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert_ne!(bytes(&doc, 1), members[1].1); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn a_truncated_deflate_read_keeps_its_partial_charge() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + // Shorten the first member's compressed size, cutting its deflate stream mid-image. + change_member_field(&mut zip, 1, 20, 15); + let limit = members[0].1.len() as u64; + let archive = Archive::open(&zip).unwrap(); + archive.image_budget.remaining.set(limit); + assert!(archive.image(&"01".repeat(20)).is_none()); + let remaining = archive.image_budget.remaining.get(); + assert!(remaining > 0 && remaining < limit, "partial read left {remaining} of {limit} bytes"); + assert!(archive.image(&"02".repeat(20)).is_none()); + assert_eq!(archive.image_budget.remaining.get(), remaining); + let (doc, warnings) = import_with_image_limit(&zip, "Images", limit).unwrap(); + assert_ne!(bytes(&doc, 1), members[1].1); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn the_individual_image_cap_is_preserved_without_reading_a_large_member() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + change_member_field(&mut zip, 1, 24, (MAX_IMAGE + 1) as u32); + let (doc, warnings) = import_with_image_limit(&zip, "Images", members[1].1.len() as u64).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), members[1].1); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } + + #[test] + fn crc_is_checked_even_when_the_read_ends_exactly_at_the_budget() { + let members = [(1, image(1))]; + let mut zip = file(&[1], &members); + change_member_field(&mut zip, 1, 16, 0); + let (doc, warnings) = import_with_image_limit(&zip, "Images", members[0].1.len() as u64).unwrap(); + assert_ne!(bytes(&doc, 0), members[0].1); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } + + #[test] + fn an_understated_size_cannot_read_past_the_remaining_budget() { + let members = [(1, image(1)), (2, image(2))]; + let mut zip = file(&[1, 2], &members); + change_member_field(&mut zip, 1, 24, 1); + let archive = Archive::open(&zip).unwrap(); + archive.image_budget.remaining.set(members[0].1.len() as u64 - 1); + assert!(archive.image(&"01".repeat(20)).is_none()); + assert_eq!(archive.image_budget.remaining.get(), 0); + assert_eq!(archive.image_budget.skipped.get(), 1); + assert!(archive.image(&"02".repeat(20)).is_none()); + assert_eq!(archive.image_budget.remaining.get(), 0); + assert_eq!(archive.image_budget.skipped.get(), 2); + } + + #[test] + fn invalid_image_contents_are_charged_before_image_validation() { + let members = [(1, vec![0; 8]), (2, image(2))]; + let limit = members[1].1.len() as u64 + 7; + let (doc, warnings) = import_with_image_limit(&file(&[1, 2], &members), "Images", limit).unwrap(); + assert_ne!(bytes(&doc, 1), members[1].1); + assert_eq!(budget_warnings(&warnings).len(), 1, "{warnings:?}"); + } + + #[test] + fn missing_and_unreferenced_members_do_not_consume_the_budget() { + let members = [(1, image(1)), (2, vec![0; 128])]; + let (doc, warnings) = import_with_image_limit(&file(&[9, 1], &members), "Images", members[0].1.len() as u64).unwrap(); + assert_eq!(bytes(&doc, 1), members[0].1); + assert!(warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } + + #[test] + fn embedded_image_fallback_is_independent_of_the_zip_budget() { + let canvas = canvas(&schema(), &message_with_image_in_blobs(0)); + for bytes in [&canvas, &zip_file(&canvas)] { + let (doc, warnings) = import_with_image_limit(bytes, "Images", 0).unwrap(); + let photo = named(&doc, "Photo"); + let figcraft_doc::color::PaintKind::Image(fill) = &photo.fills[0].kind else { panic!("not an image fill") }; + assert_eq!(*doc.images[&fill.image].bytes, png()); + assert!(!warnings.iter().any(|w| w.contains("isn't in the file")), "{warnings:?}"); + assert_eq!(budget_warnings(&warnings).len(), usize::from(bytes.starts_with(b"PK")), "{warnings:?}"); + } + } + + #[test] + fn default_import_preserves_image_bytes_dimensions_and_existing_warnings() { + let members = [(1, image(1)), (2, image(2))]; + let (doc, warnings) = import(&file(&[1, 2], &members), "Images").unwrap(); + assert_eq!(bytes(&doc, 0), members[0].1); + assert_eq!(bytes(&doc, 1), members[1].1); + assert!(doc.images.values().all(|img| (img.width, img.height) == (2, 1))); + assert!(warnings.is_empty(), "{warnings:?}"); + let (doc, warnings) = import(&fixture(), "Fixture").unwrap(); + assert!(doc.images.values().any(|img| *img.bytes == png() && (img.width, img.height) == (2, 1))); + assert!(budget_warnings(&warnings).is_empty(), "{warnings:?}"); + } +} + #[test] fn other_files_are_not_fig() { assert!(try_import(b"{\"document\": {}}", "x").is_none()); diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 4e26b6142..02f4ff558 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); 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 @@ -17,8 +17,8 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.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), 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). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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 | -| `.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), 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, 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 (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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 | +| `.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), 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). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), 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. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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 | +| `.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), 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, 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 (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), 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. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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, 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 | — | @@ -185,6 +185,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1076: `.fig` ZIP images now share a budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images. The 256 MiB member cap stays; members that do not fit are skipped before allocation with an import warning. Failed reads are charged too, repeated references keep their cached resolution, and smaller later members can still fit. Embedded images and decoded pixel memory are outside this budget. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | | 2026-10-30 | minor | M8, #1082: SVG and PDF exports draw a pattern's source one level below the layer it fills, and the images they embed of layers and pattern cells go on from the layer's depth, so one depth cap bounds a whole export. Numbers unchanged. | | 2026-10-30 | minor | M8, #1079: a `.figcraft` file unpacks at most 6 GiB in all when it opens (2 GiB in the browser), every area together, beyond each area's own cap; what passes it is left out with a note (images stay in the file, #1313). Numbers unchanged. | | 2026-10-30 | minor | M8, #1077: `.fig` import: instance expansion no longer walks variable nodes per instance or copies every outer override row into each nested instance; crafted files that took 14 s and 40 s import in 21 ms and 98 ms, normal files map as before. Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 889eccb07..c09019893 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (`.fig` ZIP image reads share a total byte budget and count skipped images in the import warnings (#1076); 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -56,7 +56,7 @@ blockers. | M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | | M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11; best-effort `.sketch` import 2026-10-21) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | | M7 Release & fidelity (signed builds, look & feel, shortcuts) | builds done; fidelity pass started | tool-by-tool pass | 40–60 | -| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% (explicit variables files capped at 256 MiB 2026-10-30, part of #1114) | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | +| M8 Hardening & performance (bench, retained canvas, accessibility, idle) | ~60% (`.fig` ZIP image reads bounded in total, #1076) (explicit variables files capped at 256 MiB 2026-10-30, part of #1114) | real-file corpus, platform QA, GPU canvas, screen readers | 60–110 | | M9 Real-time collaboration | core done (#103: ids, change sets, server ordering, convergence); live sessions (WebSocket transport with limits and reconnect, hosting from the app, invite links, document sync between apps, cursors, selections, avatars, follow, `collab.*` commands); per-user undo, rejection handling, offline queue and resuming after a reconnect; styles, variables, collections, modes and comments created in a session get ids from their creator's space; others' text carets and selections on the canvas; the browser client (joins desktop-hosted sessions); `figcraft://` links open the app and join (Windows registers per user; macOS and Linux packages declare the scheme); the standalone `figcraft-server` (persistence with compaction, restarts that lose nothing confirmed, publishing from the app, share links to edit or view, revocable); team libraries kept on the server and enabled by a share link, with usage counts, and swapping a library; `wss://` from the desktop app, server releases and a deployment guide | accounts, teams | 45–90 | | M10 Localization | ~39%: the layer, English, Russian and Turkish reviewed by native speakers; Spanish (with a Latin American overlay), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Arabic draft: 2,508 entries, native review and panel mirroring pending | native reviews (including the Arabic draft), Hindi, Vietnamese, Arabic panel mirroring, engine messages and number formatting ([localization-parity](localization-parity.md)) | 29–51 | | M11 Plugins | runtime, API, menu, manager and SDK done (#101); plugin panels and settings (ABI v2); widgets (API 3), panels side by side, colour picker on plugin fields; a plugin directory on `figcraft-server` (publish, browse, install, update); widget fields and property menus (API 4) | a public catalogue, publisher accounts and signatures, the directory in the browser, widget hover and timers; Figma plugin compatibility is an owner decision | 6–22 | @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1076: `.fig` ZIP image reads share a total budget, 4 GiB on 64-bit targets and 1 GiB on 32-bit targets, keeping the 256 MiB per-image cap. Members that do not fit are skipped before allocation with an import warning; failed reads consume their bytes, repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | | 2026-10-30 | minor | M8, #1082 (the rest): SVG and PDF pattern sources and the renders exports embed go on from the layer's depth: one cap for a whole export. Tests: `pattern_depth_tests` (SVG, PDF), `render_depth_tests` | | 2026-10-30 | minor | M8, #1082: pattern sources go on from the depth of the layer they fill (one cap for the whole render); render, save and band threads get 8 MiB stacks (a level takes about 6.5 KB). Tests: `render_depth_tests` | | 2026-10-30 | minor | M8, #1114 (explicit variables files): `variable.import` and the `variables` of `file.open` and `file.import` read a path with the same 256 MiB cap and regular-file check as the sibling `variables.json`, so an oversized file, a folder or a device is refused before the document changes; inline objects and JSON text, access roots and the browser's error are unchanged. Primary file reads and `image.place` are still uncapped. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged | From 2f47014917600058edb015ed65ce44945614a3c7 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 06:48:01 +0300 Subject: [PATCH 08/34] M3: stored variable and collection references keep their identity (#1613) Co-Authored-By: Claude Opus 5.5 Co-Authored-By: Claude Sonnet 5.5 Co-Authored-By: Claude Fable 5.1 Co-Authored-By: GPT-6.1 Sol Co-Authored-By: GPT-6 Astra Co-Authored-By: GPT-6.1 Terra --- crates/doc/src/proto_expr.rs | 20 ++- crates/doc/src/proto_expr_mode_tests.rs | 34 +++++ crates/doc/src/proto_expr_parse.rs | 10 +- crates/doc/src/styles.rs | 7 +- crates/doc/src/tests.rs | 14 ++ crates/engine/src/assets/library.rs | 13 +- crates/engine/src/assets/library_swap.rs | 8 +- .../engine/src/assets/library_swap/tests.rs | 32 ++++ crates/engine/src/cmd/library.rs | 7 +- crates/engine/src/cmd/proto.rs | 38 +---- crates/engine/src/cmd/proto_actions.rs | 71 ++++++++- crates/engine/src/cmd/styles.rs | 2 +- crates/engine/src/cmd/variables.rs | 18 ++- crates/engine/src/cmd/variables_io.rs | 4 +- crates/engine/src/dev/devinspect.rs | 2 +- crates/engine/src/devinspect_tests.rs | 19 +++ crates/engine/src/fig_export_assets_tests.rs | 23 +++ crates/engine/src/library_variables_tests.rs | 33 +++++ crates/engine/src/proto_run.rs | 16 +- crates/engine/src/proto_run_tests.rs | 137 ++++++++++++++++++ .../engine/src/variables_behaviour_tests.rs | 21 +++ crates/engine/src/variables_editor_tests.rs | 63 ++++++++ crates/engine/src/variables_io_tests.rs | 83 +++++++++++ crates/format/src/fig/export/assets.rs | 4 +- crates/format/src/penpot/export.rs | 2 +- crates/format/src/penpot/export_tests.rs | 24 +++ crates/format/src/rest/conditional_tests.rs | 31 ++++ crates/format/src/rest/proto.rs | 4 +- crates/ui-egui/src/panels/variable_ui.rs | 4 +- crates/ui-egui/src/present/actions.rs | 2 +- crates/ui-egui/src/present/variables_tests.rs | 31 +++- crates/ui-egui/src/proto_responsive_tests.rs | 28 ++++ docs/components-parity.md | 5 +- docs/roadmap.md | 5 +- 34 files changed, 717 insertions(+), 98 deletions(-) diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index d5441b283..e40d3ef7a 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -103,10 +103,12 @@ impl Expr { Expr::Value(VariableValue::Alias(id)) => return vars.resolve(id, modes), Expr::Value(VariableValue::Number(n)) => return n.is_finite().then_some(VariableValue::Number(*n)), Expr::Value(v) => return Some(v.clone()), - Expr::Mode { collection } => return vars.collection(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone())), + Expr::Mode { collection } => { + return vars.collection_by_id(collection)?.current_mode(modes).map(|m| VariableValue::String(m.name.clone())); + } Expr::InMode { variable, mode } => { - let v = vars.get(variable)?; - let c = vars.collection(&v.collection)?; + let v = vars.get_by_id(variable)?; + let c = vars.collection_by_id(&v.collection)?; let mut at = modes.clone(); at.insert(c.id.clone(), c.find_mode(mode)?.id.clone()); return vars.resolve(&v.id, &at); @@ -143,20 +145,22 @@ impl Expr { return "…".into(); } match self { - Expr::Value(VariableValue::Alias(id)) => vars.get(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)), + Expr::Value(VariableValue::Alias(id)) => vars.get_by_id(id).map_or_else(|| "(missing variable)".into(), |v| var_ref(vars, v)), Expr::Value(VariableValue::String(s)) => quote(s), // Exact numbers and `#` colours, so the text reads back as the same expression. Expr::Value(VariableValue::Number(n)) => format!("{n}"), Expr::Value(VariableValue::Color(c)) => c.to_css_hex(), Expr::Value(v) => v.display(), Expr::Mode { collection } => { - let name = vars.collection(collection).map_or("(missing collection)", |c| c.name.as_str()); + let name = vars.collection_by_id(collection).map_or("(missing collection)", |c| c.name.as_str()); format!("mode({})", name_arg(name)) } Expr::InMode { variable, mode } => { - let var = vars.get(variable); - let mode = - var.and_then(|v| vars.collection(&v.collection)).and_then(|c| c.find_mode(mode)).map_or("(missing mode)", |m| m.name.as_str()); + let var = vars.get_by_id(variable); + let mode = var + .and_then(|v| vars.collection_by_id(&v.collection)) + .and_then(|c| c.find_mode(mode)) + .map_or("(missing mode)", |m| m.name.as_str()); format!("{}[{}]", Expr::var(variable.clone()).display_at(vars, depth + 1), name_arg(mode)) } Expr::Op { op, args } => { diff --git a/crates/doc/src/proto_expr_mode_tests.rs b/crates/doc/src/proto_expr_mode_tests.rs index ea2757c7b..1a8d8b78b 100644 --- a/crates/doc/src/proto_expr_mode_tests.rs +++ b/crates/doc/src/proto_expr_mode_tests.rs @@ -138,3 +138,37 @@ fn lookups_evaluate_in_their_mode_and_modes_follow_the_layer() { assert_eq!(mode_of("gone").eval(&vars, &light), None); assert_eq!(in_mode("n", "dark").variables(), ["n"]); } + +#[test] +fn stored_variable_ids_keep_expression_evaluation_display_and_types() { + let mut vars = more_vars(); + let mut shadow = vars.get_by_id("s").unwrap().clone(); + shadow.id = "shadow".into(); + shadow.name = "n".into(); + vars.variables.insert(0, shadow); + let mut shadow = vars.collection("cs").unwrap().clone(); + shadow.id = "shadow".into(); + shadow.name = "c".into(); + vars.collections.insert(0, shadow); + let modes = BTreeMap::new(); + for e in [Expr::var("n"), in_mode("n", "dark")] { + assert_eq!(e.type_of(&vars), Ok(VariableType::Number)); + assert!(e.display(&vars).starts_with("n-name")); + } + assert_eq!(Expr::var("n").eval(&vars, &modes), Some(VariableValue::Number(3.0))); + assert_eq!(in_mode("n", "dark").eval(&vars, &modes), Some(VariableValue::Number(7.0))); + assert_eq!(Expr::parse("n-name[Dark]", &vars), Ok(in_mode("n", "dark"))); + assert_eq!(mode_of("c").eval(&vars, &modes), Some(VariableValue::String("Light".into()))); + assert_eq!(mode_of("c").display(&vars), "mode(Theme)"); + + vars.variables.retain(|v| v.id != "n"); + for e in [Expr::var("n"), in_mode("n", "dark")] { + assert_eq!(e.eval(&vars, &modes), None); + assert_eq!(e.type_of(&vars), Err("No variable `n`".into())); + assert!(e.display(&vars).contains("missing variable")); + } + vars.collections.retain(|c| c.id != "c"); + assert_eq!(mode_of("c").eval(&vars, &modes), None); + assert_eq!(mode_of("c").type_of(&vars), Err("No variable collection `c`".into())); + assert_eq!(mode_of("c").display(&vars), "mode(\"(missing collection)\")"); +} diff --git a/crates/doc/src/proto_expr_parse.rs b/crates/doc/src/proto_expr_parse.rs index 54615ee04..660b4a836 100644 --- a/crates/doc/src/proto_expr_parse.rs +++ b/crates/doc/src/proto_expr_parse.rs @@ -88,15 +88,15 @@ impl Expr { return Err("The expression is nested too deeply".into()); } let (op, args) = match self { - Expr::Value(VariableValue::Alias(id)) => return vars.get(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")), + Expr::Value(VariableValue::Alias(id)) => return vars.get_by_id(id).map(|v| v.kind).ok_or_else(|| format!("No variable `{id}`")), Expr::Value(VariableValue::Number(n)) if !n.is_finite() => return Err("Numbers must be finite".into()), Expr::Value(v) => return v.type_of().ok_or_else(|| "Not a value".to_string()), Expr::Mode { collection } => { - return vars.collection(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`")); + return vars.collection_by_id(collection).map(|_| T::String).ok_or_else(|| format!("No variable collection `{collection}`")); } Expr::InMode { variable, mode } => { - let v = vars.get(variable).ok_or_else(|| format!("No variable `{variable}`"))?; - let c = vars.collection(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?; + let v = vars.get_by_id(variable).ok_or_else(|| format!("No variable `{variable}`"))?; + let c = vars.collection_by_id(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?; return c.find_mode(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name)); } Expr::Op { op, args } => (*op, args), @@ -501,7 +501,7 @@ impl Parser<'_> { self.i += 1; let start = self.i; let name = self.argument(']', "mode")?; - let coll = self.vars.get(&var).and_then(|v| self.vars.collection(&v.collection)); + let coll = self.vars.get_by_id(&var).and_then(|v| self.vars.collection_by_id(&v.collection)); match coll.and_then(|c| c.find_mode(&name)) { Some(m) => Ok(Expr::InMode { variable: var, mode: m.id.clone() }), None => { diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 92ea24cc3..9f8dbdb7e 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -278,13 +278,18 @@ impl Variables { self.collections.iter().find(|c| c.id == id || c.name == id) } + /// Look up a stored collection reference by exact id, without matching collection names. + pub fn collection_by_id(&self, id: &str) -> Option<&Collection> { + self.collections.iter().find(|c| c.id == id) + } + /// Resolve `id` in `modes` (collection → chosen mode; default mode otherwise), following aliases /// (bounded, cycles return None). pub fn resolve(&self, id: &str, modes: &BTreeMap) -> Option { let mut cur = id.to_string(); for _ in 0..32 { let var = self.get_by_id(&cur)?; - let coll = self.collection(&var.collection)?; + let coll = self.collection_by_id(&var.collection)?; let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?; match var.values.get(mode).or_else(|| var.values.values().next())? { VariableValue::Alias(next) => cur = next.clone(), diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 0ed0ef353..5b1cf23c4 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -708,3 +708,17 @@ fn two_segment_closed_loop_rounded_corners() { let path = net.fill_path(); assert!(!path.elements().is_empty()); } + +#[test] +fn stored_variable_ids_resolve_only_their_exact_collection() { + let mut vars = crate::proto_expr_tests::vars(); + // The wrong collection's modes force the old resolver's first-value fallback (dark). + let mut shadow = vars.collections[0].clone(); + shadow.id = "shadow".into(); + shadow.name = "c".into(); + shadow.modes = vec![Mode { id: "other".into(), name: "Other".into() }]; + vars.collections.insert(0, shadow); + assert_eq!(vars.resolve("n", &Default::default()), Some(VariableValue::Number(3.0))); + vars.collections.retain(|c| c.id != "c"); + assert_eq!(vars.resolve("n", &Default::default()), None); +} diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs index 24699ef2c..94500eac2 100644 --- a/crates/engine/src/assets/library.rs +++ b/crates/engine/src/assets/library.rs @@ -240,7 +240,10 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele .collections .into_iter() .filter(|c| { - pick(prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())), doc.variables.collection(c).map(|x| x.name.as_str())) + pick( + prev.and_then(|m| m.collection(c)).map(|x| (x.published, x.name.as_str())), + doc.variables.collection_by_id(c).map(|x| x.name.as_str()), + ) }) .collect(), } @@ -251,7 +254,7 @@ fn alias_closure(vars: &Variables, seeds: impl IntoIterator) let mut out = BTreeSet::new(); let mut stack: Vec = seeds.into_iter().collect(); while let Some(cur) = stack.pop() { - let Some(v) = vars.get(&cur) else { continue }; + let Some(v) = vars.get_by_id(&cur) else { continue }; if out.len() >= MAX_ASSETS || !out.insert(v.id.clone()) { continue; } @@ -702,7 +705,7 @@ pub fn import_variables( ) -> VariableMap { let (lid, m, src) = (lib.manifest.id.as_str(), &lib.manifest, &lib.doc.variables); let vars = alias_closure(src, keys.iter().cloned()); - let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get(v)).map(|v| &v.collection)).collect(); + let colls: BTreeSet<&CollectionId> = collections.iter().chain(vars.iter().filter_map(|v| src.get_by_id(v)).map(|v| &v.collection)).collect(); let mut out = VariableMap::default(); // Collections first: the variables name theirs. for key in colls { @@ -758,7 +761,7 @@ pub fn import_variables( let val = match val { // An alias to a variable the library doesn't have resolves to nothing: dropped // (never pointed at a variable of this file). - VariableValue::Alias(a) => match src.get(a).and_then(|t| out.variables.get(&t.id)) { + VariableValue::Alias(a) => match src.get_by_id(a).and_then(|t| out.variables.get(&t.id)) { Some(l) => VariableValue::Alias(l.clone()), None => continue, }, @@ -955,7 +958,7 @@ pub fn pending(doc: &Document, lib: &PublishedLibrary) -> Pending { .filter(|r| r.library == m.id) .map(|r| r.key.as_str()) .collect(); - let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get(&r.local).is_some()); + let copied = |key: &str| libs.variables.iter().any(|r| r.library == m.id && r.key == key && vars.get_by_id(&r.local).is_some()); variables.extend(published_variables(lib).filter(|v| extended.contains(v.collection.as_str()) && !copied(&v.id)).map(|v| ImportedRecord { library: m.id.clone(), key: v.id.clone(), diff --git a/crates/engine/src/assets/library_swap.rs b/crates/engine/src/assets/library_swap.rs index 1dfc1691e..f4af1c34f 100644 --- a/crates/engine/src/assets/library_swap.rs +++ b/crates/engine/src/assets/library_swap.rs @@ -149,7 +149,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re out.styles = styles.len(); // Variables: by collection and variable name and type; collections and modes by name. - let coll_name = |d: &Document, id: &str| d.variables.collection(id).map(|c| c.name.clone()).unwrap_or_default(); + let coll_name = |d: &Document, id: &str| d.variables.collection_by_id(id).map(|c| c.name.clone()).unwrap_or_default(); // Variables the document's own layers bind or its own variables alias. let remote: BTreeSet = doc.libraries.variables.iter().map(|r| r.local.clone()).collect(); let aliases = doc.variables.variables.iter().filter(|v| !remote.contains(&v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x { @@ -161,7 +161,7 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re let lib_coll = |name: &str| to.doc.variables.collections.iter().find(|c| c.name == name && m.collection(&c.id).is_some_and(|x| x.published)); let mut var_keys: BTreeMap = BTreeMap::new(); for r in &var_recs { - let Some(v) = doc.variables.get(&r.local) else { continue }; + let Some(v) = doc.variables.get_by_id(&r.local) else { continue }; let cname = coll_name(doc, &v.collection); let found = lib_coll(&cname).and_then(|c| { to.doc @@ -189,10 +189,10 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re // A's collection → (B's, A's mode id → B's mode of the same name). let mut colls: BTreeMap)> = BTreeMap::new(); for (a, k) in &coll_keys { - let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection(a)) else { continue }; + let (Some(b), Some(ac)) = (vmap.collections.get(k), doc.variables.collection_by_id(a)) else { continue }; let modes = doc .variables - .collection(b) + .collection_by_id(b) .map(|bc| { ac.modes.iter().filter_map(|am| bc.modes.iter().find(|bm| bm.name == am.name).map(|bm| (am.id.clone(), bm.id.clone()))).collect() }) diff --git a/crates/engine/src/assets/library_swap/tests.rs b/crates/engine/src/assets/library_swap/tests.rs index fa01e05a8..db769a0e6 100644 --- a/crates/engine/src/assets/library_swap/tests.rs +++ b/crates/engine/src/assets/library_swap/tests.rs @@ -114,3 +114,35 @@ fn swap_moves_instances_styles_and_variables_by_name() { assert!(s.execute("library.swap", &json!({"from": b, "to": b})).is_err()); assert!(s.execute("library.swap", &json!({"from": a, "to": b})).is_err()); } + +#[test] +fn stored_variable_ids_keep_library_swap_matches_on_the_real_copies() { + let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-ids-{}", std::process::id()))); + std::fs::create_dir_all(&dir.0).unwrap(); + let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned()); + let a = library(&pa, "#0000ff", &[]); + let b = library(&pb, "#00ff00", &[]); + let mut s = Session::new(); + let shadow_collection = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"})); + let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#ff0000"})); + ex(&mut s, "library.add", json!({"path": pa})); + ex(&mut s, "library.add", json!({"path": pb})); + let dot = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); + ex(&mut s, "library.bindVariable", json!({"library": a, "variable": "accent", "field": "fill", "ids": [dot]})); + ex(&mut s, "library.setVariableMode", json!({"library": a, "collection": "Theme", "mode": "Dark", "ids": [dot]})); + let doc = s.doc().unwrap(); + let target = doc.libraries.variables[0].local.clone(); + let collection = doc.libraries.collections[0].local.clone(); + ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": target})); + ex(&mut s, "variable.renameCollection", json!({"collection": shadow_collection["id"], "name": collection})); + let result = ex(&mut s, "library.swap", json!({"from": a, "to": b})); + assert_eq!(result["variables"], 1); + assert_eq!(result["unmatched"], json!([])); + let doc = s.doc().unwrap(); + let node = doc.node(dot).unwrap(); + assert_eq!(node.fills[0].solid_color().unwrap().to_hex(), "00FF00"); + assert!(!node.bound.is_empty()); + assert!(!node.var_modes.is_empty()); + assert!(node.bound.values().all(|v| doc.libraries.variable_import(v).is_some_and(|r| r.library == b))); + assert!(node.var_modes.keys().all(|c| doc.libraries.collection_import(c).is_some_and(|r| r.library == b))); +} diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 0c7edbb2b..e562cfd6a 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -238,7 +238,7 @@ fn alias(s: &mut Session, p: &Value) -> Result { let kind = doc.variables.get(&own).filter(|v| !is_remote_variable(doc, &v.id)).map(|v| v.kind); let kind = kind.ok_or_else(|| bad(cmd, format!("no local variable `{own}`")))?; let (lib, key) = variable_arg(s, cmd, p, "alias")?; - if lib.doc.variables.get(&key).is_some_and(|v| v.kind != kind) { + if lib.doc.variables.get_by_id(&key).is_some_and(|v| v.kind != kind) { return Err(bad(cmd, "the library variable has another type")); } let local = import_variable(s, &lib, &key)?; @@ -467,7 +467,8 @@ fn swap_cmd(s: &mut Session, p: &Value) -> Result { fn assets_json(lib: &PublishedLibrary) -> Value { let m = &lib.manifest; - let modes = |c: &str| lib.doc.variables.collection(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::>()); + let modes = + |c: &str| lib.doc.variables.collection_by_id(c).map(|c| c.modes.iter().map(|x| json!({"id": x.id, "name": x.name})).collect::>()); json!({ "components": m.components.iter().filter(|a| a.published).map(|a| json!({"id": a.id, "name": a.name, "set": a.set, "version": a.version})).collect::>(), "styles": m.styles.iter().filter(|x| x.published).map(|x| json!({"id": x.id, "name": x.name, "type": x.kind, "version": x.version})).collect::>(), @@ -513,7 +514,7 @@ fn list(s: &mut Session, p: &Value) -> Result { res["publishable"] = json!({ "components": all.components.iter().filter_map(|c| doc.node(*c).map(|n| json!({"id": c, "name": n.name, "selected": def.components.contains(c)}))).collect::>(), "styles": all.styles.iter().filter_map(|x| doc.style(x).map(|st| json!({"id": st.id, "name": st.name, "selected": def.styles.contains(x)}))).collect::>(), - "collections": all.collections.iter().filter_map(|x| doc.variables.collection(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::>(), + "collections": all.collections.iter().filter_map(|x| doc.variables.collection_by_id(x).map(|c| json!({"id": c.id, "name": c.name, "selected": def.collections.contains(x)}))).collect::>(), }); } Ok(res) diff --git a/crates/engine/src/cmd/proto.rs b/crates/engine/src/cmd/proto.rs index 3f51bb5e5..a620b0371 100644 --- a/crates/engine/src/cmd/proto.rs +++ b/crates/engine/src/cmd/proto.rs @@ -308,40 +308,6 @@ pub(crate) fn check_action_dests(cmd: &str, doc: &Document, actions: &[Action]) Ok(()) } -/// Variables, collections and modes that actions name by name, stored by id. -fn canonical_ids(doc: &Document, it: &mut Interaction) { - canonical_actions(&doc.variables, &mut it.actions); -} - -/// Variables (also those expressions read), collections and modes that `actions` name by name, -/// stored by id. -pub(crate) fn canonical_actions(vars: &Variables, actions: &mut [Action]) { - walk_mut(actions, &mut |a| match a { - Action::SetVariable { variable, expr, .. } => { - if let Some(v) = vars.get(variable) { - *variable = v.id.clone(); - } - if let Some(e) = expr { - canonical_expr(vars, e, 0); - } - } - Action::SetVariableMode { collection, mode } => { - if let Some(c) = vars.collection(collection) { - if let Some(m) = c.find_mode(mode) { - *mode = m.id.clone(); - } - *collection = c.id.clone(); - } - } - Action::Conditional { blocks } => { - for e in blocks.iter_mut().filter_map(|b| b.condition.as_mut()) { - canonical_expr(vars, e, 0); - } - } - _ => {} - }); -} - /// Variables, collections and modes an expression names by name, stored by id. pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) { match e { @@ -357,7 +323,7 @@ pub(crate) fn canonical_expr(vars: &Variables, e: &mut Expr, depth: usize) { } Expr::InMode { variable, mode } => { if let Some(v) = vars.get(variable) { - if let Some(m) = vars.collection(&v.collection).and_then(|c| c.find_mode(mode)) { + if let Some(m) = vars.collection_by_id(&v.collection).and_then(|c| c.find_mode(mode)) { *mode = m.id.clone(); } *variable = v.id.clone(); @@ -425,7 +391,6 @@ fn add(s: &mut Session, p: &Value) -> Result { let mut it = it; it.delay_ms = delay(cmd, p)?.unwrap_or(0); settle(&mut it); - canonical_ids(s.doc()?, &mut it); check_dests(cmd, s, &it)?; let mut index = 0; for i in req_targets(cmd, s, p)? { @@ -484,7 +449,6 @@ fn set(s: &mut Session, p: &Value) -> Result { if it.actions.is_empty() { return Err(bad(cmd, "an interaction needs an action")); } - canonical_ids(s.doc()?, &mut it); check_dests(cmd, s, &it)?; if let Some(slot) = s.doc_mut()?.get_mut(i)?.interactions.get_mut(k) { *slot = it; diff --git a/crates/engine/src/cmd/proto_actions.rs b/crates/engine/src/cmd/proto_actions.rs index 95a53a9d4..9af669cd4 100644 --- a/crates/engine/src/cmd/proto_actions.rs +++ b/crates/engine/src/cmd/proto_actions.rs @@ -159,15 +159,73 @@ pub fn default_condition(vars: &Variables) -> Expr { } } -/// `p` with every `expression` text in it read: on a set variable action it becomes `expr` -/// (checked against the variable), elsewhere (conditional blocks) the `condition` (checked to give -/// true or false). +/// `p` with incoming names stored as ids and every `expression` text read: on a set variable +/// action it becomes `expr` (checked against the variable), elsewhere (conditional blocks) the +/// `condition` (checked to give true or false). Parsed text already holds ids, so it is never +/// canonicalized again. Existing actions that a command leaves alone never pass through here. pub fn with_expressions(cmd: &str, vars: &Variables, p: &Value) -> Result { let mut v = p.clone(); + canonical_inputs(cmd, vars, &mut v, 0)?; resolve_expressions(cmd, vars, &mut v, 0)?; Ok(v) } +/// Visit input actions and conditional blocks, never literal values or parsed expressions. +fn canonical_inputs(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> { + if depth > 4 * MAX_ACTION_DEPTH { + return Err(bad(cmd, "actions are nested too deeply")); + } + match v { + Value::Array(a) => a.iter_mut().try_for_each(|x| canonical_inputs(cmd, vars, x, depth + 1)), + Value::Object(o) => { + canonical_action_names(vars, o); + canonical_json_exprs(cmd, vars, o)?; + for key in ["action", "actions", "interaction", "blocks"] { + if let Some(child) = o.get_mut(key) { + canonical_inputs(cmd, vars, child, depth + 1)?; + } + } + Ok(()) + } + _ => Ok(()), + } +} + +/// Only command input accepts names: settle action targets before reading text expressions. +fn canonical_action_names(vars: &Variables, o: &mut serde_json::Map) { + match o.get("type").and_then(Value::as_str) { + Some("setVariable") => { + if let Some(v) = o.get("variable").and_then(Value::as_str).and_then(|n| vars.get(n)) { + o.insert("variable".into(), json!(v.id)); + } + } + Some("setVariableMode") => { + if let Some(c) = o.get("collection").and_then(Value::as_str).and_then(|n| vars.collection(n)) { + if let Some(m) = o.get("mode").and_then(Value::as_str).and_then(|n| c.find_mode(n)) { + o.insert("mode".into(), json!(m.id)); + } + o.insert("collection".into(), json!(c.id)); + } + } + _ => {} + } +} + +/// JSON expressions accept names too; text replaces them and is parsed separately. +fn canonical_json_exprs(cmd: &str, vars: &Variables, o: &mut serde_json::Map) -> Result<()> { + if o.get("expression").is_some_and(Value::is_string) { + return Ok(()); + } + for key in ["expr", "condition"] { + if let Some(value) = o.get_mut(key).filter(|v| !v.is_null()) { + let mut e: Expr = serde_json::from_value(value.clone()).map_err(|e| bad(cmd, format!("{key}: {e}")))?; + super::proto::canonical_expr(vars, &mut e, 0); + *value = expr_json(cmd, &e)?; + } + } + Ok(()) +} + fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) -> Result<()> { if depth > 4 * MAX_ACTION_DEPTH { return Err(bad(cmd, "actions are nested too deeply")); @@ -181,7 +239,7 @@ fn resolve_expressions(cmd: &str, vars: &Variables, v: &mut Value, depth: usize) let var = o .get("variable") .and_then(Value::as_str) - .and_then(|n| vars.get(n)) + .and_then(|n| vars.get_by_id(n)) .ok_or_else(|| bad(cmd, "setVariable: no such `variable`"))?; e.check_value(vars, var.kind).map_err(|m| bad(cmd, m))?; o.insert("expr".into(), expr_json(cmd, &e)?); @@ -208,7 +266,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> { for x in walk(std::slice::from_ref(a)) { match x { Action::SetVariable { variable, expr, .. } => { - let v = vars.get(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?; + let v = vars.get_by_id(variable).ok_or_else(|| bad(cmd, format!("no variable `{variable}`")))?; if let Some(e) = expr { e.check_value(vars, v.kind).map_err(|m| bad(cmd, m))?; } @@ -231,8 +289,7 @@ fn check_action(cmd: &str, vars: &Variables, a: &Action) -> Result<()> { fn action_param(cmd: &str, s: &Session, p: &Value) -> Result { let doc = s.doc()?; let p = with_expressions(cmd, &doc.variables, p)?; - let mut a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?; - super::proto::canonical_actions(&doc.variables, std::slice::from_mut(&mut a)); + let a: Action = parse(cmd, &p, "action")?.ok_or_else(|| bad(cmd, "`action` required"))?; check_action(cmd, &doc.variables, &a)?; super::proto::check_action_dests(cmd, doc, std::slice::from_ref(&a))?; Ok(a) diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 5ccad4267..b88e31591 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -958,7 +958,7 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result { let v = req_string(cmd, p, "variable")?; let doc = s.doc_mut()?; let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?; - let coll = doc.variables.collection(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?; + let coll = doc.variables.collection_by_id(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?; let mode = match string(p, "mode") { Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?, None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?, diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 24ecb0099..723633f9f 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -89,7 +89,7 @@ fn inherited(cmd: &str, what: &str) -> crate::EngineError { /// Refuse mode edits of extended collections (they have the modes of the collection they extend). pub(crate) fn check_modes_editable(doc: &Document, cmd: &str, collection: &str) -> Result<()> { - match doc.variables.collection(collection) { + match doc.variables.collection_by_id(collection) { Some(c) if c.extends.is_some() => Err(inherited(cmd, "an extended collection's modes")), _ => Ok(()), } @@ -304,7 +304,7 @@ pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet) for v in vars.variables.iter().filter(|v| !gone.contains(&v.id)) { for (m, val) in &v.values { let VariableValue::Alias(a) = val else { continue }; - let Some(t) = vars.get(a).filter(|t| gone.contains(&t.id)) else { continue }; + let Some(t) = vars.get_by_id(a).filter(|t| gone.contains(&t.id)) else { continue }; let modes = std::collections::BTreeMap::from([(t.collection.clone(), m.clone())]); if let Some(r) = vars.resolve(&t.id, &modes) { detached.push((v.id.clone(), m.clone(), r)); @@ -333,11 +333,11 @@ fn reset_override(s: &mut Session, p: &Value) -> Result { let v = req_string(cmd, p, "variable")?; let doc = s.doc_mut()?; let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?; - let parent = var.extends.as_deref().and_then(|x| doc.variables.get(x)).cloned(); + let parent = var.extends.as_deref().and_then(|x| doc.variables.get_by_id(x)).cloned(); let parent = parent.ok_or_else(|| bad(cmd, "only variables an extended collection inherits have overrides"))?; let modes: Vec = match string(p, "mode") { Some(m) => { - let coll = doc.variables.collection(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?; + let coll = doc.variables.collection_by_id(&var.collection).ok_or_else(|| bad(cmd, "variable has no collection"))?; vec![coll.find_mode(&m).ok_or_else(|| bad(cmd, "no such mode"))?.id.clone()] } None => var.overrides.iter().cloned().collect(), @@ -433,6 +433,12 @@ pub fn sync_extensions(doc: &mut Document) { /// lead back to `var`. Returns the target's id. pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::result::Result { let t = vars.get(target).ok_or_else(|| format!("no variable `{target}` to alias"))?; + alias_target_by_id(vars, var, &t.id) +} + +/// Check an alias whose target is already a stored or remapped id, without matching names. +pub(crate) fn alias_target_by_id(vars: &Variables, var: &Variable, target: &str) -> std::result::Result { + let t = vars.get_by_id(target).ok_or_else(|| format!("no variable `{target}` to alias"))?; if t.kind != var.kind { return Err(format!("can't alias a {} variable to a {} one", var.kind.id(), t.kind.id())); } @@ -445,9 +451,9 @@ pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::resu if !seen.insert(cur.clone()) || seen.len() > 10_000 { continue; } - for v in vars.get(&cur).map(|x| x.values.values()).into_iter().flatten() { + for v in vars.get_by_id(&cur).map(|x| x.values.values()).into_iter().flatten() { if let VariableValue::Alias(a) = v - && let Some(next) = vars.get(a) + && let Some(next) = vars.get_by_id(a) { stack.push(next.id.clone()); } diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 0648376b5..3bbadcc7a 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -95,7 +95,7 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)), Some(VariableValue::String(t)) => format!("\"{}\"", css_safe(t)), Some(VariableValue::Boolean(b)) => b.to_string(), - Some(VariableValue::Alias(a)) => match vars.get(a) { + Some(VariableValue::Alias(a)) => match vars.get_by_id(a) { Some(t) => format!("var({})", name(t)), None => continue, }, @@ -330,7 +330,7 @@ fn import(s: &mut Session, p: &Value) -> Result { VariableValue::Alias(a) => { let Some(var) = doc.variables.variables.iter().find(|x| x.id == *local) else { continue }; let target = ids.get(a).ok_or_else(|| "its alias target wasn't imported".to_string()); - match target.and_then(|t| super::variables::alias_target(&doc.variables, var, t)) { + match target.and_then(|t| super::variables::alias_target_by_id(&doc.variables, var, t)) { Ok(t) => VariableValue::Alias(t), Err(e) => { warnings.push(format!("`{}`: {e}", v.name)); diff --git a/crates/engine/src/dev/devinspect.rs b/crates/engine/src/dev/devinspect.rs index dbf3ac913..e646e53f4 100644 --- a/crates/engine/src/dev/devinspect.rs +++ b/crates/engine/src/dev/devinspect.rs @@ -372,7 +372,7 @@ pub fn bound_variable<'d>(doc: &'d Document, n: &Node, field: &str) -> Option<&' _ => &[], }; let found = if forms.is_empty() { n.bound.get(field) } else { forms.iter().find_map(|k| n.bound.get(*k)) }; - found.and_then(|v| doc.variables.get(v)) + found.and_then(|v| doc.variables.get_by_id(v)) } /// The variable bound to `field` on `n`, by name. diff --git a/crates/engine/src/devinspect_tests.rs b/crates/engine/src/devinspect_tests.rs index 5ee590cc7..44a762d20 100644 --- a/crates/engine/src/devinspect_tests.rs +++ b/crates/engine/src/devinspect_tests.rs @@ -58,6 +58,25 @@ fn properties_group_layout_auto_layout_paints_and_tokens() { assert!(s.execute("inspect.properties", &json!({"id": 999_999})).is_err()); } +#[test] +fn stored_variable_ids_keep_their_dev_inspect_token() { + let mut s = Session::new(); + s.execute("variable.createCollection", &json!({"name": "Theme"})).unwrap(); + let shadow = s.execute("variable.create", &json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})).unwrap(); + let surface = s.execute("variable.create", &json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})).unwrap(); + let r = id(s.execute("node.create", &json!({"type": "rectangle", "props": {"fill": {"$var": surface["id"]}}})).unwrap()); + s.execute("variable.update", &json!({"variable": "surface", "codeSyntax": {"WEB": "--real-surface"}})).unwrap(); + s.execute("variable.update", &json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}})).unwrap(); + + let code = s.execute("inspect.code", &json!({"id": r, "language": "css"})).unwrap(); + assert!(code["code"].as_str().unwrap().contains("var(--real-surface")); + assert!(!code["code"].as_str().unwrap().contains("--shadow")); + let out = s.execute("inspect.properties", &json!({"id": r})).unwrap(); + let fills = out["sections"].as_array().unwrap().iter().find(|s| s["title"] == "Fills").unwrap(); + assert_eq!(fills["rows"][0]["color"], "#0D99FF"); + assert_eq!(fills["rows"][0]["token"], "surface"); +} + #[test] fn typography_section_for_text() { let mut s = Session::new(); diff --git a/crates/engine/src/fig_export_assets_tests.rs b/crates/engine/src/fig_export_assets_tests.rs index 9e8567b8d..f5b9f7758 100644 --- a/crates/engine/src/fig_export_assets_tests.rs +++ b/crates/engine/src/fig_export_assets_tests.rs @@ -206,3 +206,26 @@ fn nested_instances_of_variants_link_to_their_mains_layers() { let _ = std::fs::remove_file(&path); assert_eq!(outline(s.doc().unwrap()), before); } + +#[test] +fn stored_variable_ids_keep_exported_binding_identity() { + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let shadow = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})); + let surface = run(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})); + let r = id(&run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}}))); + run(&mut s, "select.set", json!({"ids": [r]})); + run(&mut s, "style.create", json!({"type": "paint", "name": "Surface"})); + run(&mut s, "node.set", json!({"id": r, "props": {"name": "Styled"}})); + run(&mut s, "node.create", json!({"type": "rectangle", "props": {"name": "Bound", "fill": {"$var": surface["id"]}}})); + run(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]})); + let (bytes, _) = figcraft_format::fig::export(s.doc().unwrap(), None).unwrap(); + let (back, _) = figcraft_format::fig::import(&bytes, "identity").unwrap(); + let bound = back.nodes().find(|n| n.name == "Bound").unwrap().bound.values().next().unwrap(); + assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface"); + let style = back.styles.iter().find(|s| s.name == "Surface").unwrap(); + assert!(!style.bound.is_empty()); + for bound in style.bound.values() { + assert_eq!(back.variables.get_by_id(bound).unwrap().name, "surface"); + } +} diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index a811806ed..e2d8535e5 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -428,3 +428,36 @@ fn a_library_published_before_modes_were_overrides_keeps_the_modes_set_inside_ne healthy(&s); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn stored_variable_ids_keep_library_alias_dependencies() { + let mut s = Session::new(); + for name in ["Shadow", "Base", "Theme"] { + ex(&mut s, "variable.createCollection", json!({"name": name})); + } + let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "color", "value": "#FF0000"})); + let surface = ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "color", "value": "#0D99FF"})); + let alias = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}})); + let f = idv(&ex(&mut s, "frame.create", json!({"name": "Chip", "x": 0, "y": 0, "width": 100, "height": 40}))); + ex(&mut s, "variable.bind", json!({"ids": [f], "field": "fill", "variable": alias["id"]})); + let chip = idv(&ex(&mut s, "component.create", json!({"ids": [f]}))); + ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]})); + let doc = s.doc().unwrap(); + let selection = Selection { components: vec![chip], ..Default::default() }; + let lib = publish(doc, None, &selection, None, 0).unwrap(); + let target = surface["id"].as_str().unwrap(); + assert!(lib.doc.variables.get_by_id(target).is_some()); + assert!(lib.doc.variables.get_by_id(shadow["id"].as_str().unwrap()).is_none()); + assert!(lib.doc.variables.collections.iter().any(|c| c.name == "Base")); + + // Import from a snapshot that retains both the dependency and its namesake. + let lib = publish(doc, None, &publishable(doc), None, 1).unwrap(); + let mut dest = Document::new("consumer"); + let key = alias["id"].as_str().unwrap().to_string(); + let map = import_variables(&mut dest, &lib, &std::collections::BTreeSet::from([key.clone()]), &Default::default(), false); + assert!(map.variables.contains_key(target)); + let copy = dest.variables.get_by_id(&map.variables[&key]).unwrap(); + assert!(!copy.values.is_empty()); + assert!(copy.values.values().all(|v| *v == VariableValue::Alias(map.variables[target].clone()))); + assert!(dest.variables.collections.iter().any(|c| c.name == "Base")); +} diff --git a/crates/engine/src/proto_run.rs b/crates/engine/src/proto_run.rs index 3fa374e00..7a25fcbd9 100644 --- a/crates/engine/src/proto_run.rs +++ b/crates/engine/src/proto_run.rs @@ -42,10 +42,11 @@ fn fit(kind: VariableType, v: VariableValue) -> Option { } } -/// Set `variable` to `value`, or to `expr` evaluated in the modes of `node` (the layer whose -/// interaction runs), in every mode, and re-apply bindings. False when nothing could be set. +/// Set stored variable id `variable` to `value`, or to `expr` evaluated in the modes of `node` +/// (the layer whose interaction runs), in every mode, and re-apply bindings. False when nothing +/// could be set. pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Value, expr: Option<&Expr>) -> bool { - let Some(var) = doc.variables.get(variable) else { return false }; + let Some(var) = doc.variables.get_by_id(variable) else { return false }; let (id, kind) = (var.id.clone(), var.kind); let v = match expr { Some(e) => e.eval(&doc.variables, &doc.modes_for(node)).and_then(|v| fit(kind, v)), @@ -65,7 +66,14 @@ pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Va /// on the page's top-level frames that chose another mode of it (modes chosen deeper inside still /// win), then re-apply bindings. False for an unknown collection or mode. pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool { - let Some(c) = doc.variables.collection(collection) else { return false }; + let Some(id) = doc.variables.collection(collection).map(|c| c.id.clone()) else { return false }; + set_mode_by_id(doc, frame, &id, mode) +} + +/// Switch a stored collection id to `mode`, without matching another collection's name. +/// Otherwise behaves like [`set_mode`]. +pub fn set_mode_by_id(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool { + let Some(c) = doc.variables.collection_by_id(collection) else { return false }; let Some(mode) = c.find_mode(mode).map(|m| m.id.clone()) else { return false; }; diff --git a/crates/engine/src/proto_run_tests.rs b/crates/engine/src/proto_run_tests.rs index c59afe91e..cd2d3b671 100644 --- a/crates/engine/src/proto_run_tests.rs +++ b/crates/engine/src/proto_run_tests.rs @@ -207,3 +207,140 @@ fn junk_conditional_actions_never_panic() { } } } + +#[test] +fn stored_variable_ids_set_only_the_prototype_action_target() { + let (mut s, frame, _, _, _) = setup(); + let shadow = var_id(s.doc().unwrap(), "count"); + let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap(); + let target = target["id"].as_str().unwrap().to_string(); + s.execute("variable.update", &json!({"variable": shadow, "name": target})).unwrap(); + let mut doc = s.doc().unwrap().clone(); + assert!(set_variable(&mut doc, frame, &target, &json!(9), None)); + assert_eq!(doc.variables.resolve(&target, &Default::default()), Some(VariableValue::Number(9.0))); + assert_eq!(doc.variables.resolve(&shadow, &Default::default()), Some(VariableValue::Number(0.0))); + doc.variables.variables.retain(|v| v.id != target); + let before = doc.clone(); + assert!(!set_variable(&mut doc, frame, &target, &json!(11), None)); + assert_eq!(doc, before); +} + +#[test] +fn stored_variable_ids_survive_unrelated_interaction_edits() { + let (mut s, frame, _, _, _) = setup(); + let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + s.execute( + "proto.interaction.add", + &json!({"id": frame, "actions": [ + {"type": "back"}, + {"type": "setVariable", "variable": "target", "expression": "target + 1"}, + {"type": "setVariableMode", "collection": "Theme", "mode": "Dark"} + ]}), + ) + .unwrap(); + let theme = s.doc().unwrap().variables.collections[0].id.clone(); + s.execute("variable.createCollection", &json!({"name": "shadow", "modes": ["Light", "Dark"]})).unwrap(); + s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": theme})).unwrap(); + // Put the namesake before the referenced collection, as variable lookup already does. + s.doc_mut().unwrap().variables.collections.swap(0, 1); + s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap(); + let actions = s.doc().unwrap().node(frame).unwrap().interactions[0].actions.clone(); + for patch in [json!({"durationMs": 450}), json!({"action": {"type": "closeOverlay"}})] { + let mut p = json!({"id": frame, "index": 0}); + p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone()); + s.execute("proto.interaction.set", &p).unwrap(); + let it = &s.doc().unwrap().node(frame).unwrap().interactions[0]; + assert_eq!(it.duration_ms, 450); + assert_eq!(it.actions[1..], actions[1..], "only the supplied fields change"); + } +} + +#[test] +fn stored_variable_ids_from_text_are_not_resolved_twice() { + let (mut s, frame, _, _, _) = setup(); + let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap(); + s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap(); + let action = json!({"type": "setVariable", "variable": "target", "expression": "target + 1"}); + s.execute("proto.interaction.add", &json!({"id": frame, "action": action})).unwrap(); + for (cmd, patch) in [ + ("proto.interaction.add", json!({"action": action})), + ("proto.interaction.set", json!({"action": action})), + ("proto.interaction.set", json!({"interaction": {"actions": [action]}})), + ("proto.action.add", json!({"path": [0], "action": action})), + ("proto.action.set", json!({"path": [0], "action": action})), + ] { + let mut p = json!({"id": frame}); + if cmd != "proto.interaction.add" { + p["index"] = json!(0); + } + p.as_object_mut().unwrap().extend(patch.as_object().unwrap().clone()); + let r = s.execute(cmd, &p).unwrap(); + let index = if cmd == "proto.interaction.add" { r["index"].as_u64().unwrap() as usize } else { 0 }; + let Action::SetVariable { variable, expr: Some(expr), .. } = &s.doc().unwrap().node(frame).unwrap().interactions[index].actions[0] else { + panic!("{cmd}: no set variable expression") + }; + assert_eq!(variable, &target, "{cmd}"); + assert_eq!(expr.eval(&s.doc().unwrap().variables, &Default::default()), Some(VariableValue::Number(6.0)), "{cmd}"); + } +} + +#[test] +fn stored_variable_ids_resolve_only_incoming_action_and_expression_names() { + let (mut s, frame, _, _, _) = setup(); + let target = s.execute("variable.create", &json!({"collection": "Theme", "name": "target", "type": "number", "value": 5})).unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + s.execute("variable.set", &json!({"variable": "count", "value": 100})).unwrap(); + s.execute("variable.update", &json!({"variable": "count", "name": target})).unwrap(); + let value = json!({"type": "setVariable", "variable": "target"}); + s.execute( + "proto.interaction.add", + &json!({"id": frame, "actions": [ + {"type": "setVariable", "variable": "target", "value": null, "expr": Expr::var(&target)}, + {"type": "conditional", "blocks": [{"expression": "target == 5", "actions": [ + {"type": "setVariable", "variable": "label", "value": value} + ]}]} + ]}), + ) + .unwrap(); + let doc = s.doc().unwrap(); + let actions = &doc.node(frame).unwrap().interactions[0].actions; + let Action::SetVariable { variable, expr: Some(expr), .. } = &actions[0] else { panic!("no set variable") }; + assert_eq!(variable, &target); + // A JSON expression is command input: it still accepts names, even one that resembles an id. + assert_eq!(expr.eval(&doc.variables, &Default::default()), Some(VariableValue::Number(100.0))); + let Action::Conditional { blocks } = &actions[1] else { panic!("no conditional") }; + assert_eq!(blocks[0].condition.as_ref().unwrap().eval(&doc.variables, &Default::default()), Some(VariableValue::Boolean(true))); + let Action::SetVariable { value: stored, .. } = &blocks[0].actions[0] else { panic!("no nested set variable") }; + assert_eq!(stored, &value, "a literal value is not an action to canonicalize"); +} + +#[test] +fn stored_variable_ids_switch_only_the_prototype_collection() { + let (s, frame, _, _, r) = setup(); + let mut doc = s.doc().unwrap().clone(); + let theme = doc.variables.collections[0].clone(); + let mut shadow = theme.clone(); + shadow.id = "shadow".into(); + shadow.name = theme.id.clone(); + doc.variables.collections.insert(0, shadow); + assert!(crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark")); + assert_eq!(fill_hex(&doc, r).as_deref(), Some("000000")); + let page = doc.page_of(frame).unwrap(); + assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow")); + // The public name-accepting wrapper keeps its existing lookup behavior. + assert!(set_mode(&mut doc, frame, &theme.id, "Light")); + assert!(doc.node(page).unwrap().var_modes.contains_key("shadow")); + doc.variables.collections.retain(|c| c.id != theme.id); + let before = doc.clone(); + assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark")); + assert_eq!(doc, before); +} diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 7f80a0442..9c4853620 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -498,3 +498,24 @@ fn a_translucent_variable_bound_to_an_empty_fill_applies_its_alpha_once() { assert_eq!(node(&s, r).fills[0].solid_color().unwrap().to_css_hex(), "#00FF0040"); assert_eq!(node(&s, r).fills[0].opacity, 1.0); } + +#[test] +fn stored_variable_ids_cannot_hide_an_alias_cycle() { + for link in ["a", "b"] { + let mut s = Session::new(); + run(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let mut ids = std::collections::BTreeMap::new(); + for name in ["shadow", "a", "b"] { + let v = run(&mut s, "variable.create", json!({"collection": "Theme", "name": name, "type": "number", "value": 1})); + ids.insert(name, v["id"].clone()); + } + run(&mut s, "variable.set", json!({"variable": "b", "value": {"alias": "a"}})); + run(&mut s, "variable.update", json!({"variable": ids["shadow"], "name": ids[link]})); + let before = s.doc().unwrap().clone(); + let undo = s.ds().unwrap().history.undo.len(); + let error = s.execute("variable.set", &json!({"variable": "a", "value": {"alias": "b"}})).unwrap_err(); + assert!(error.to_string().contains("refer back to itself"), "{link}: {error}"); + assert_eq!(s.doc().unwrap(), &before); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + } +} diff --git a/crates/engine/src/variables_editor_tests.rs b/crates/engine/src/variables_editor_tests.rs index 9518911e1..806300c70 100644 --- a/crates/engine/src/variables_editor_tests.rs +++ b/crates/engine/src/variables_editor_tests.rs @@ -125,3 +125,66 @@ fn create_collection_refuses_a_taken_name() { ex(&mut s, "variable.createCollection", json!({"name": "Theme 2"})); assert_eq!(s.doc().unwrap().variables.collections.len(), before + 1); } + +#[test] +fn stored_variable_ids_detach_deleted_aliases_per_mode() { + for collection in [false, true] { + let mut s = Session::new(); + for name in ["Base", "Theme"] { + ex(&mut s, "variable.createCollection", json!({"name": name, "modes": ["Light", "Dark"]})); + } + let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})); + let surface = ex( + &mut s, + "variable.create", + json!({"collection": "Base", "name": "surface", "type": "color", "values": {"Light": "#0D99FF", "Dark": "#000000"}}), + ); + // Use a different collection only when deleting the whole target collection. + let home = if collection { "Theme" } else { "Base" }; + let alias = ex(&mut s, "variable.create", json!({"collection": home, "name": "alias", "type": "color", "value": {"alias": surface["id"]}})); + ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]})); + let aid = alias["id"].as_str().unwrap(); + let before = s.doc().unwrap().variables.get_by_id(aid).unwrap().clone(); + let expected: std::collections::BTreeMap<_, _> = before + .values + .keys() + .map(|m| { + let modes = std::collections::BTreeMap::from([(before.collection.clone(), m.clone())]); + (m.clone(), s.doc().unwrap().variables.resolve(aid, &modes).unwrap()) + }) + .collect(); + if collection { + ex(&mut s, "variable.deleteCollection", json!({"collection": "Base"})); + } else { + ex(&mut s, "variable.delete", json!({"variable": "surface"})); + } + assert_eq!(s.doc().unwrap().variables.get_by_id(aid).unwrap().values, expected); + } +} + +#[test] +fn stored_variable_ids_keep_collection_edits_on_the_resolved_collection() { + let mut s = Session::new(); + let shadow = ex(&mut s, "variable.createCollection", json!({"name": "Shadow", "modes": ["Only"]})); + let theme = ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "number", "value": 1})); + ex(&mut s, "variable.renameCollection", json!({"collection": shadow["id"], "name": theme["id"]})); + ex(&mut s, "variable.set", json!({"variable": "surface", "mode": "Dark", "value": 7})); + let doc = s.doc().unwrap(); + let coll = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap(); + let mode = coll.find_mode("Dark").unwrap().id.clone(); + let modes = std::collections::BTreeMap::from([(coll.id.clone(), mode)]); + assert_eq!(doc.variables.resolve(surface["id"].as_str().unwrap(), &modes), Some(figcraft_doc::VariableValue::Number(7.0))); + + // Mode commands resolve the user's collection argument before checking its stored id. + let doc = s.doc_mut().unwrap(); + let mut extended = doc.variables.collection_by_id(theme["id"].as_str().unwrap()).unwrap().clone(); + extended.id = "extended".into(); + extended.name = "Extended".into(); + extended.extends = Some(theme["id"].as_str().unwrap().into()); + doc.variables.collections.push(extended); + doc.variables.collections[0].name = "extended".into(); + let before = s.doc().unwrap().clone(); + assert!(s.execute("variable.removeMode", &json!({"collection": "Extended", "mode": "Dark"})).is_err()); + assert_eq!(s.doc().unwrap(), &before); +} diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index b34294a84..9f1300af3 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -231,6 +231,39 @@ fn variables_export_and_import_round_trip() { let _ = std::fs::remove_file(&path); } +#[test] +fn stored_variable_ids_keep_remapped_import_aliases() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "number", "value": 100}))["id"] + .as_str() + .unwrap() + .to_string(); + let target = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "target", "type": "number", "value": 5}))["id"] + .as_str() + .unwrap() + .to_string(); + ex(&mut s, "variable.update", json!({"variable": shadow, "name": target})); + let imported = json!({"meta": { + "variableCollections": {"c": {"id": "c", "name": "Theme", "modes": [{"modeId": "m", "name": "Mode 1"}]}}, + "variables": { + "t": {"id": "t", "name": "target", "variableCollectionId": "c", "resolvedType": "FLOAT", "valuesByMode": {"m": 5}}, + "a": {"id": "a", "name": "alias", "variableCollectionId": "c", "resolvedType": "FLOAT", + "valuesByMode": {"m": {"type": "VARIABLE_ALIAS", "id": "t"}}} + } + }}); + let result = ex(&mut s, "variable.import", json!({"variables": imported})); + assert_eq!(result["warnings"], json!([])); + let vars = &s.doc().unwrap().variables; + let alias = vars.get("alias").unwrap(); + assert_eq!(alias.values.values().next(), Some(&VariableValue::Alias(target.clone()))); + assert_eq!(vars.resolve(&alias.id, &Default::default()), Some(VariableValue::Number(5.0))); + // The command's alias argument remains name-accepting, unlike the import's remapped id. + ex(&mut s, "variable.set", json!({"variable": "alias", "value": {"alias": target}})); + let vars = &s.doc().unwrap().variables; + assert_eq!(vars.resolve(&vars.get("alias").unwrap().id, &Default::default()), Some(VariableValue::Number(100.0))); +} + #[test] fn variables_import_checks_what_it_reads() { let mut s = Session::new(); @@ -555,3 +588,53 @@ fn css_export_neutralises_every_css_newline_in_strings() { assert!(css.contains("\"a b c\""), "{css:?}"); } } + +#[test] +fn stored_variable_ids_keep_exported_css_alias_names() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let shadow = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"})); + let surface = ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"})); + ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": surface["id"]}})); + ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"], "codeSyntax": {"WEB": "--shadow"}})); + let css = ex(&mut s, "variable.export", json!({"format": "css"})); + assert!(css["text"].as_str().unwrap().contains("--alias: var(--surface);")); +} + +#[test] +fn stored_variable_ids_reset_only_the_inherited_parent_and_collection() { + for collection_shadow in [false, true] { + let mut s = Session::new(); + let shadow_coll = ex(&mut s, "variable.createCollection", json!({"name": "Shadow"})); + ex(&mut s, "variable.createCollection", json!({"name": "Base", "modes": ["Light", "Dark"]})); + let shadow = ex(&mut s, "variable.create", json!({"collection": "Shadow", "name": "shadow", "type": "number", "value": 0})); + let surface = + ex(&mut s, "variable.create", json!({"collection": "Base", "name": "surface", "type": "number", "values": {"Light": 4, "Dark": 8}})); + if collection_shadow { + ex(&mut s, "variable.renameCollection", json!({"collection": shadow_coll["id"], "name": "extended"})); + } else { + ex(&mut s, "variable.update", json!({"variable": shadow["id"], "name": surface["id"]})); + } + let doc = s.doc_mut().unwrap(); + let parent = doc.variables.get_by_id(surface["id"].as_str().unwrap()).unwrap().clone(); + let mut coll = doc.variables.collection_by_id(&parent.collection).unwrap().clone(); + let (light, dark) = (coll.find_mode("Light").unwrap().id.clone(), coll.find_mode("Dark").unwrap().id.clone()); + coll.extends = Some(coll.id.clone()); + coll.id = "extended".into(); + coll.name = "Extended".into(); + let mut inherited = parent.clone(); + inherited.id = "inherited".into(); + inherited.name = "inherited".into(); + inherited.collection = coll.id.clone(); + inherited.extends = Some(parent.id); + inherited.overrides = inherited.values.keys().cloned().collect(); + inherited.values.values_mut().for_each(|v| *v = VariableValue::Number(2.0)); + doc.variables.collections.push(coll); + doc.variables.variables.push(inherited); + ex(&mut s, "variable.resetOverride", json!({"variable": "inherited", "mode": "Dark"})); + let result = s.doc().unwrap().variables.get_by_id("inherited").unwrap(); + assert_eq!(result.values[&light], VariableValue::Number(2.0)); + assert_eq!(result.values[&dark], VariableValue::Number(8.0)); + assert_eq!(result.overrides, std::collections::BTreeSet::from([light])); + } +} diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs index bd2695563..4bf790fd7 100644 --- a/crates/format/src/fig/export/assets.rs +++ b/crates/format/src/fig/export/assets.rs @@ -251,9 +251,9 @@ impl<'d> Exporter<'d> { } } - /// A reference to variable `var` (its id or name) as variable data. + /// A reference to variable `var` (its stored id) as variable data. fn alias(&mut self, var: &str) -> Option> { - let found = self.doc.variables.get(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?))); + let found = self.doc.variables.get_by_id(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?))); let Some((kind, local)) = found else { self.warn("bindings to variables that aren't in the document were written as their values"); return None; diff --git a/crates/format/src/penpot/export.rs b/crates/format/src/penpot/export.rs index 1de09e89d..1cec7eaf9 100644 --- a/crates/format/src/penpot/export.rs +++ b/crates/format/src/penpot/export.rs @@ -1657,7 +1657,7 @@ impl<'a> Exporter<'a> { if let Some(name) = self.token_names.get(variable) { return Some(name.clone()); } - let var = self.doc.variables.get(variable)?; + let var = self.doc.variables.get_by_id(variable)?; let name = if variable.starts_with("penpot:tokens:") { var.name.split_once('/').map_or(var.name.clone(), |(_, n)| n.to_owned()) } else { diff --git a/crates/format/src/penpot/export_tests.rs b/crates/format/src/penpot/export_tests.rs index 233cfc859..1432a8c57 100644 --- a/crates/format/src/penpot/export_tests.rs +++ b/crates/format/src/penpot/export_tests.rs @@ -909,3 +909,27 @@ fn page_guides_reloaded_from_a_figcraft_file_are_exported() { let page_json = e.iter().find(|(k, _)| k.matches('/').count() == 3 && k.contains("/pages/")).unwrap().1; assert_eq!(page_json["guides"].as_object().unwrap().values().next().unwrap()["position"], 410); } + +#[test] +fn stored_variable_ids_keep_penpot_token_identity() { + for missing in [false, true] { + let mut doc = Document::new("Token identity"); + doc.variables.collections.push(Collection { + id: "c".into(), + name: "Theme".into(), + modes: vec![Mode { id: "m".into(), name: "Default".into() }], + extends: None, + }); + doc.variables.variables.push(variable("shadow", "target", "c", VariableType::Number, &[("m", VariableValue::Number(20.0))])); + if !missing { + doc.variables.variables.push(variable("target", "surface", "c", VariableType::Number, &[("m", VariableValue::Number(40.0))])); + } + let p = page(&doc); + let r = add(&mut doc, p, NodeKind::Rectangle, "Bound", 0.0, 0.0, 40.0, 10.0); + doc.node_mut(r).unwrap().bound.insert("width".into(), "target".into()); + let exported = entries(&export(&doc).unwrap().0); + let width = &shape(&exported, "Bound")["appliedTokens"]["width"]; + let expected = if missing { Value::Null } else { json!("surface") }; + assert_eq!(width, &expected); + } +} diff --git a/crates/format/src/rest/conditional_tests.rs b/crates/format/src/rest/conditional_tests.rs index 99e08cef5..7f1d316cd 100644 --- a/crates/format/src/rest/conditional_tests.rs +++ b/crates/format/src/rest/conditional_tests.rs @@ -248,3 +248,34 @@ fn junk_in_conditional_actions_never_panics() { } } } + +#[test] +fn stored_variable_ids_keep_imported_mode_lookup_identity() { + for collection in [false, true] { + let mut vars = variables_json(); + if collection { + let mut shadow = vars["meta"]["variableCollections"][THEME].clone(); + shadow["id"] = json!("VariableCollectionId:0:0"); + shadow["name"] = json!(THEME); + shadow["modes"] = json!([{"modeId": "other", "name": "Other"}]); + vars["meta"]["variableCollections"]["VariableCollectionId:0:0"] = shadow; + } else { + let mut shadow = vars["meta"]["variables"][SPACE].clone(); + shadow["id"] = json!("VariableID:0:0"); + shadow["name"] = json!(LABEL); + vars["meta"]["variables"]["VariableID:0:0"] = shadow; + } + let lookup = expr("VAR_MODE_LOOKUP", vec![var(LABEL), data("STRING", json!("1:0"))]); + let (doc, warnings) = import_with(&file(vec![on("ON_CLICK", vec![set(LABEL, lookup)])]), Some(&vars), &no_images).unwrap(); + let actions = &button(&doc).interactions[0].actions; + assert_eq!( + actions[0], + Action::SetVariable { + variable: LABEL.into(), + value: Value::Null, + expr: Some(Expr::InMode { variable: LABEL.into(), mode: "1:0".into() }), + }, + "{warnings:?}" + ); + } +} diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs index 618699165..5ba0c5ad2 100644 --- a/crates/format/src/rest/proto.rs +++ b/crates/format/src/rest/proto.rs @@ -442,8 +442,8 @@ impl Ctx<'_> { }; let vars = &self.doc.variables; let found = parts.filter(|(id, _)| self.var_kinds.contains_key(*id)).and_then(|(id, mode)| { - let v = vars.get(id)?; - let m = vars.collection(&v.collection)?.modes.iter().find(|m| m.id == mode)?; + let v = vars.get_by_id(id)?; + let m = vars.collection_by_id(&v.collection)?.modes.iter().find(|m| m.id == mode)?; Some(Expr::InMode { variable: v.id.clone(), mode: m.id.clone() }) }); if found.is_none() { diff --git a/crates/ui-egui/src/panels/variable_ui.rs b/crates/ui-egui/src/panels/variable_ui.rs index fe822a1f1..80e544f9a 100644 --- a/crates/ui-egui/src/panels/variable_ui.rs +++ b/crates/ui-egui/src/panels/variable_ui.rs @@ -131,7 +131,7 @@ pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc]) -> Ve let (id, name) = (lib.manifest.id.as_str(), library::library_name(doc, &lib.manifest.id)); for c in library::published_collections(lib) { let local = copy(id, &c.id).map(|r| r.local.as_str()); - let coll = local.and_then(|l| doc.variables.collection(l)).unwrap_or(c); + let coll = local.and_then(|l| doc.variables.collection_by_id(l)).unwrap_or(c); if several(coll) { out.push(ModeRow { coll, library: Some((id, name)), key: Some(&c.id), local }); } @@ -141,7 +141,7 @@ pub fn mode_rows<'a>(doc: &'a Document, libs: &'a [Arc]) -> Ve if out.iter().any(|x| x.local == Some(r.local.as_str())) { continue; } - if let Some(c) = doc.variables.collection(&r.local).filter(|c| several(c)) { + if let Some(c) = doc.variables.collection_by_id(&r.local).filter(|c| several(c)) { out.push(ModeRow { coll: c, library: Some((&r.library, library::library_name(doc, &r.library))), key: None, local: Some(&r.local) }); } } diff --git a/crates/ui-egui/src/present/actions.rs b/crates/ui-egui/src/present/actions.rs index 5cd4e03e7..1b4e0f745 100644 --- a/crates/ui-egui/src/present/actions.rs +++ b/crates/ui-egui/src/present/actions.rs @@ -232,7 +232,7 @@ impl Presenter { Action::SetVariableMode { collection, mode } => { let Some(d) = self.design.as_mut() else { return false }; let before = d.clone(); - let set = figcraft_engine::proto_run::set_mode(d, self.current, collection, mode); + let set = figcraft_engine::proto_run::set_mode_by_id(d, self.current, collection, mode); if set { self.settle(&before); } diff --git a/crates/ui-egui/src/present/variables_tests.rs b/crates/ui-egui/src/present/variables_tests.rs index 23491800e..3b67139a7 100644 --- a/crates/ui-egui/src/present/variables_tests.rs +++ b/crates/ui-egui/src/present/variables_tests.rs @@ -76,7 +76,8 @@ fn counter_then_conditional_navigation() { #[test] fn set_variable_mode_recolours_bound_layers() { let (mut doc, a, _, button) = setup(); - on_click(&mut doc, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Dark".into() }]); + let theme = doc.variables.collection("Theme").unwrap().id.clone(); + on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.clone(), mode: "Dark".into() }]); let hex = |p: &Presenter| p.doc.as_ref().and_then(|d| d.node(a)?.fills.first()?.solid_color()).map(|c| c.to_hex()); let mut p = Presenter::new(a); p.sync(&Arc::new(doc), 0.0); @@ -85,7 +86,7 @@ fn set_variable_mode_recolours_bound_layers() { assert_eq!(hex(&p).as_deref(), Some("000000")); // An unknown mode does nothing. let mut d2 = p.doc.clone().unwrap(); - on_click(&mut d2, button, vec![Action::SetVariableMode { collection: "Theme".into(), mode: "Sepia".into() }]); + on_click(&mut d2, button, vec![Action::SetVariableMode { collection: theme, mode: "Sepia".into() }]); let mut p2 = Presenter::new(a); p2.sync(&Arc::new(d2), 0.0); assert!(!p2.fire(button, 1, None, 1.0)); @@ -104,3 +105,29 @@ fn deeply_nested_conditionals_stop() { assert!(!p.fire(button, 0, None, 1.0)); assert_eq!(p.current, a); } + +#[test] +fn stored_variable_ids_keep_player_collection_identity() { + for missing in [false, true] { + let (mut doc, a, _, button) = setup(); + let theme = doc.variables.collections[0].clone(); + let mut shadow = theme.clone(); + shadow.id = "shadow".into(); + shadow.name = theme.id.clone(); + doc.variables.collections.insert(0, shadow); + if missing { + doc.variables.collections.retain(|c| c.id != theme.id); + } + on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.id.clone(), mode: "Dark".into() }]); + let mut p = Presenter::new(a); + p.sync(&Arc::new(doc), 0.0); + assert_eq!(p.fire(button, 0, None, 1.0), !missing); + let played = p.doc.as_ref().unwrap(); + let modes = &played.node(played.page_of(a).unwrap()).unwrap().var_modes; + assert!(!modes.contains_key("shadow")); + if !missing { + let fill = played.node(a).unwrap().fills[0].solid_color().unwrap(); + assert_eq!(fill.to_hex(), "000000"); + } + } +} diff --git a/crates/ui-egui/src/proto_responsive_tests.rs b/crates/ui-egui/src/proto_responsive_tests.rs index f0ead8e98..8ff4f608b 100644 --- a/crates/ui-egui/src/proto_responsive_tests.rs +++ b/crates/ui-egui/src/proto_responsive_tests.rs @@ -133,6 +133,34 @@ fn brand_library(tag: &str) -> (String, String) { (path, lid) } +#[test] +fn stored_variable_ids_keep_library_mode_picker_rows() { + let (path, lid) = brand_library("stored-ids"); + let mut s = Session::new(); + let f = id(&s.execute("frame.create", &json!({"width": 100, "height": 100})).unwrap()); + s.execute("variable.createCollection", &json!({"name": "shadow"})).unwrap(); + s.execute("library.add", &json!({"path": path})).unwrap(); + s.execute("library.setVariableMode", &json!({"library": lid, "collection": "Brand", "mode": "Dark", "ids": [f]})).unwrap(); + let local = s.doc().unwrap().libraries.collections[0].local.clone(); + s.execute("variable.renameCollection", &json!({"collection": "shadow", "name": local})).unwrap(); + let mut u = Ui::new(s); + let doc = u.app.session.doc().unwrap(); + let libs = crate::variable_ui::libraries(&u.app, doc); + // Both a read library and an unavailable one keep the actual copy's modes, not its namesake's. + for available in [libs.as_slice(), &[]] { + let rows = crate::variable_ui::mode_rows(doc, available); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!(rows[0].coll.id, local); + assert_eq!(rows[0].coll.name, "Brand"); + assert_eq!(rows[0].coll.modes.len(), 2); + assert_eq!(rows[0].local, Some(local.as_str())); + assert_eq!(rows[0].library.unwrap().0, lid); + } + u.run("select.set", json!({"ids": [f]})); + u.frames(2, Vec::new); + let _ = std::fs::remove_file(&path); +} + #[test] fn library_collections_show_in_the_mode_picker_under_their_library() { let (path, lid) = brand_library("picker"); diff --git a/docs/components-parity.md b/docs/components-parity.md index 4cf946178..e5cc5abe3 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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 @@ -68,7 +68,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Deleting a bound variable or its collection | done | layers keep their last value and are unbound; aliases to it take its values | | Code syntax per platform (Web, iOS, Android, Flutter) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose/ArkUI/Flutter code doesn't name variables yet | | Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | -| Explicit modes per frame, library collections' modes | done | | +| Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there | | Gradient stops and variant properties bound to variables | done | a stop's binding follows it when earlier stops are removed | | Extended collections | done | library collections, including a library's own extended collections (chains through libraries); overrides per mode with reset; follow library updates, variables new to the library collection being an update of their own; modes inherited. Checked with synthetic libraries only ([gaps](gaps.md) #14) | | Variables import/export (JSON) | done | the REST local variables shape (round-trips; merges by collection, mode and variable name; `variableCollections` and `variables` read under `meta` or at the top level, a warning when no variables are found or fewer than the collections list); CSS custom properties and Dart constants (Flutter) export; a variables file named by path must be a regular file of at most 256 MiB; in the browser the export arrives as a download (import there still needs a file picker) | @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged | | 2026-10-30 | minor | M8, #1114: variables import from an explicit file path (`variable.import`, the `variables` of `file.open` and `file.import`) takes regular files of at most 256 MiB, the limit the sibling `variables.json` already had; anything else is refused before the document changes. Grade unchanged. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads` | | 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged | | 2026-10-29 | minor | M3, #1338: the instance-swap property's menu (on an instance, its main component's default and the component playground) and the instance's swap menu open with a search field at the top that has the keyboard; typing filters the components by name (any case, headings without matches drop out), an empty result reads "No matching components", Up and Down step through the matches and Enter picks one (the first without a step), Escape clears the search and then closes the menu. Only the list scrolls; the menu grows back when the search is cleared. Tests: `component_swap_search_tests`. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 889eccb07..b7b1dec97 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -51,7 +51,7 @@ blockers. | M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform) | — | — | | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | -| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11) | real libraries, component playground | 7–14 | +| M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11; stored references keep exact variable and collection ids) | real libraries, component playground | 7–14 | | M4 Vector & text depth | ~80% (live booleans, arc fields, shape builder on strokes, boolean hit testing on the result, one-sided widths, text on a path, pattern fills, brushes and pen pressure landed 2026-10-11; vector edit mode regions 2026-10-13) | spell check, Figma Draw multi-edit | 7–18 | | M5 Prototype & present | ~85% | media, controllers on macOS/Linux/FreeBSD, separate window | 2–5 | | M6 Interop & Dev Mode | ~65% (code connect, Dev Mode plugins and best-effort `.fig` import landed 2026-10-11; best-effort `.sketch` import 2026-10-21) | `.fig` import's tail, video/HEIC (animated GIFs and TIFF landed 2026-10-11), CMYK intent | 15–30 | @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged | | 2026-10-30 | minor | M8, #1082 (the rest): SVG and PDF pattern sources and the renders exports embed go on from the layer's depth: one cap for a whole export. Tests: `pattern_depth_tests` (SVG, PDF), `render_depth_tests` | | 2026-10-30 | minor | M8, #1082: pattern sources go on from the depth of the layer they fill (one cap for the whole render); render, save and band threads get 8 MiB stacks (a level takes about 6.5 KB). Tests: `render_depth_tests` | | 2026-10-30 | minor | M8, #1114 (explicit variables files): `variable.import` and the `variables` of `file.open` and `file.import` read a path with the same 256 MiB cap and regular-file check as the sibling `variables.json`, so an oversized file, a folder or a device is refused before the document changes; inline objects and JSON text, access roots and the browser's error are unchanged. Primary file reads and `image.place` are still uncapped. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads`. Numbers unchanged | From 87e4abfbf6c95ef45537fc48ece00046a6df34d3 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 07:11:15 +0300 Subject: [PATCH 09/34] #1615: each generated-docs test gets its own temporary directory, removed when it ends Co-Authored-By: Claude Opus 5.5 Co-Authored-By: GPT-6.1 Sol --- apps/figcraft-cli/tests/it/generated_docs.rs | 67 ++++++++++++++------ 1 file changed, 47 insertions(+), 20 deletions(-) diff --git a/apps/figcraft-cli/tests/it/generated_docs.rs b/apps/figcraft-cli/tests/it/generated_docs.rs index e39c77b5d..061be5b38 100644 --- a/apps/figcraft-cli/tests/it/generated_docs.rs +++ b/apps/figcraft-cli/tests/it/generated_docs.rs @@ -1,22 +1,45 @@ //! Generated CLI documentation uses the parser's command definition, without running commands. use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; #[path = "../../examples/man-and-completions.rs"] #[expect(dead_code, reason = "These tests call generate directly; the example's main is smoke-tested separately.")] mod generator; -fn tmp(name: &str) -> std::io::Result { - let dir = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{name}", std::process::id())); - fs::create_dir_all(&dir)?; - Ok(dir) +struct TempDir(PathBuf); + +impl TempDir { + fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn tmp(name: &str) -> std::io::Result { + static NEXT: AtomicUsize = AtomicUsize::new(0); + loop { + let n = NEXT.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!("figcraft-cli-test-{}-{n}-{name}", std::process::id())); + match fs::create_dir(&dir) { + Ok(()) => return Ok(TempDir(dir)), + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } } #[test] fn completions_name_the_cli_its_subcommands_and_options() -> std::io::Result<()> { - let dir = tmp("completions")?; - generator::generate(&dir)?; + let temp = tmp("completions")?; + let dir = temp.path(); + generator::generate(dir)?; for name in ["figcraft-cli.bash", "figcraft-cli.elv", "figcraft-cli.fish", "_figcraft-cli.ps1", "_figcraft-cli"] { let text = fs::read_to_string(dir.join(name))?; for word in [ @@ -40,13 +63,14 @@ fn completions_name_the_cli_its_subcommands_and_options() -> std::io::Result<()> } assert!(!text.contains("man-and-completions"), "{name} names the example instead of the CLI"); } - fs::remove_dir_all(dir) + Ok(()) } #[test] fn bash_dispatch_states_have_matching_option_blocks() -> std::io::Result<()> { - let dir = tmp("bash-completion-states")?; - generator::generate(&dir)?; + let temp = tmp("bash-completion-states")?; + let dir = temp.path(); + generator::generate(dir)?; let text = fs::read_to_string(dir.join("figcraft-cli.bash"))?; let states: Vec<_> = text.lines().filter_map(|line| line.trim().strip_prefix("cmd=\"")?.strip_suffix('"')).filter(|state| !state.is_empty()).collect(); @@ -55,15 +79,16 @@ fn bash_dispatch_states_have_matching_option_blocks() -> std::io::Result<()> { let block = format!("{state})"); assert!(text.lines().any(|line| line.trim() == block), "Bash dispatch state {state} has no option block"); } - fs::remove_dir_all(dir) + Ok(()) } #[test] fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::Result<()> { - let dir = tmp("man-pages")?; - generator::generate(&dir)?; + let temp = tmp("man-pages")?; + let dir = temp.path(); + generator::generate(dir)?; let mut pages = Vec::new(); - for entry in fs::read_dir(&dir)? { + for entry in fs::read_dir(dir)? { let path = entry?.path(); if path.extension().is_some_and(|ext| ext == "1") { pages.push(path.file_name().unwrap_or_default().to_string_lossy().into_owned()); @@ -101,12 +126,13 @@ fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::R let text = fs::read_to_string(dir.join(page))?.replace("\\-", "-"); assert!(text.contains(option), "{page} does not contain {option}"); } - fs::remove_dir_all(dir) + Ok(()) } #[test] fn generation_creates_the_output_directory_and_can_run_again() -> std::io::Result<()> { - let dir = tmp("generated-docs-repeat")?; + let temp = tmp("generated-docs-repeat")?; + let dir = temp.path(); let output = dir.join("new").join("docs"); assert!(!output.exists()); generator::generate(&output)?; @@ -117,18 +143,19 @@ fn generation_creates_the_output_directory_and_can_run_again() -> std::io::Resul generator::generate(&output)?; assert_eq!(fs::read(page)?, first); assert_eq!(fs::read_dir(output)?.count(), 16); - fs::remove_dir_all(dir) + Ok(()) } #[test] fn generation_returns_filesystem_errors() -> std::io::Result<()> { - let dir = tmp("generated-docs-errors")?; + let temp = tmp("generated-docs-errors")?; + let dir = temp.path(); let file = dir.join("not-a-directory"); fs::write(&file, "keep me")?; assert!(generator::generate(&file).is_err()); assert_eq!(fs::read_to_string(&file)?, "keep me"); // A valid directory can still fail when writing one of the generated pages. fs::create_dir(dir.join("figcraft-cli.1"))?; - assert!(generator::generate(&dir).is_err()); - fs::remove_dir_all(dir) + assert!(generator::generate(dir).is_err()); + Ok(()) } From cc0976a4a2f94414696b18ce41460863d3f18b94 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 07:13:03 +0300 Subject: [PATCH 10/34] #1609: a grouped panic keeps its payload and lets later live edits sync Co-Authored-By: Claude Opus 5.5 Co-Authored-By: GPT-6.1 Sol --- crates/engine/src/group_panic_tests.rs | 41 +++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/group_panic_tests.rs b/crates/engine/src/group_panic_tests.rs index aaa1a8bf4..48754e389 100644 --- a/crates/engine/src/group_panic_tests.rs +++ b/crates/engine/src/group_panic_tests.rs @@ -40,7 +40,8 @@ fn a_grouped_closure_that_panics_leaves_the_session_as_before() { }) })); - assert!(unwound.is_err(), "the panic goes on to the caller"); + let payload = unwound.unwrap_err(); + assert_eq!(payload.downcast_ref::<&str>(), Some(&"a bug in the closure"), "the original panic goes on to the caller"); let ds = s.ds().unwrap(); assert!(Arc::ptr_eq(&ds.doc, &doc)); assert_eq!(*ds.doc, *doc); @@ -61,6 +62,44 @@ fn a_grouped_closure_that_panics_leaves_the_session_as_before() { assert_eq!(s.undo_depth(), (depth.0 + 2, 0)); } +#[test] +fn a_grouped_closure_that_panics_releases_the_live_hold_so_later_edits_sync() { + use crate::live_tests::{push, relay, serve, session}; + + let (mut s, script) = session(); + let first = rect(&mut s, 0); + s.execute("collab.host", &json!({})).unwrap(); + let (mut server, id, welcome) = serve(s.doc().unwrap()); + push(&script, vec![welcome]); + s.live_poll(); + assert!(s.live_doc(s.ds().unwrap().uid)); + let before = s.doc().unwrap().clone(); + script.lock().unwrap().msgs.clear(); + + let unwound = catch_unwind(AssertUnwindSafe(|| { + s.grouped("Rename then panic", |s| -> crate::Result<()> { + s.execute("node.set", &json!({"id": first, "props": {"name": "Rolled back"}}))?; + s.live_sync(); + assert!(script.lock().unwrap().msgs.is_empty(), "the edit waits for the group to end"); + panic!("a bug in the live closure"); + }) + })); + + assert!(unwound.is_err()); + assert_eq!(s.doc().unwrap(), &before); + s.live_sync(); + assert!(script.lock().unwrap().msgs.is_empty(), "the rolled-back edit never goes out"); + + s.execute("node.set", &json!({"id": first, "props": {"name": "Sent afterwards"}})).unwrap(); + s.live_sync(); + assert_eq!(script.lock().unwrap().msgs.len(), 1, "the later edit goes out"); + let replies = relay(&script, &mut server, id); + push(&script, replies); + s.live_poll(); + assert_eq!(server.document().node(first).unwrap().name, "Sent afterwards"); + assert_eq!(s.doc().unwrap(), server.document()); +} + #[test] fn a_panic_in_an_inner_group_rolls_back_through_the_outer_one() { let mut s = Session::new(); From a3d70d4c4360c450242f92e5393522b17b04fe6a Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 12:10:25 +0900 Subject: [PATCH 11/34] M8: an edit renders where the changed layer paints, its effects' margin once paint_bounds, the area a change to a layer can affect, was its paint bounds (effects included) grown again by effect_margin, what its effects read from outside. Drawing an area already takes that in, as culling does, so the damage of an edit near effects was counted twice: moving a 120 x 120 rectangle with a shadow falling 40 rendered 122,844 pixels for 56,876 it can change, and edits that would fit a partial render fell back to full ones. The retained test that a group's shadow had pushed past the partial share gets its 300 x 220 view back. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/draw.rs | 9 ++++---- crates/render/src/retained_tests.rs | 34 +++++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 6 deletions(-) diff --git a/crates/render/src/draw.rs b/crates/render/src/draw.rs index c2ab8d095..f46222570 100644 --- a/crates/render/src/draw.rs +++ b/crates/render/src/draw.rs @@ -550,14 +550,15 @@ pub fn effect_margin(n: &Node) -> f64 { layer + others } -/// Everything node `id` can paint, in page coordinates: its paint bounds (glyphs of overflowing -/// text included) grown by its effects' margin. What a change to the node can affect on screen. +/// Everything node `id` can paint, in page coordinates: its paint bounds, effects and glyphs of +/// overflowing text included. What a change to the node can affect on screen. Its effects' margin +/// (what they read from outside, see [`effect_margin`]) isn't added again: drawing the area +/// takes in what lies that far off, as culling does. pub fn paint_bounds(doc: &Document, id: NodeId) -> Option { let n = doc.node(id)?; - let m = effect_margin(n).abs(); let world = doc.world_transform(id); let local = local_paint_bounds(doc, n, world, 0, None); - Some(figcraft_geom::transform_rect_bbox(world, local).inflate(m, m)) + Some(figcraft_geom::transform_rect_bbox(world, local)) } /// The local → page map of a layer drawn at `xf` (local → device): the view's scale taken out. diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 1b1d94457..03609fc1a 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -188,9 +188,9 @@ fn edits_under_and_beside_effects_match() { let beside = edit(&across, |d| d.get_mut(s.rect).unwrap().transform = Affine::translate((10.0, 110.0))); assert!(partial(check(&mut r, &beside, s.page, &v))); // A child of a group with a drop shadow: the shadow moves too, past the group's box with the - // child (a view large enough that this stays a part of it). + // child. let g1 = s.doc.find_by_name("G1", None)[0]; - let v = View { origin: (260.0, 0.0), scale: 1.0, width: 400, height: 300 }; + let v = View { origin: (260.0, 0.0), scale: 1.0, width: 300, height: 220 }; check(&mut r, &beside, s.page, &v); let shadowed = edit(&beside, |d| d.get_mut(g1).unwrap().transform = Affine::translate((30.0, 20.0))); assert!(partial(check(&mut r, &shadowed, s.page, &v))); @@ -448,6 +448,36 @@ fn effect_margins_too_large_to_add_stay_large() { assert_eq!(m, 2.0 * crate::draw::MAX_REACH); } +#[test] +fn a_moved_layer_renders_where_its_effects_paint_and_no_further() { + fonts(); + let shadow = |dy: f64, r: f64| Effect { + kind: EffectKind::DropShadow(Shadow { color: Color::BLACK, offset: (0.0, dy), radius: r, ..Shadow::default() }), + ..Effect::drop_shadow() + }; + let blur = Effect { kind: EffectKind::LayerBlur(figcraft_doc::Blur::uniform(30.0)), ..Effect::layer_blur() }; + for (why, effects) in [("a shadow falling 40", vec![shadow(40.0, 8.0)]), ("a blur of 30", vec![blur])] { + let mut d = Document::new("moved"); + let p = d.first_page().unwrap(); + let id = d.create(p, NodeKind::Rectangle, "R", 300.0, 200.0, 120.0, 120.0).unwrap(); + solid(d.node_mut(id).unwrap(), "3366FF"); + d.node_mut(id).unwrap().effects = effects; + let before = Arc::new(d); + let after = edit(&before, |d| d.get_mut(id).unwrap().transform = Affine::translate((315.0, 210.0))); + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 1000, height: 800 }; + let mut r = Retained::default(); + check(&mut r, &before, p, &v); + // Where it painted and where it paints now, a pixel round each for rounding out; a quarter + // more, as layers grow by a shadow's reach on every side, not only towards its offset. + let area = |doc: &Document| doc.world_render_bounds(id).map_or(0.0, |b| (b.width() + 2.0) * (b.height() + 2.0)); + let most = (area(&before) + area(&after)) * 1.25; + match check(&mut r, &after, p, &v) { + Work::Partial { pixels } => assert!(pixels as f64 <= most, "{why}: {pixels} pixels rendered, where it paints is {most}"), + w => panic!("{why}: {w:?}"), + } + } +} + /// Every channel of `a` within `tol` of the same channel of `b` from row `rows_down` on. fn assert_within(a: &Pixmap, b: &Pixmap, rows_down: u32, tol: u8, what: &str) { assert_eq!(a.width(), b.width(), "{what}: widths"); From 40f83a9a08ec7d601495bd2513e0305a989ced75 Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 15:10:48 +0900 Subject: [PATCH 12/34] M8: a patch reads no backdrop past the view's edge Background blurs and glass read the pixels drawn under them, and a full render's backdrops end where the view does. The retained renderer differed in two ways. render_into drew each damage rect with a margin all round, past the edge too, so a backdrop there read content a full render never drew: with a background blur of 60 across the left edge, moving a square beside it patched 10 levels off a full render, and moving one entirely off the view 89. The margin now stops at the edge. A pan re-rendered only the strips it uncovered. At the opposite edges, which moved into the old frame, the shifted pixels kept what their backdrop had read past the old edge: a background blur of 10 across the view was 89 levels off after a pan of 30. Those edges are now redrawn as far as a backdrop reads. Co-Authored-By: Claude Opus 5.5 --- crates/render/src/retained.rs | 27 ++++++++-- crates/render/src/retained_tests.rs | 83 +++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 5 deletions(-) diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs index 046ed8427..f345e21d6 100644 --- a/crates/render/src/retained.rs +++ b/crates/render/src/retained.rs @@ -413,6 +413,19 @@ fn patch(last: Frame, doc: &Arc, page: NodeId, view: &View, px: (i64, } else if dy < 0 { damage.push(Px { x0: 0, y0: 0, x1: w, y1: reach - dy }); } + // And at the edges that moved in: a backdrop there read what is now past the edge. + if reach > 0 { + if dx > 0 { + damage.push(Px { x0: 0, y0: 0, x1: reach, y1: h }); + } else if dx < 0 { + damage.push(Px { x0: w - reach, y0: 0, x1: w, y1: h }); + } + if dy > 0 { + damage.push(Px { x0: 0, y0: 0, x1: w, y1: reach }); + } else if dy < 0 { + damage.push(Px { x0: 0, y0: h - reach, x1: w, y1: h }); + } + } // Layers that changed, where they were and where they are now. if !Arc::ptr_eq(&last.doc, doc) { let mut areas = Vec::new(); @@ -528,18 +541,22 @@ struct FrameRender<'a> { opts: &'a Options, } -/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`. +/// Render the frame pixels `d` (with `margin` more around, discarded) into `pix`. The margin stops +/// at the frame's edge: background blurs and glass read what is drawn under them, and a full +/// render's backdrops end where the frame does. fn render_into(frame: FrameRender<'_>, d: &Px, margin: i64, pix: &mut Pixmap) -> Option<()> { let FrameRender { doc, page, view, px, opts } = frame; let s = view.scale; - let (ox, oy) = (d.x0 - margin, d.y0 - margin); - let (rw, rh) = (u32::try_from(d.x1 - d.x0 + 2 * margin).ok()?, u32::try_from(d.y1 - d.y0 + 2 * margin).ok()?); + let (w, h) = (i64::from(pix.width()), i64::from(pix.height())); + let (ox, oy) = ((d.x0 - margin).max(0), (d.y0 - margin).max(0)); + let (ex, ey) = ((d.x1 + margin).min(w), (d.y1 + margin).min(h)); + let (rw, rh) = (u32::try_from(ex - ox).ok()?, u32::try_from(ey - oy).ok()?); // The same pixel grid as the frame: origin (px + offset) / scale. let sub = View { origin: ((px.0 + ox) as f64 / s, (px.1 + oy) as f64 / s), scale: s, width: rw, height: rh }; let part = render_view(doc, page, &sub, opts)?; let inner = IntRect::from_xywh( - i32::try_from(margin).ok()?, - i32::try_from(margin).ok()?, + i32::try_from(d.x0 - ox).ok()?, + i32::try_from(d.y0 - oy).ok()?, u32::try_from(d.x1 - d.x0).ok()?, u32::try_from(d.y1 - d.y0).ok()?, )?; diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 1b1d94457..5060d1cab 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -456,3 +456,86 @@ fn assert_within(a: &Pixmap, b: &Pixmap, rows_down: u32, tol: u8, what: &str) { let worst = a.data().iter().zip(below).map(|(x, y)| x.abs_diff(*y)).max().unwrap_or(0); assert!(worst <= tol, "{what}: differs by {worst} levels"); } + +#[test] +fn backdrops_at_the_views_edge_patch_as_they_render() { + fonts(); + let blur = |r: f64| { + let mut e = Effect::background_blur(); + if let EffectKind::BackgroundBlur(b) = &mut e.kind { + b.radius = r; + } + e + }; + let mut glass = Effect::glass(); + if let EffectKind::Glass(g) = &mut glass.kind { + (g.depth, g.refraction) = (30.0, 1.0); + } + // A layer reading its backdrop across the view's left edge, over stripes, and a square moved + // beside it in the view and off it: what the patch reads past the edge a full render doesn't. + for (why, fx) in [("a background blur of 60", blur(60.0)), ("glass", glass)] { + for (from, by) in [((180.0, 100.0), (15.0, 10.0)), ((-150.0, 100.0), (20.0, 0.0))] { + let mut d = Document::new("edge"); + let p = d.first_page().unwrap(); + for k in 0..20 { + let s = d.create(p, NodeKind::Rectangle, "S", -200.0 + f64::from(k) * 20.0, 0.0, 10.0, 400.0).unwrap(); + solid(d.node_mut(s).unwrap(), if k % 2 == 0 { "FF0000" } else { "0000FF" }); + } + let e = d.create(p, NodeKind::Rectangle, "E", -60.0, 50.0, 160.0, 200.0).unwrap(); + let n = d.node_mut(e).unwrap(); + n.fills = vec![Paint { opacity: 0.2, ..Paint::solid(Color::WHITE) }]; + n.effects = vec![fx.clone()]; + let m = d.create(p, NodeKind::Rectangle, "M", from.0, from.1, 40.0, 40.0).unwrap(); + solid(d.node_mut(m).unwrap(), "00AA00"); + let before = Arc::new(d); + let after = edit(&before, |d| d.node_mut(m).unwrap().transform = Affine::translate((from.0 + by.0, from.1 + by.1))); + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 300 }; + let mut r = Retained::default(); + r.render(&before, p, &v, &Options::default()).unwrap(); + let (shown, pix, work) = r.render(&after, p, &v, &Options::default()).unwrap(); + let pix = pix.clone(); + let full = render_view(&after, p, &shown, &Options::default()).unwrap(); + let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(worst <= 2, "{why}, a square moved from {from:?}: {work:?} is {worst} levels off a full render"); + } + } +} + +#[test] +fn pans_patch_backdrops_at_the_edges_that_moved_in() { + fonts(); + let mut blur = Effect::background_blur(); + if let EffectKind::BackgroundBlur(b) = &mut blur.kind { + b.radius = 10.0; + } + let mut glass = Effect::glass(); + if let EffectKind::Glass(g) = &mut glass.kind { + (g.depth, g.refraction) = (30.0, 1.0); + } + // A layer reading its backdrop across all four edges of the view, over stripes: after a pan, + // what it read past an edge that moved in is past the edge no more. + for (why, fx) in [("a background blur of 10", blur), ("glass", glass)] { + let mut d = Document::new("pan"); + let p = d.first_page().unwrap(); + for k in 0..40 { + let s = d.create(p, NodeKind::Rectangle, "S", -200.0 + f64::from(k) * 20.0, -100.0, 10.0, 600.0).unwrap(); + solid(d.node_mut(s).unwrap(), if k % 2 == 0 { "FF0000" } else { "0000FF" }); + } + let e = d.create(p, NodeKind::Rectangle, "E", -60.0, -60.0, 500.0, 420.0).unwrap(); + let n = d.node_mut(e).unwrap(); + n.fills = vec![Paint { opacity: 0.2, ..Paint::solid(Color::WHITE) }]; + n.effects = vec![fx]; + let doc = Arc::new(d); + for (dx, dy) in [(30.0, 0.0), (-30.0, 0.0), (0.0, 30.0), (0.0, -30.0)] { + let mut r = Retained::default(); + r.render(&doc, p, &View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 300 }, &Options::default()).unwrap(); + let v = View { origin: (dx, dy), scale: 1.0, width: 400, height: 300 }; + let (shown, pix, work) = r.render(&doc, p, &v, &Options::default()).unwrap(); + let pix = pix.clone(); + let full = render_view(&doc, p, &shown, &Options::default()).unwrap(); + let worst = pix.data().iter().zip(full.data()).map(|(a, b)| a.abs_diff(*b)).max().unwrap_or(0); + assert!(partial(work), "{why}, a pan by ({dx}, {dy}): {work:?}"); + assert!(worst <= 2, "{why}, a pan by ({dx}, {dy}): {work:?} is {worst} levels off a full render"); + } + } +} From 47e9e0a50d6203c7de43f14a02410755593382fc Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 13:32:36 +0700 Subject: [PATCH 13/34] M8: a component set's property rows fit the 240 px right panel (#1629) --- ROADMAP.md | 3 +- .../ui-egui/src/component_set_narrow_tests.rs | 56 +++++++++++++++++++ crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/panels/component_panel.rs | 46 +++++++++++---- docs/components-parity.md | 3 +- docs/roadmap.md | 3 +- 6 files changed, 100 insertions(+), 13 deletions(-) create mode 100644 crates/ui-egui/src/component_set_narrow_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 6f584593e..a8dea6153 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-10-30 · **Change:** minor (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); 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); 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-10-30 · **Change:** minor (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); 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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8 (found while retaking the README screenshots): at the default (and narrowest) 240 px right panel, a component set's variant property rows ran off the panel: values past the second ("Outline", "Disabled") were cut off, and the + after them and the − of each property sat past the edge; a shared boolean property's − was pushed out too. Values now wrap as many to a line as fit (two at 240 px, three from about 320 px as before), the property name field gives way first so the default and − stay in view, and a boolean's checkbox no longer takes the whole row. Numbers unchanged. Tests: `component_set_narrow_tests`. | | 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged | | 2026-10-30 | minor | M8, #1097: while the Assets panel showed a file, the app asked the server of every team library the file named for updates, with the link's token and a usage report, so a file someone sent could make the app contact any server. Now only team libraries you added (`library.add`) or checked on this computer are checked by themselves (kept with the preferences as hashes of their links); another one waits in **Assets ▸ Libraries** as "Not checked yet" until you click **Check for updates** (`library.check`, which plugins can't call). Placing or applying something from it is your own action and asks its server as before. Numbers unchanged. Tests: `library_beacon_tests` (engine and UI). | | 2026-10-30 | minor | M8, #1121: client-side maps were unbounded. A hostile host or server could mint people without end: the presence waiting for the UI, the people a connection had seen and the session's list of who is here each grew with every new id; and a session server's set of deleted layer ids (kept so undo may create them again) only grew. Now a client keeps track of at most `MAX_PEERS` (4,096) others: past it, someone new is left out with a logged warning (in a welcome too), someone already here can still be renamed, and someone leaving makes room; a server's `max_people` above 4,096 is lowered to it with a startup warning (the server still starts). A session remembers at most 262,144 deleted ids, forgetting the lowest first (on restore too). Threat model in `collab-protocol.md`. Tests: `client_cap_tests.rs` (collab-net), `live_peer_cap_tests.rs` (engine), `deleted_cap_tests.rs` (collab), the server's `max_people` clamp. | diff --git a/crates/ui-egui/src/component_set_narrow_tests.rs b/crates/ui-egui/src/component_set_narrow_tests.rs new file mode 100644 index 000000000..ba0dcce2b --- /dev/null +++ b/crates/ui-egui/src/component_set_narrow_tests.rs @@ -0,0 +1,56 @@ +//! A component set's Properties at the narrowest right panel (240 px, the default), driven through +//! egui headless: every value field and every + and − button stays inside the panel, and the +//! values wrap onto more lines instead of running off its edge (found while retaking the README +//! screenshots). + +use egui::accesskit::Node; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::component_swap_default_tests::tree; +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +/// The right edge of what a panel row may use: the window's, less the panel's right inset. +const RIGHT: f64 = 1440.0 - crate::widgets::PAD_RIGHT as f64; + +fn label(n: &Node) -> String { + n.label().or(n.value()).unwrap_or_default().to_string() +} + +#[test] +fn a_component_sets_properties_fit_the_narrowest_panel() { + let mut s = Session::new(); + let mut variants = Vec::new(); + let names = ["Style=Primary, State=Default", "Style=Tonal, State=Hover", "Style=Outline, State=Disabled", "Style=Primary, State=Hover"]; + for (k, name) in names.iter().enumerate() { + let f = id(s.execute("frame.create", &json!({"x": 0, "y": k * 60, "width": 80, "height": 40, "name": name})).unwrap()); + variants.push(id(s.execute("component.create", &json!({"ids": [f]})).unwrap())); + } + let set = id(s.execute("component.combineVariants", &json!({"ids": variants})).unwrap()); + s.execute("component.addProperty", &json!({"id": set, "name": "Show icon", "type": "boolean", "default": true})).unwrap(); + s.execute("component.addProperty", &json!({"id": set, "name": "Label", "type": "text", "default": "Continue"})).unwrap(); + s.execute("select.set", &json!({"ids": [set]})).unwrap(); + let mut u = Ui::new(s); + assert_eq!(u.app.ui.right_width, crate::PANEL_DEFAULT); + u.ctx.enable_accesskit(); + let nodes = tree(&mut u); + let find = |text: &str| -> Vec<&Node> { nodes.iter().filter(|n| label(n) == text && n.bounds().is_some()).collect() }; + for text in ["Primary", "Tonal", "Outline", "Default", "Hover", "Disabled", "Delete property", "Add variant with a new value"] { + let found = find(text); + assert!(!found.is_empty(), "{text} is in the panel"); + for n in found { + let b = n.bounds().unwrap_or_default(); + assert!(b.x1 <= RIGHT + 0.5, "{text} ends at {} past the panel's {RIGHT}", b.x1); + } + } + assert_eq!(find("Delete property").len(), 4, "a − for each property"); + assert_eq!(find("Add variant with a new value").len(), 2, "a + for each variant property"); + // Two values to a line at this width: Outline goes on the line under Tonal. + let y = |text: &str| find(text).first().map(|n| n.bounds().unwrap_or_default().y0).unwrap_or_default(); + assert!(y("Outline") > y("Tonal") + 10.0, "Outline wraps to the next line"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6061b91af..33c83164b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -72,6 +72,8 @@ mod comments_tests; #[cfg(test)] mod component_props_reorder_tests; #[cfg(test)] +mod component_set_narrow_tests; +#[cfg(test)] mod component_swap_default_tests; #[cfg(test)] mod component_swap_search_tests; diff --git a/crates/ui-egui/src/panels/component_panel.rs b/crates/ui-egui/src/panels/component_panel.rs index ddd7e3d3e..048071e3f 100644 --- a/crates/ui-egui/src/panels/component_panel.rs +++ b/crates/ui-egui/src/panels/component_panel.rs @@ -207,6 +207,23 @@ fn slot_fill(app: &mut FigApp, ui: &mut Ui, doc: &Document, slot: NodeId, prefer }); } +/// The indent of a variant property's value lines (under its icon). +const INDENT: f32 = 22.0; +/// A variant value field. +const VALUE_W: f32 = 62.0; +/// The + and − buttons. +const BUTTON: f32 = 22.0; +/// A variant property's default dropdown. +const DEFAULT_W: f32 = 70.0; +/// A shared property's default (a text field, a dropdown; a checkbox takes less). +const SHARED_W: f32 = 84.0; + +/// A property row's name field: `max` wide, less when the panel is narrow so that the type icon, +/// `default_w` of default and the − button after it stay in view. +fn name_width(ui: &Ui, default_w: f32, max: f32) -> f32 { + (ui.available_width() - default_w - BUTTON - 2.0 * widgets::GUTTER).clamp(48.0, max) +} + /// Properties of a component or component set. fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { let t = Tokens::get(ui.ctx()); @@ -257,15 +274,20 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { // Variant properties: name, values (the matrix), add value, delete. Dragged by their left // margin to reorder them (only among themselves: the shared ones keep the engine's order). let mut drag = ListDrag::in_order(ui, ("variant_props", owner)); + // As many value fields to a line as the panel fits (2 at its narrowest), with the + after them. + let inner = ui.available_width() - widgets::PAD - widgets::PAD_RIGHT; + let per_line = (((inner - INDENT - BUTTON - widgets::GUTTER) / (VALUE_W + widgets::GUTTER)).floor() as usize).max(1); for (i, (k, vals)) in variants.iter().enumerate() { let header = row_rect(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, Icon::ComponentSet, COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, 110.0, tr("Name")) { + // The name gives way first, so the default and the delete button stay in view. + let name_w = name_width(ui, DEFAULT_W, 110.0); + if let Some(nn) = widgets::text_field(ui, Id::unique(("vprop_name", owner, k.as_str())), k, name_w, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } let def = defaults.get(k).or(vals.first()).cloned().unwrap_or_default(); - let resp = widgets::dropdown(ui, &def, 70.0, None).on_hover_text(tr("Default value (the top-left variant)")); + let resp = widgets::dropdown(ui, &def, DEFAULT_W, None).on_hover_text(tr("Default value (the top-left variant)")); menu_popup(&resp).show(|ui| { for v in vals { if menu_item(ui, v, "") { @@ -274,30 +296,31 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { } } }); - if icons::button(ui, Icon::Minus, 22.0, false, tr("Delete property")).clicked() { + if icons::button(ui, Icon::Minus, BUTTON, false, tr("Delete property")).clicked() { app.run("component.deleteProperty", json!({"id": owner, "name": k})); } }); // Values: rename inline; "+" adds a variant with a new value. let mut vi = 0; while vi < vals.len() { - let chunk: Vec = vals.iter().skip(vi).take(3).cloned().collect(); + let chunk: Vec = vals.iter().skip(vi).take(per_line).cloned().collect(); let last = vi + chunk.len() >= vals.len(); row(ui, 30.0, |ui| { - ui.add_space(22.0); + ui.add_space(INDENT); for v in &chunk { - if let Some(nv) = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, 62.0, "") + let field = widgets::text_field(ui, Id::unique(("vprop_val", owner, k.as_str(), v.as_str())), v, VALUE_W, ""); + if let Some(nv) = field && nv != *v { app.run("component.editProperty", json!({"id": owner, "name": k, "value": v, "newValue": nv})); } } - if last && icons::button(ui, Icon::Plus, 22.0, false, tr("Add variant with a new value")).clicked() { + if last && icons::button(ui, Icon::Plus, BUTTON, false, tr("Add variant with a new value")).clicked() { let nv = (vals.len() + 1..).map(|n| format!("Value {n}")).find(|n| !vals.contains(n)).unwrap_or_default(); app.run("component.addVariant", json!({"id": owner, "property": k, "value": nv})); } }); - vi += 3; + vi += chunk.len().max(1); } drag.row(ui, i, header, header.with_max_y(ui.cursor().min.y.max(header.max.y)), || k.clone()); } @@ -314,12 +337,15 @@ fn properties_ui(app: &mut FigApp, ui: &mut Ui, doc: &Document, owner: NodeId) { row(ui, 32.0, |ui| { let (ir, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover()); icons::paint(ui.painter(), ir, type_icon(d), COMPONENT); - if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, 96.0, tr("Name")) { + let name_w = name_width(ui, SHARED_W, 96.0); + if let Some(nn) = widgets::text_field(ui, Id::unique(("prop_name", owner, k.as_str())), k, name_w, tr("Name")) { app.run("component.editProperty", json!({"id": owner, "name": k, "newName": nn})); } match d { PropDef::Boolean { default } => { - if widgets::checkbox(ui, *default, if *default { tr("True") } else { tr("False") }) { + // As wide as the other defaults: a checkbox takes all the room, which the − needs. + let label = if *default { tr("True") } else { tr("False") }; + if ui.allocate_ui(vec2(SHARED_W, widgets::FIELD_H), |ui| widgets::checkbox(ui, *default, label)).inner { app.run("component.editProperty", json!({"id": owner, "name": k, "default": !default})); } } diff --git a/docs/components-parity.md b/docs/components-parity.md index 41d0b23ed..bbb3d4550 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8: the component set's Properties section fits the narrowest right panel: variant values wrap onto more lines and every + and − button stays visible. Grade unchanged. | | 2026-10-30 | minor | M8, #1097: library updates from a team library a file names are offered, not fetched from its server by themselves: only team libraries you added or checked are checked in the background (**Check for updates** in Assets ▸ Libraries). Numbers unchanged. | | 2026-10-30 | minor | M8, #1114: variables import from an explicit file path (`variable.import`, the `variables` of `file.open` and `file.import`) takes regular files of at most 256 MiB, the limit the sibling `variables.json` already had; anything else is refused before the document changes. Grade unchanged. Tests: `import_export_tests::an_explicit_variables_path_is_capped_like_the_sibling_file`, `variables_io_tests::variables_import_caps_the_file_it_reads` | | 2026-10-30 | minor | M3, #1536 and #1537: component properties are listed in the order they were made in, and renaming one keeps its place (they were sorted by name, so a rename moved the property); that holds for every kind, on every variant of a set and on instances, in `component.properties`, the Design panel and `.fig` export. Files from before keep their properties by name until one is added or renamed. Make slot (the Slot menu of a frame inside a component) asks for the slot's name first: a field under the Slot row with the frame's name selected, Enter or Make slot to make it, Escape to cancel; a name taken keeps the field open. Renaming stays in the component's Properties list. Tests: `engine/src/prop_order_tests.rs`, `ui-egui/src/make_slot_naming_tests.rs`. Left: dragging the shared properties to reorder them, and renaming a slot with only its frame selected. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 3b0b395a7..6d208e837 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8: a component set's property rows fit the 240 px right panel (values wrap; + and − stay in view). Tests: `component_set_narrow_tests` | | 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged | | 2026-10-30 | minor | M8, #1097: team libraries are checked in the background only when you added or checked them here (`library.check`); opening a file that names another server contacts nothing. Tests: `library_beacon_tests` | | 2026-10-30 | minor | M8, #1121: client-side maps were unbounded. A hostile host or server could mint people without end: the presence waiting for the UI, the people a connection had seen and the session's list of who is here each grew with every new id; and a session server's set of deleted layer ids (kept so undo may create them again) only grew. Now a client keeps track of at most `MAX_PEERS` (4,096) others: past it, someone new is left out with a logged warning (in a welcome too), someone already here can still be renamed, and someone leaving makes room; a server's `max_people` above 4,096 is lowered to it with a startup warning (the server still starts). A session remembers at most 262,144 deleted ids, forgetting the lowest first (on restore too). Threat model in `collab-protocol.md`. Tests: `client_cap_tests.rs` (collab-net), `live_peer_cap_tests.rs` (engine), `deleted_cap_tests.rs` (collab), the server's `max_people` clamp. | From b0717ba6279c9894bd2f82fba1a420a787fc7ba2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 30 Oct 2026 02:45:41 -0400 Subject: [PATCH 14/34] UPD.1: honor app pretend versions in update checks (#1627) --- Cargo.lock | 2 +- Cargo.toml | 7 ++++--- contributors/people.toml | 3 +++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 9e50554ed..7191353e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1115,7 +1115,7 @@ dependencies = [ [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=1065653e75854e9c17e1ff563acdd344c1b185f0#1065653e75854e9c17e1ff563acdd344c1b185f0" +source = "git+https://github.com/storytold/craft-libs?rev=04aa1703b32d299e54f78fcde4182f2b393a9116#04aa1703b32d299e54f78fcde4182f2b393a9116" dependencies = [ "chrono", "dunce", diff --git a/Cargo.toml b/Cargo.toml index e86780cef..2bef2eb20 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,7 +33,8 @@ figcraft-mcp = { path = "crates/mcp" } figcraft-collab-net = { path = "crates/collab-net" } figcraft-plugin = { path = "crates/plugin" } -# craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together. +# Keep coupled craft-libs crates on one rev. craft-update has no sibling-crate +# dependencies and is pinned separately for the suite updater rollout. # Which paths stored in a document may be followed (craft-libs). craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the @@ -41,8 +42,8 @@ craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-l # to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would # pull a second egui family version). craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } -# craft-update with storytold/craft-libs#434: the update prompt shows the app's icon, accent and developer. -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "1065653e75854e9c17e1ff563acdd344c1b185f0", features = ["egui"] } +# craft-update: app-specific pretend versions apply to startup and manual checks. +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "04aa1703b32d299e54f78fcde4182f2b393a9116", features = ["egui"] } # The suite's font store: exact lookup of a missing font in the pinned Google Fonts and Noto # indexes, and checksum-verified installs into the one folder every Craft app reads (craft-libs). craft-font-store = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } diff --git a/contributors/people.toml b/contributors/people.toml index 77553d44f..acab9bca2 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -98,3 +98,6 @@ display_name = "Filip Strajnar" [people.Carbocode] sync_github_name = true + +[people.dylanmtaylor] +sync_github_name = true From c47177498dd148ad832eaf43436b8ad2657c19d8 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 13:48:26 +0700 Subject: [PATCH 15/34] M8: every file a command reads by path is a regular file within a cap (#1114) (#1625) --- ROADMAP.md | 3 +- apps/figcraft-cli/src/files.rs | 21 +++- crates/engine/clippy.toml | 6 +- crates/engine/src/automation/file_access.rs | 37 +++++-- .../src/automation/file_access/tests.rs | 11 ++- crates/engine/src/cmd/create.rs | 3 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/file/load.rs | 7 +- crates/engine/src/extensions/plugins.rs | 6 +- crates/engine/src/import_export_tests.rs | 5 +- crates/engine/src/lib.rs | 2 + crates/engine/src/path_read_caps_tests.rs | 96 +++++++++++++++++++ crates/engine/src/variables_io_tests.rs | 2 +- crates/ui-egui/src/widgets/colorpicker.rs | 2 +- docs/file-format-parity.md | 3 +- docs/roadmap.md | 3 +- 16 files changed, 180 insertions(+), 29 deletions(-) create mode 100644 crates/engine/src/path_read_caps_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index a8dea6153..5edda53aa 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-10-30 · **Change:** minor (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); 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); 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-10-30 · **Change:** minor (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; 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); 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); 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 @@ -355,6 +355,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1114 (the rest): `file.open` and `file.import` (and recovery copies), `image.place {path}`, the image fill's file picker and `figcraft-cli run`'s script read the path they were given whole, so a device (`/dev/zero`), a pipe or an absurd file could fill memory or wait forever. Every whole-file read of a path now goes through `file_access::read_capped`, which looks at the path before opening it and refuses a folder, a device or pipe, or a file over its cap, with a message saying which, and reads at most the cap even if the file grows; the uncapped `file_access::read` is gone. Caps: documents 16 GiB (a `.figcraft` keeps its images as they are: #1313's file is 7.4 GB on disk), placed images 256 MiB (what a document takes from outside, `ImageData::MAX_BYTES`), scripts 1 GiB; fonts (256 MiB), plugin files and variables files already had theirs. Numbers unchanged. Tests: `path_read_caps_tests`, `file_access` tests, the CLI's `a_script_that_isnt_a_regular_file_is_refused`. | | 2026-10-30 | minor | M8 (found while retaking the README screenshots): at the default (and narrowest) 240 px right panel, a component set's variant property rows ran off the panel: values past the second ("Outline", "Disabled") were cut off, and the + after them and the − of each property sat past the edge; a shared boolean property's − was pushed out too. Values now wrap as many to a line as fit (two at 240 px, three from about 320 px as before), the property name field gives way first so the default and − stay in view, and a boolean's checkbox no longer takes the whole row. Numbers unchanged. Tests: `component_set_narrow_tests`. | | 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged | | 2026-10-30 | minor | M8, #1097: while the Assets panel showed a file, the app asked the server of every team library the file named for updates, with the link's token and a usage report, so a file someone sent could make the app contact any server. Now only team libraries you added (`library.add`) or checked on this computer are checked by themselves (kept with the preferences as hashes of their links); another one waits in **Assets ▸ Libraries** as "Not checked yet" until you click **Check for updates** (`library.check`, which plugins can't call). Placing or applying something from it is your own action and asks its server as before. Numbers unchanged. Tests: `library_beacon_tests` (engine and UI). | diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 7b8226469..8614e2cdd 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -402,8 +402,12 @@ pub fn parse_script(text: &str) -> Result, String> { .collect() } +/// The largest script `run` reads (one may carry images as base64): a device, a pipe or a larger +/// file is refused before it is read (#1114). +pub const MAX_SCRIPT: u64 = 1 << 30; + pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliResult { - let bytes = figcraft_engine::file_access::read(script).map_err(|e| CliError::Failed(e.to_string()))?; + let bytes = figcraft_engine::file_access::read_capped(script, MAX_SCRIPT).map_err(|e| CliError::Failed(e.to_string()))?; let text = String::from_utf8(bytes).map_err(|_| CliError::Failed(format!("{script}: not UTF-8")))?; let steps = parse_script(&text).map_err(|e| CliError::Failed(format!("{script}: {e}")))?; let (mut s, input_images_left_out) = match input { @@ -538,6 +542,21 @@ mod tests { dir } + /// A script path that is a folder or a device is refused before anything is read (#1114). + #[test] + fn a_script_that_isnt_a_regular_file_is_refused() { + let dir = temp_dir("script-caps"); + let mut bad = vec![dir.to_string_lossy().into_owned()]; + if cfg!(unix) { + bad.push("/dev/zero".into()); + } + for path in bad { + let Err(CliError::Failed(e)) = run_script(&path, None, None) else { panic!("{path} ran") }; + assert!(e.contains("is a folder, not a file") || e.contains("isn't a regular file"), "{path}: {e}"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it /// as "no parent" and the layer landed on the page — with every step reporting success. diff --git a/crates/engine/clippy.toml b/crates/engine/clippy.toml index 3ceac8998..4cf321bd0 100644 --- a/crates/engine/clippy.toml +++ b/crates/engine/clippy.toml @@ -6,8 +6,8 @@ allow-expect-in-tests = true allow-panic-in-tests = true too-many-arguments-threshold = 10 disallowed-methods = [ - { path = "std::fs::read", reason = "use file_access::read (automation roots)" }, - { path = "std::fs::read_to_string", reason = "use file_access::read (automation roots)" }, + { path = "std::fs::read", reason = "use file_access::read_capped (automation roots, size caps)" }, + { path = "std::fs::read_to_string", reason = "use file_access::read_capped (automation roots, size caps)" }, { path = "std::fs::write", reason = "use file_access::write (automation roots)" }, { path = "std::fs::metadata", reason = "use file_access::metadata (automation roots)" }, { path = "std::fs::symlink_metadata", reason = "use file_access (automation roots)" }, @@ -35,5 +35,5 @@ disallowed-methods = [ { path = "std::path::Path::canonicalize", reason = "use file_access (automation roots)" }, { path = "figcraft_format::write_file_atomic", reason = "use file_access::write (automation roots)" }, { path = "figcraft_format::save_file", reason = "use file_access::write (automation roots)" }, - { path = "figcraft_format::load_file", reason = "use file_access::read (automation roots)" }, + { path = "figcraft_format::load_file", reason = "use file_access::read_capped (automation roots, size caps)" }, ] diff --git a/crates/engine/src/automation/file_access.rs b/crates/engine/src/automation/file_access.rs index f741e21c9..aeaa3c130 100644 --- a/crates/engine/src/automation/file_access.rs +++ b/crates/engine/src/automation/file_access.rs @@ -236,21 +236,42 @@ fn io(path: &Path) -> impl Fn(std::io::Error) -> EngineError + '_ { move |e| EngineError::Other(format!("{}: {e}", path.display())) } -/// The bytes of file `path`. -pub fn read(path: impl AsRef) -> Result> { - let path = path.as_ref(); - std::fs::read(allow(path, Access::Read)?).map_err(io(path)) +/// `bytes` for a person: "300 bytes", "12 MB", "6.5 GB". +fn size(bytes: u64) -> String { + match bytes { + b if b < 1 << 20 => format!("{b} bytes"), + b if b < 1 << 30 => format!("{} MB", b.div_ceil(1 << 20)), + b => format!("{:.1} GB", b as f64 / f64::from(1u32 << 30)), + } } -/// The bytes of file `path`, refused when it isn't a file or is larger than `max`. +/// The bytes of file `path`, at most `max` of them. A folder, a device, a pipe or a file larger +/// than `max` is refused before anything is read, so a path a caller gives can't fill memory or +/// wait forever (#1114). Every whole-file read of a path goes through here. pub fn read_capped(path: impl AsRef, max: u64) -> Result> { + use std::io::Read as _; let path = path.as_ref(); let p = allow(path, Access::Read)?; + let refuse = |why: String| EngineError::Other(format!("{}: {why}", path.display())); + // Looked at before opening: opening a pipe waits for whoever writes it. let meta = std::fs::metadata(&p).map_err(io(path))?; - if !meta.is_file() || meta.len() > max { - return Err(EngineError::Other(format!("{}: not a file of at most {max} bytes", path.display()))); + if meta.is_dir() { + return Err(refuse("is a folder, not a file".into())); } - std::fs::read(p).map_err(io(path)) + if !meta.is_file() { + return Err(refuse("isn't a regular file (a device or a pipe?), so it isn't read".into())); + } + if meta.len() > max { + return Err(refuse(format!("is {}, more than the {} allowed here", size(meta.len()), size(max)))); + } + let f = std::fs::File::open(p).map_err(io(path))?; + let mut bytes = Vec::with_capacity(usize::try_from(meta.len()).unwrap_or(0)); + f.take(max.saturating_add(1)).read_to_end(&mut bytes).map_err(io(path))?; + // It grew after it was looked at. + if bytes.len() as u64 > max { + return Err(refuse(format!("grew past the {} allowed here while it was read", size(max)))); + } + Ok(bytes) } /// File `path` opened for reading. diff --git a/crates/engine/src/automation/file_access/tests.rs b/crates/engine/src/automation/file_access/tests.rs index ba55e1348..72f471f94 100644 --- a/crates/engine/src/automation/file_access/tests.rs +++ b/crates/engine/src/automation/file_access/tests.rs @@ -198,10 +198,10 @@ fn a_scope_confines_the_thread_and_ends_with_it() { assert!(current().is_none()); confined(Some(&roots), || { assert!(allow(f.p("outside/secret.txt"), Access::Read).is_err()); - assert!(super::read(f.p("outside/secret.txt")).is_err()); - assert_eq!(super::read(f.p("root/in.txt")).unwrap(), b"in"); + assert!(super::read_capped(f.p("outside/secret.txt"), 64).is_err()); + assert_eq!(super::read_capped(f.p("root/in.txt"), 64).unwrap(), b"in"); // Not confined: the app's own files. - assert_eq!(super::app_owned(|| super::read(f.p("outside/secret.txt")).unwrap()), b"secret"); + assert_eq!(super::app_owned(|| super::read_capped(f.p("outside/secret.txt"), 64).unwrap()), b"secret"); assert!(current().is_some(), "back in the scope after app_owned"); // Another thread isn't confined by this one's scope. let p = f.p("outside/secret.txt"); @@ -386,3 +386,8 @@ fn only_resolved_paths_count_as_the_apps_own_folder() { assert!(!super::within_app_folder(&install.join("missing.wasm"), &install)); assert!(!super::within_app_folder(&install.join("in.txt"), &f.0.join("no-such-folder"))); } + +#[test] +fn sizes_read_as_a_person_says_them() { + assert_eq!((super::size(300), super::size((12 << 20) - 5), super::size(13 << 29)), ("300 bytes".into(), "12 MB".into(), "6.5 GB".into())); +} diff --git a/crates/engine/src/cmd/create.rs b/crates/engine/src/cmd/create.rs index 83f7a13e6..1e1f8f3bb 100644 --- a/crates/engine/src/cmd/create.rs +++ b/crates/engine/src/cmd/create.rs @@ -410,9 +410,10 @@ fn free_spot(s: &Session, w: f64, h: f64) -> Result<(f64, f64)> { }) } +/// An image file to place: no larger than a document takes an image from outside (#1114). #[cfg(not(target_arch = "wasm32"))] fn read_path(path: &str) -> Result> { - crate::file_access::read(path) + crate::file_access::read_capped(path, figcraft_doc::ImageData::MAX_BYTES) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index c99914f4b..4939bc2c6 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -30,9 +30,9 @@ pub use export::{ }; use export::{export, export_frames_pdf, export_layers, pdf_bytes}; use load::font_report; +pub use load::{MAX_DOCUMENT_FILE, load, load_bytes, load_path}; #[cfg(test)] pub(crate) use load::{installed_family, sketch_line}; -pub use load::{load, load_bytes, load_path}; pub(crate) use load::{load_fig, read_capped, recovered, recovered_from, variables_param, write}; pub use open::MAX_RECENT; #[cfg(test)] diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 8234ad094..1ed6dafe3 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -14,9 +14,14 @@ pub(crate) fn recovered_from(bytes: &[u8]) -> Result { Ok(DocState::new(loaded.doc, None).with_versions(loaded.versions).with_branches(loaded.branches).unsaved()) } +/// The largest file opened or imported as a document. A `.figcraft` file keeps its images as +/// they are, so a real one can be far larger than it unpacks to (#1313's took 7.4 GB); twice that +/// opens, while a device, a pipe or an absurd file is refused before it is read (#1114). +pub const MAX_DOCUMENT_FILE: u64 = 16 << 30; + #[cfg(not(target_arch = "wasm32"))] pub(super) fn read(path: &str) -> Result> { - crate::file_access::read(path) + crate::file_access::read_capped(path, MAX_DOCUMENT_FILE) } #[cfg(target_arch = "wasm32")] diff --git a/crates/engine/src/extensions/plugins.rs b/crates/engine/src/extensions/plugins.rs index cd9d35ec6..dbc083368 100644 --- a/crates/engine/src/extensions/plugins.rs +++ b/crates/engine/src/extensions/plugins.rs @@ -324,11 +324,7 @@ pub(crate) fn installed<'a>(s: &'a Session, cmd: &str, id: &str) -> Result<&'a I /// Read a file of at most `max` bytes. #[cfg(not(target_arch = "wasm32"))] pub(crate) fn read_file(path: &std::path::Path, max: usize) -> Result> { - let meta = crate::file_access::metadata(path)?; - if !meta.is_file() || meta.len() > max as u64 { - return Err(EngineError::Other(format!("{}: not a file of at most {} KB", path.display(), max / 1024))); - } - crate::file_access::read(path) + crate::file_access::read_capped(path, max as u64) } /// The manifest at `path` (the file, or the folder holding `figcraft-plugin.json`), its absolute diff --git a/crates/engine/src/import_export_tests.rs b/crates/engine/src/import_export_tests.rs index b6897b2a8..4acc86216 100644 --- a/crates/engine/src/import_export_tests.rs +++ b/crates/engine/src/import_export_tests.rs @@ -128,7 +128,10 @@ fn an_explicit_variables_path_is_capped_like_the_sibling_file() { for cmd in ["file.open", "file.import"] { let before = state(&s); let e = s.execute(cmd, &json!({"path": path, "variables": bad})).unwrap_err().to_string(); - assert!(e.contains("not a file of at most 268435456 bytes"), "{cmd} {bad}: {e}"); + assert!( + ["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)), + "{cmd} {bad}: {e}" + ); assert_eq!(state(&s), before, "{cmd} {bad}"); } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e0ef1ff72..cedc61be6 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -419,6 +419,8 @@ mod paint_tests; #[cfg(test)] mod panels_behaviour_tests; #[cfg(test)] +mod path_read_caps_tests; +#[cfg(test)] mod pdf_print_tests; #[cfg(test)] mod pen_end_tests; diff --git a/crates/engine/src/path_read_caps_tests.rs b/crates/engine/src/path_read_caps_tests.rs new file mode 100644 index 000000000..2805bf51f --- /dev/null +++ b/crates/engine/src/path_read_caps_tests.rs @@ -0,0 +1,96 @@ +//! Paths callers give are read only when they are regular files within a cap that fits their +//! kind (#1114): a folder, a device or an oversized file is refused with a clear error before +//! anything is read, and the session is left as it was. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use crate::Session; +use crate::file_access::read_capped; + +struct Dir(PathBuf); + +impl Dir { + fn new(name: &str) -> Self { + let d = std::env::temp_dir().join(format!("figcraft-caps-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(d.join("folder")).unwrap(); + Self(d) + } + fn p(&self, rel: &str) -> String { + self.0.join(rel).to_string_lossy().into_owned() + } + /// A sparse file of `len` bytes (nothing is written). + fn sized(&self, rel: &str, len: u64) -> String { + std::fs::File::create(self.0.join(rel)).unwrap().set_len(len).unwrap(); + self.p(rel) + } +} + +impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// `cmd` with `params` fails with a message containing `why`, and changes no document. +fn refused(s: &mut Session, cmd: &str, params: Value, why: &str) { + let before = (format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::>()), s.active); + let e = s.execute(cmd, ¶ms).unwrap_err().to_string(); + assert!(e.contains(why), "{cmd} {params}: {e}"); + assert_eq!((format!("{:?}", s.docs.iter().map(|d| &d.doc).collect::>()), s.active), before, "{cmd} {params}"); +} + +const FOLDER: &str = "is a folder, not a file"; +const DEVICE: &str = "isn't a regular file"; + +#[test] +fn the_capped_read_refuses_folders_devices_and_oversized_files() { + let d = Dir::new("read"); + std::fs::write(d.0.join("four.txt"), b"four").unwrap(); + assert_eq!(read_capped(d.p("four.txt"), 4).unwrap(), b"four"); + let e = read_capped(d.p("four.txt"), 3).unwrap_err().to_string(); + assert!(e.contains("is 4 bytes, more than the 3 bytes allowed here"), "{e}"); + assert!(read_capped(d.p("folder"), u64::MAX).unwrap_err().to_string().contains(FOLDER)); + if cfg!(unix) { + // An endless source: refused, not read until memory runs out. + assert!(read_capped("/dev/zero", u64::MAX).unwrap_err().to_string().contains(DEVICE)); + } +} + +#[test] +fn documents_are_refused_as_folders_and_devices() { + let d = Dir::new("docs"); + let mut s = Session::new(); + for cmd in ["file.open", "file.import"] { + refused(&mut s, cmd, json!({"path": d.p("folder")}), FOLDER); + if cfg!(unix) { + refused(&mut s, cmd, json!({"path": "/dev/zero"}), DEVICE); + } + } + const { assert!(crate::cmd::file::MAX_DOCUMENT_FILE > 7_400_000_000, "a real 7.4 GB file still opens") }; +} + +#[test] +fn placed_images_are_capped_at_what_a_document_takes() { + let d = Dir::new("images"); + let mut s = Session::new(); + let big = d.sized("huge.png", figcraft_doc::ImageData::MAX_BYTES + 1); + refused(&mut s, "image.place", json!({"path": big}), "more than the 256 MB allowed here"); + refused(&mut s, "image.place", json!({"path": d.p("folder")}), FOLDER); + if cfg!(unix) { + refused(&mut s, "image.place", json!({"path": "/dev/zero"}), DEVICE); + } +} + +#[test] +fn fonts_and_plugins_are_refused_as_folders() { + let d = Dir::new("plugins"); + let mut s = Session::new(); + refused(&mut s, "text.loadFont", json!({"path": d.p("folder")}), FOLDER); + // A plugin folder whose manifest is a folder. + std::fs::create_dir_all(d.0.join("plugin").join("figcraft-plugin.json")).unwrap(); + refused(&mut s, "plugin.import", json!({"path": d.p("plugin")}), FOLDER); + assert!(s.prefs.plugins.is_empty()); +} diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index b34294a84..e9eca6a14 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -291,7 +291,7 @@ fn variables_import_caps_the_file_it_reads() { for bad in &refused { let before = (format!("{:?}", t.docs), t.active); let e = t.execute("variable.import", &json!({"variables": bad})).unwrap_err().to_string(); - assert!(e.contains("not a file of at most 268435456 bytes"), "{bad}: {e}"); + assert!(["more than the 256 MB allowed here", "is a folder, not a file", "isn't a regular file"].iter().any(|w| e.contains(w)), "{bad}: {e}"); assert_eq!((format!("{:?}", t.docs), t.active), before, "{bad}"); } // A file under the limit still imports. diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index 99af5f0ff..6f2964795 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -584,7 +584,7 @@ fn paint_type_row(ui: &mut Ui, app: &mut FigApp, target: &PickerTarget, paint: & image_pending = true; let target = target.clone(); app.pick_open(tr("Choose image"), &["png", "jpg", "jpeg", "webp", "gif", "tif", "tiff"], move |app, p| { - let Ok(bytes) = figcraft_engine::file_access::read(&p) else { return }; + let Ok(bytes) = figcraft_engine::file_access::read_capped(&p, figcraft_doc::ImageData::MAX_BYTES) else { return }; let Some(hash) = app.session.doc_mut().ok().and_then(|d| figcraft_format_add(d, bytes)) else { return }; if let Some(mut paint) = current_paint(app, &target) { paint.kind = Paint::image(hash, ImageScaleMode::Fill).kind; diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 0a5a475ae..b9cdd36e9 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -185,6 +185,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1114: a document opened or imported by path must be a regular file of at most 16 GiB, and an image placed by path one of at most 256 MiB: a folder, a device or a pipe is refused before anything is read. Real files open as before. Numbers unchanged. | | 2026-10-30 | minor | M8, #1101: `.fig` and REST imports of files nested 256 levels deep (the cap) fit the browser's 1 MB stack: the REST layer walk no longer recurses. Numbers unchanged. | | 2026-10-30 | minor | M8, #1082: SVG and PDF exports draw a pattern's source one level below the layer it fills, and the images they embed of layers and pattern cells go on from the layer's depth, so one depth cap bounds a whole export. Numbers unchanged. | | 2026-10-30 | minor | M8, #1079: a `.figcraft` file unpacks at most 6 GiB in all when it opens (2 GiB in the browser), every area together, beyond each area's own cap; what passes it is left out with a note (images stay in the file, #1313). Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 6d208e837..81f1392ba 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1114 (the rest): documents, placed images and CLI scripts read by path are regular files within caps (16 GiB, 256 MiB, 1 GiB); folders, devices and pipes are refused before anything is read. Tests: `path_read_caps_tests` | | 2026-10-30 | minor | M8: a component set's property rows fit the 240 px right panel (values wrap; + and − stay in view). Tests: `component_set_narrow_tests` | | 2026-10-30 | minor | M9, #1130: the server key is compared by its hash. `Store::manage` hashes the presented and the kept key with SHA-256 and compares the two digests; the key on disk, `figcraft-server key` and rotation are unchanged. Regression tests in `store_tests` (`operator_key_*`). Numbers unchanged | | 2026-10-30 | minor | M8, #1097: team libraries are checked in the background only when you added or checked them here (`library.check`); opening a file that names another server contacts nothing. Tests: `library_beacon_tests` | From 58d791c88781d216eb5f87b4d6301c9057ef0a0c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 13:34:04 +0700 Subject: [PATCH 16/34] #1616: take the image byte budget from IMAGE_BUDGET --- crates/format/src/fig.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index fce7066d1..fb18fd5d5 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -68,10 +68,11 @@ const MAX_CANVAS: u64 = 256 * 1024 * 1024; const MAX_CHUNK: u64 = 512 * 1024 * 1024; /// Largest image member read. const MAX_IMAGE: u64 = figcraft_doc::ImageData::MAX_BYTES; -/// Total decompressed ZIP image bytes read per import: the native image budget on 64-bit targets, -/// lower on 32-bit targets to leave address space for the canvas, layers and decoded pixels. +/// Total decompressed ZIP image bytes read per import: the native image budget (`IMAGE_BUDGET`) on +/// 64-bit targets, lower on 32-bit targets to leave address space for the canvas, layers and +/// decoded pixels. #[cfg(not(target_pointer_width = "32"))] -const MAX_IMAGE_BYTES: u64 = 4 * 1024 * 1024 * 1024; +const MAX_IMAGE_BYTES: u64 = crate::IMAGE_BUDGET.bytes; #[cfg(target_pointer_width = "32")] const MAX_IMAGE_BYTES: u64 = 1024 * 1024 * 1024; /// Most chunks read from `canvas.fig`. From 1a454edd099675ee82188d064434de2122d1ce42 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Fri, 30 Oct 2026 15:09:25 +0800 Subject: [PATCH 17/34] M8: .fig import budgets four values per compressed canvas byte instead of one (#574) The .fig import gave a file one decoded value per byte of its compressed canvas (at least 20M, at most 128M). Dense legitimate files decode past that, further since every scalar field counts, and the whole import was refused as "not a valid FigCraft file: .fig file: message: too many values". The budget is now four values per compressed byte; the floor and both ceilings (128M, 32M on 32-bit targets) are unchanged, so a small file that unpacks past the floor is still refused and no file decodes more values than the ceiling allowed before. Generated 21 MB and 15 MB canvases (220,044 and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole without warnings; the 49.7 MB one's test peaks at about 9 GB. --- crates/format/src/fig.rs | 2 + crates/format/src/fig/export.rs | 2 +- crates/format/src/fig/kiwi.rs | 19 ++- crates/format/src/fig/tests.rs | 36 +++-- crates/format/src/fig/value_budget_tests.rs | 165 ++++++++++++++++++++ docs/file-format-parity.md | 7 +- 6 files changed, 208 insertions(+), 23 deletions(-) create mode 100644 crates/format/src/fig/value_budget_tests.rs diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index dc10cc55c..766dc42cc 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -50,6 +50,8 @@ mod text_skip_ink_tests; mod text_style_tests; #[cfg(test)] mod text_wrap_tests; +#[cfg(test)] +mod value_budget_tests; use std::collections::BTreeMap; use std::io::Read; diff --git a/crates/format/src/fig/export.rs b/crates/format/src/fig/export.rs index b475a9cb6..9e2c4b364 100644 --- a/crates/format/src/fig/export.rs +++ b/crates/format/src/fig/export.rs @@ -216,7 +216,7 @@ fn too_large() -> FormatError { } /// `canvas.fig`: the magic, the version and the two deflated chunks, each after its length. -fn canvas(schema: &[u8], message: &[u8]) -> Result, FormatError> { +pub(super) fn canvas(schema: &[u8], message: &[u8]) -> Result, FormatError> { let mut out = b"fig-kiwi".to_vec(); out.extend_from_slice(&VERSION.to_le_bytes()); for chunk in [schema, message] { diff --git a/crates/format/src/fig/kiwi.rs b/crates/format/src/fig/kiwi.rs index 82bdc6cdc..d34b3774d 100644 --- a/crates/format/src/fig/kiwi.rs +++ b/crates/format/src/fig/kiwi.rs @@ -11,11 +11,20 @@ use std::fmt; /// Deepest struct/message nesting decoded. const MAX_DEPTH: usize = 64; /// Most values decoded from one message (every scalar, array element and field counts): at least -/// [`MIN_VALUES`], and one per byte of the file's compressed canvas up to [`MAX_VALUES`], so a -/// large real file decodes while a small one can't expand into a huge tree, however far its -/// chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so their ceiling is -/// lower: past it the file is refused cleanly instead of running out of memory. +/// [`MIN_VALUES`], and [`VALUES_PER_PACKED_BYTE`] per byte of the file's compressed canvas up to +/// [`MAX_VALUES`], so a large real file decodes while a small one can't expand into a huge tree, +/// however far its chunks inflate. 32-bit targets (wasm32) have a 4 GB address space in all, so +/// their ceiling is lower: past it the file is refused cleanly instead of running out of memory. const MIN_VALUES: usize = 20_000_000; +/// Value budget per compressed canvas byte. The real files measured in +/// `large_files_get_a_larger_value_budget` hold up to 0.75 values per compressed byte without their +/// single scalar fields; counting those too adds 15–43% on the generated files in +/// `value_budget_tests` and up to 3.2 times on a rectangle carrying only geometry and paint, so a +/// dense file may reach about 2.4 per byte. Four leaves two-thirds more room than that. The +/// trade-off: from 32 MiB of canvas (8 MiB on 32-bit targets) a file gets all of [`MAX_VALUES`], +/// and above the floor it may decode four values for every byte it carries; [`MIN_VALUES`] and +/// [`MAX_VALUES`] still bound every file. +const VALUES_PER_PACKED_BYTE: usize = 4; #[cfg(not(target_pointer_width = "32"))] const MAX_VALUES: usize = 128 << 20; #[cfg(target_pointer_width = "32")] @@ -306,7 +315,7 @@ impl Schema { /// Most values decoded from a file whose compressed canvas is `packed` bytes. pub(crate) fn value_budget(packed: usize) -> usize { - packed.clamp(MIN_VALUES, MAX_VALUES) + packed.saturating_mul(VALUES_PER_PACKED_BYTE).clamp(MIN_VALUES, MAX_VALUES) } struct Decoder<'s, 'b> { diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 697f949de..70bdb7c84 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -79,41 +79,41 @@ message Blob byte[] bytes message Message NodeChange[] nodeChanges, Blob[] blobs "; -fn schema() -> Schema { +pub(super) fn schema() -> Schema { Schema::parse(SCHEMA).unwrap() } -fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { +pub(super) fn o(fields: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { Kv::Object(fields) } -fn f(x: f64) -> Kv<'static> { +pub(super) fn f(x: f64) -> Kv<'static> { Kv::Float(x as f32) } -fn u(x: u64) -> Kv<'static> { +pub(super) fn u(x: u64) -> Kv<'static> { Kv::Uint(x) } fn e(n: &'static str) -> Kv<'static> { Kv::Enum(n) } -fn s(x: &str) -> Kv<'static> { +pub(super) fn s(x: impl Into) -> Kv<'static> { Kv::Str(x.into()) } -fn g(l: u64) -> Kv<'static> { +pub(super) fn g(l: u64) -> Kv<'static> { o(vec![("sessionID", u(1)), ("localID", u(l))]) } -fn arr(v: Vec>) -> Kv<'static> { +pub(super) fn arr(v: Vec>) -> Kv<'static> { Kv::Array(v) } -fn xy(x: f64, y: f64) -> Kv<'static> { +pub(super) fn xy(x: f64, y: f64) -> Kv<'static> { o(vec![("x", f(x)), ("y", f(y))]) } -fn at(x: f64, y: f64) -> Kv<'static> { +pub(super) fn at(x: f64, y: f64) -> Kv<'static> { o(vec![("m00", f(1.0)), ("m01", f(0.0)), ("m02", f(x)), ("m10", f(0.0)), ("m11", f(1.0)), ("m12", f(y))]) } fn rgb(r: f64, gr: f64, b: f64) -> Kv<'static> { o(vec![("r", f(r)), ("g", f(gr)), ("b", f(b)), ("a", f(1.0))]) } -fn solid(r: f64, gr: f64, b: f64) -> Kv<'static> { +pub(super) fn solid(r: f64, gr: f64, b: f64) -> Kv<'static> { o(vec![("type", e("SOLID")), ("color", rgb(r, gr, b)), ("opacity", f(1.0)), ("visible", Kv::Bool(true))]) } fn root_guid() -> Kv<'static> { @@ -128,7 +128,7 @@ fn prop_ref(def: u64, field: &'static str) -> Kv<'static> { } /// A node: id (0: the document), parent (id, position), type, name and further fields. -fn node(id: u64, par: Option<(u64, &str)>, ty: &'static str, name: &str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { +pub(super) fn node(id: u64, par: Option<(u64, &str)>, ty: &'static str, name: &str, mut more: Vec<(&'static str, Kv<'static>)>) -> Kv<'static> { let mut v = vec![("guid", if id == 0 { root_guid() } else { g(id) })]; if let Some((p, pos)) = par { v.push(("parentIndex", o(vec![("guid", if p == 0 { root_guid() } else { g(p) }), ("position", s(pos))]))); @@ -803,10 +803,10 @@ fn other_files_are_not_fig() { assert!(try_import(&native, "x").is_none()); } -/// A cheap deterministic generator for the mangle tests. -struct Lcg(u64); +/// A cheap deterministic generator (mangled inputs, generated fixtures). +pub(super) struct Lcg(pub(super) u64); impl Lcg { - fn next(&mut self) -> u64 { + pub(super) fn next(&mut self) -> u64 { self.0 = self.0.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); self.0 >> 33 } @@ -889,6 +889,14 @@ fn large_files_get_a_larger_value_budget() { assert!(kiwi::value_budget(39_528_420) > 29_703_730); // Still bounded by the largest canvas read. assert!(kiwi::value_budget(MAX_CANVAS as usize) <= 128 << 20); + // Counting every scalar field, a dense file may reach about 2.4 values per compressed byte (the + // 15 MB text-heavy fixture in `value_budget_tests` measures 1.8): below the ceiling the budget must + // cover that, the floor still holds up to 5 MB of canvas, and 32 MiB is enough for the ceiling. + assert!(kiwi::value_budget(8 << 20) >= (8 << 20) * 12 / 5); + #[cfg(not(target_pointer_width = "32"))] + assert!(kiwi::value_budget(30_000_000) >= 30_000_000 * 12 / 5); + assert_eq!(kiwi::value_budget(5_000_000), 20_000_000); + assert_eq!(kiwi::value_budget(32 << 20), kiwi::value_budget(MAX_CANVAS as usize)); } #[test] diff --git a/crates/format/src/fig/value_budget_tests.rs b/crates/format/src/fig/value_budget_tests.rs new file mode 100644 index 000000000..7046b1d50 --- /dev/null +++ b/crates/format/src/fig/value_budget_tests.rs @@ -0,0 +1,165 @@ +//! Dense legitimate canvases must open whole within the value budget their size gives them, and a +//! small canvas that unpacks past the floor (tens of millions of values) must stay refused. The dense ones take seconds and gigabytes: +//! `cargo test -p figcraft-format --release --lib value_budget -- --ignored --test-threads=1`. + +use super::kiwi::{Kv, Schema, encode}; +use super::tests::{Lcg, arr, at, f, g, node, o, s, schema, solid, u, xy}; +use super::{decode_canvas, export, import, kiwi}; + +fn frac(r: &mut Lcg, hi: f64) -> f64 { + (r.next() % 100_000) as f64 / 100_000.0 * hi +} + +fn hex(r: &mut Lcg, nibbles: usize) -> String { + (0..nibbles).map(|_| format!("{:x}", r.next() % 16)).collect() +} + +fn word(r: &mut Lcg) -> &'static str { + const WORDS: &[&str] = &[ + "frame", "card", "hero", "row", "cell", "icon", "badge", "button", "input", "label", "group", "section", "header", "footer", "nav", "list", + "item", "menu", "panel", "view", "page", "modal", "form", "field", "text", "title", "body", "caption", "avatar", "thumb", "chart", "graph", + "axis", "grid", "slot", "chip", "tag", "pill", "note", "hint", "tip", "alert", "toast", "banner", "strip", "rail", "tabs", "tab", "step", + "flow", "state", "theme", "mode", "token", + ]; + WORDS[(r.next() as usize) % WORDS.len()] +} + +/// Strings and GUIDs with real entropy on every record (a key, a version, a path of GUIDs), so +/// deflate packs the fixture about as tightly as a real file rather than far tighter. +fn identity(r: &mut Lcg, depth: usize) -> Vec<(&'static str, Kv<'static>)> { + let path = (0..depth).map(|_| g(r.next() % (1 << 30))).collect(); + vec![ + ("key", s(format!("{}:{}{}", hex(r, 4), hex(r, 4), r.next() % 997))), + ("version", s(format!("{}:0", hex(r, 6)))), + ("guidPath", o(vec![("guids", arr(path))])), + ] +} + +/// Somewhere on a page the size of a large design file. +fn placed(r: &mut Lcg) -> (&'static str, Kv<'static>) { + ("transform", at(frac(r, 12_000.0), frac(r, 8_000.0))) +} + +fn rect(id: u64, page: u64, r: &mut Lcg) -> Kv<'static> { + let name = format!("{} {} {}", word(r), word(r), r.next() % 9_999); + let mut fields = vec![ + ("size", xy(24.0 + frac(r, 300.0), 16.0 + frac(r, 200.0))), + placed(r), + ("fillPaints", arr(vec![solid(frac(r, 1.0), frac(r, 1.0), frac(r, 1.0))])), + ("strokeWeight", f(1.0 + frac(r, 3.0))), + ("cornerRadius", f(frac(r, 24.0))), + ("opacity", f(0.5 + frac(r, 0.5))), + ("visible", Kv::Bool(true)), + ]; + fields.extend(identity(r, 3)); + node(id, Some((page, "A")), "RECTANGLE", &name, fields) +} + +/// Text saved with per-character styles: its characters and one style id per character. +fn text(id: u64, page: u64, chars: usize, r: &mut Lcg) -> Kv<'static> { + let mut characters = String::new(); + while characters.len() < chars { + characters.push_str(word(r)); + characters.push_str(if r.next().is_multiple_of(5) { ". " } else { " " }); + } + characters.truncate(chars); + let styles = arr((0..characters.chars().count()).map(|_| u(0)).collect()); + let mut fields = vec![ + ("size", xy(320.0, 18.0)), + placed(r), + ("fontSize", f(11.0 + frac(r, 26.0))), + ("fontName", o(vec![("family", s("Inter")), ("style", s("Regular"))])), + ("fillPaints", arr(vec![solid(0.1, 0.1, 0.1)])), + ("textData", o(vec![("characters", s(characters)), ("characterStyleIDs", styles)])), + ]; + fields.extend(identity(r, 4)); + node(id, Some((page, "A")), "TEXT", "Body", fields) +} + +/// A canvas whose message writes one list field (field 1) of `count` already-encoded elements. +fn one_list_canvas(sc: &Schema, count: u32, elements: impl IntoIterator) -> Vec { + let mut head = encode::Writer::default(); + head.var_uint(1); + head.var_uint(count); + let mut msg = head.0; + msg.extend(elements); + msg.push(0); + export::canvas(&encode::schema(sc), &msg).unwrap() +} + +/// `pages` pages of `rects` rectangles and `texts` texts of `chars` characters, framed the way the +/// exporter frames a file. +fn canvas(pages: usize, rects: usize, texts: usize, chars: usize) -> Vec { + let sc = schema(); + let nc = sc.find("NodeChange").unwrap(); + let mut r = Lcg(0x5eed_1234_abcd_9999); + // Layers are encoded one at a time into the one field written (`nodeChanges`): one Kv tree of + // the whole message would take gigabytes of its own. + let mut layers = Vec::new(); + let mut count = 0u32; + let mut add = |n: Kv<'static>| { + layers.extend(encode::message(&sc, nc, &n).unwrap()); + count += 1; + }; + add(node(0, None, "DOCUMENT", "Fixture", vec![])); + let mut id = 10; + for p in 0..pages { + let page = id; + id += 1; + add(node(page, Some((0, "A")), "CANVAS", &format!("Page {p}"), vec![])); + for _ in 0..rects { + add(rect(id, page, &mut r)); + id += 1; + } + for _ in 0..texts { + add(text(id, page, chars, &mut r)); + id += 1; + } + } + one_list_canvas(&sc, count, layers) +} + +/// Every layer comes in and nothing is reported — and the fixture makes its point: one value per +/// compressed byte refuses it, and it decodes within 90% of its budget. Returns the canvas size. +fn opens_whole(label: &str, pages: usize, rects: usize, texts: usize, chars: usize) -> usize { + let file = canvas(pages, rects, texts, chars); + let (sc, msg) = decode_canvas(&file).unwrap(); + let root = sc.find("Message").unwrap(); + let one_per_byte = file.len().clamp(kiwi::value_budget(0), kiwi::value_budget(usize::MAX)); + let refused = sc.decode_message_within(root, &msg, one_per_byte).is_err_and(|e| e.0 == "message: too many values"); + assert!(refused, "{label}: one value per compressed byte no longer refuses it"); + let (doc, warnings) = import(&file, label).unwrap_or_else(|e| panic!("{label}: refused: {e}")); + assert_eq!(doc.node_count(), pages * (rects + texts) + pages, "{label}: layers"); + assert!(warnings.is_empty(), "{label}: {warnings:?}"); + println!("{label}: {} canvas bytes, {} layers", file.len(), doc.node_count()); + drop(doc); + assert!(sc.decode_message_within(root, &msg, kiwi::value_budget(file.len()) / 10 * 9).is_ok(), "{label}: not within 90% of its budget"); + file.len() +} + +#[test] +#[ignore = "heavy: --release --ignored, about 4.5 GB"] +fn dense_canvases_open_whole() { + opens_whole("21 MB design-shape canvas", 44, 3000, 2000, 140); + opens_whole("15 MB text-heavy canvas", 12, 4500, 4500, 400); +} + +/// From 32 MiB of canvas the budget is the ceiling. Its 83 million values are past the 32-bit +/// ceiling, so this one is for 64-bit targets. +#[cfg(not(target_pointer_width = "32"))] +#[test] +#[ignore = "heavy: --release --ignored, about 9 GB"] +fn a_canvas_budgeted_at_the_ceiling_opens_whole() { + let packed = opens_whole("50 MB text-heavy canvas", 106, 0, 5000, 100); + assert_eq!(kiwi::value_budget(packed), kiwi::value_budget(usize::MAX), "budgeted at the ceiling"); +} + +/// However far a small canvas inflates, the floor bounds what it may decode. +#[test] +fn a_small_canvas_that_unpacks_to_millions_of_values_is_refused() { + let n = 30_000_000; + let file = one_list_canvas(&Schema::parse("message Message uint[] xs").unwrap(), n, std::iter::repeat_n(0, n as usize)); + assert!(file.len() < 100_000, "{} bytes", file.len()); + let Err(e) = import(&file, "x") else { panic!("a {} byte canvas holding {n} values opened", file.len()) }; + assert_eq!(e.to_string(), "not a valid FigCraft file: .fig file: message: too many values"); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b9cdd36e9..33ee4f3c6 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (`.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); 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 @@ -17,8 +17,8 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.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), 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). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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 | -| `.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), 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, 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 (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), so a 15.4 MB synthetic file with 300,000 layers opens whole; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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 | +| `.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), 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). 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; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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 | +| `.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), 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, 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; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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, 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 | — | @@ -185,6 +185,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #574: `.fig` import budgets four decoded values per compressed canvas byte instead of one; the 20M floor and the 128M ceiling (32M on 32-bit targets) are unchanged. Dense legitimate files went past one value per compressed byte (further since every scalar field counts) and were refused whole as "not a valid FigCraft file: .fig file: message: too many values". Generated 21 MB and 15 MB canvases (220,044 and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole without warnings; a 29 KB canvas that unpacks to 30 million values is still refused. Files that now open instead of being refused take the memory they need: the 49.7 MB canvas's test peaks at about 9 GB. Tests: `crates/format/src/fig/value_budget_tests.rs` (the heavy ones with `--ignored`) and `large_files_get_a_larger_value_budget` in `crates/format/src/fig/tests.rs`. Numbers unchanged. | | 2026-10-30 | minor | M8, #1114: a document opened or imported by path must be a regular file of at most 16 GiB, and an image placed by path one of at most 256 MiB: a folder, a device or a pipe is refused before anything is read. Real files open as before. Numbers unchanged. | | 2026-10-30 | minor | M8, #1101: `.fig` and REST imports of files nested 256 levels deep (the cap) fit the browser's 1 MB stack: the REST layer walk no longer recurses. Numbers unchanged. | | 2026-10-30 | minor | M8, #1082: SVG and PDF exports draw a pattern's source one level below the layer it fills, and the images they embed of layers and pattern cells go on from the layer's depth, so one depth cap bounds a whole export. Numbers unchanged. | From 8dd69797afd8cb3baf4a83ca046cb0a82b3d5a77 Mon Sep 17 00:00:00 2001 From: Patrik Lermon Date: Fri, 30 Oct 2026 09:19:45 +0100 Subject: [PATCH 18/34] i18n(sv): translate 36 new strings Brings the Swedish catalog up to date with upstream main (e94350a7). Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/locales/sv.toml | 44 ++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 549b7ff82..e18044f52 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -99,6 +99,8 @@ name = "Svenska" "All supported properties" = "Alla egenskaper som stöds" "All the fonts this file uses are installed" = "Alla typsnitt som filen använder är installerade" "All variables" = "Alla variabler" +"Allow and download" = "Tillåt och hämta" +"Allow downloading fonts?" = "Tillåta hämtning av typsnitt?" "Along the bottom" = "Längs nederkanten" "Along the left" = "Längs vänsterkanten" "Along the right" = "Längs högerkanten" @@ -182,6 +184,7 @@ name = "Svenska" "Blend mode" = "Blandningsläge" "Blue" = "Blå" "Blur" = "Oskärpa" +"Board grid" = "Tavlans rutnät" "Boolean" = "Boolesk" "Boolean groups" = "Booleska grupper" "Both directions" = "Båda riktningarna" @@ -218,6 +221,7 @@ name = "Svenska" "Can comment" = "Kan kommentera" "Check" = "Bock" "Check for Updates" = "Sök efter uppdateringar" +"Check for updates" = "Sök efter uppdateringar" "Check for updates…" = "Sök efter uppdateringar…" "Checking for updates…" = "Söker efter uppdateringar…" "Checking the fonts installed on this computer…" = "Kontrollerar vilka typsnitt som är installerade på den här datorn…" @@ -332,6 +336,7 @@ name = "Svenska" "Copied as PNG" = "Kopierat som PNG" "Copied as SVG" = "Kopierat som SVG" "Copied as text" = "Kopierat som text" +"Copied comments for AI" = "Kommentarerna har kopierats för AI" "Copied {value}" = "Kopierade {value}" "Copy" = "Kopiera" "Copy as" = "Kopiera som" @@ -341,6 +346,7 @@ name = "Svenska" "Copy code" = "Kopiera kod" "Copy effect" = "Kopiera effekt" "Copy fill" = "Kopiera fyllning" +"Copy for AI" = "Kopiera för AI" "Copy link" = "Kopiera länk" "Copy stroke" = "Kopiera kontur" "Copy the {label}" = "Kopiera {label}" @@ -369,6 +375,7 @@ name = "Svenska" "Create style from the selected text" = "Skapa stil från den markerade texten" "Created" = "Skapad" "Crop" = "Beskär" +"Crop image" = "Beskär bild" "Crop marks" = "Skärmärken" "Ctrl+arrows" = "Ctrl+piltangenter" "Ctrl+click" = "Ctrl+klick" @@ -448,6 +455,8 @@ name = "Svenska" "Dots" = "Punkter" "Dotted" = "Prickad" "Down" = "Ned" +"Download" = "Hämta" +"Downloading… {percent}%" = "Hämtar… {percent} %" "Drafts" = "Utkast" "Drag the stroke to add a width point" = "Dra i konturen för att lägga till en breddpunkt" "Draw" = "Rita" @@ -534,6 +543,7 @@ name = "Svenska" "Fetching from the server…" = "Hämtar från servern…" "FigCraft couldn't open its window: {error}" = "FigCraft kunde inte öppna sitt fönster: {error}" "FigCraft doesn't support every feature of these files yet. The notes list what was left out or changed in the imported copy; the original file is unchanged." = "FigCraft stöder ännu inte alla funktioner i de här filerna. Anteckningarna visar vad som utelämnades eller ändrades i den importerade kopian; originalfilen är oförändrad." +"FigCraft stopped because of an internal error." = "FigCraft stoppades på grund av ett internt fel." "FigCraft {version} is currently the newest version available." = "FigCraft {version} är den senaste tillgängliga versionen." "File" = "Fil" "File name" = "Filnamn" @@ -592,6 +602,7 @@ name = "Svenska" "Frame selection" = "Rama in markering" "Frame tools" = "Frame-verktyg" "Frames" = "Frames" +"Free fonts come from Google Fonts and Noto, through jsDelivr and GitHub. Each file is checked against the font index before it is installed into the font folder the Craft apps share. Nothing is downloaded until you allow it." = "Fria typsnitt kommer från Google Fonts och Noto, via jsDelivr och GitHub. Varje fil kontrolleras mot typsnittsregistret innan den installeras i den typsnittsmapp som Craft-apparna delar. Ingenting hämtas förrän du tillåter det." "From the library \"{name}\": read-only" = "Från biblioteket \"{name}\": skrivskyddad" "From {name}" = "Från {name}" "From {server}" = "Från {server}" @@ -607,6 +618,7 @@ name = "Svenska" "Go to {name}" = "Gå till {name}" "Grab bag" = "Blandat" "Gradient" = "Gradient" +"Graph paper" = "Rutat papper" "Gray" = "Grå" "Green" = "Grön" "Greyscale components and two example screens to sketch a flow with" = "Komponenter i gråskala och två exempelskärmar att skissa ett flöde med" @@ -721,6 +733,7 @@ name = "Svenska" "Join with a code" = "Anslut med en kod" "Joined" = "Ansluten" "Joined {file}" = "Ansluten till {file}" +"Joining live session…" = "Ansluter till livesession…" "Joining…" = "Ansluter…" "Join|stroke" = "Hörntyp" "Jul" = "jul" @@ -890,6 +903,7 @@ name = "Svenska" "No color styles" = "Inga färgformatmallar" "No colour variables fit here." = "Inga färgvariabler passar här." "No colour variables yet. Create them in Local variables." = "Inga färgvariabler än. Skapa dem i Lokala variabler." +"No comments to copy" = "Inga kommentarer att kopiera" "No comments yet" = "Inga kommentarer än" "No compatibility warnings reported" = "Inga kompatibilitetsvarningar rapporterades" "No components yet. Select layers and press {keys}." = "Inga komponenter än. Markera lager och tryck på {keys}." @@ -938,11 +952,10 @@ name = "Svenska" "Not bound" = "Inte kopplad" "Not in a live session" = "Inte i en livesession" "Not in this font" = "Finns inte i det här typsnittet" +"Not now" = "Inte nu" "Not properties, kept as written: {names}" = "Inte egenskaper, behölls som de skrevs: {names}" -"Not read yet" = "Inte läst än" -"Check for updates" = "Sök efter uppdateringar" "Not checked yet" = "Inte kontrollerat än" -"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Den här filen använder teambibliotek på servrar som du inte har använt här. De kontaktas inte förrän du kontrollerar dem." +"Not read yet" = "Inte läst än" "Not shared" = "Inte delad" "Note {n}" = "Anteckning {n}" "Nothing changed since this version." = "Ingenting har ändrats sedan den här versionen." @@ -1140,6 +1153,9 @@ name = "Svenska" "Regular expression" = "Reguljärt uttryck" "Relative to" = "Relativt till" "Relative to your system's display scaling (100% is the system's own size). Document zoom is separate." = "Relativt systemets skalning av skärmen (100 % är systemets egen storlek). Dokumentets zoom är separat." +"Reload the page to get back the unsaved files the browser had room for." = "Läs in sidan igen för att få tillbaka de osparade filer som webbläsaren hade plats för." +"Reload the page to get your unsaved files back." = "Läs in sidan igen för att få tillbaka dina osparade filer." +"Reload the page to start again." = "Läs in sidan igen för att börja om." "Remove" = "Ta bort" "Remove all" = "Ta bort alla" "Remove annotation" = "Ta bort anteckning" @@ -1194,6 +1210,7 @@ name = "Svenska" "Reset adjustments" = "Återställ justeringar" "Reset all changes" = "Återställ alla ändringar" "Reset all overrides on this layer" = "Återställ alla overrides på det här lagret" +"Reset grid" = "Återställ rutnät" "Reset overrides" = "Återställ overrides" "Reset slot" = "Återställ plats" "Reset this override" = "Återställ den här overriden" @@ -1422,6 +1439,7 @@ name = "Svenska" "Stroke style" = "Konturstil" "Stroke weight" = "Konturtjocklek" "Strokes" = "Konturer" +"Strong line every" = "Kraftig linje var" "Style" = "Stil" "Styles" = "Stilar" "Stylistic set 1" = "Stiluppsättning 1" @@ -1486,6 +1504,7 @@ name = "Svenska" "This branch was merged or archived." = "Den här grenen har sammanfogats eller arkiverats." "This computer only" = "Endast den här datorn" "This computer remembers the owner token, so the file can be managed from the home screen's Online list. Copy it to manage the file from somewhere else: it is a password for this file and won't be shown again." = "Den här datorn kommer ihåg ägartoken, så filen kan hanteras från startskärmens onlinelista. Kopiera den om du vill hantera filen från en annan plats: den fungerar som ett lösenord för filen och visas inte igen." +"This file uses team libraries on servers you haven't used here. They aren't contacted until you check them." = "Den här filen använder teambibliotek på servrar som du inte har använt här. De kontaktas inte förrän du kontrollerar dem." "This page keeps the owner token only while it is open. Copy it to manage the file later: it is a password for this file and won't be shown again." = "Den här sidan behåller ägartoken bara så länge den är öppen. Kopiera den om du vill hantera filen senare: den fungerar som ett lösenord för filen och visas inte igen." "This condition is edited as an expression" = "Det här villkoret redigeras som ett uttryck" "This file" = "Den här filen" @@ -1496,6 +1515,7 @@ name = "Svenska" "This frame is new on the branch." = "Den här framen är ny på grenen." "This frame isn't on the branch." = "Den här framen finns inte på grenen." "This link opens a website outside the prototype:" = "Den här länken öppnar en webbplats utanför prototypen:" +"This link opens your mail app to write a message:" = "Den här länken öppnar din e-postapp för att skriva ett meddelande:" "This server is open to the public: no key needed." = "Servern är öppen för alla: ingen nyckel behövs." "This server needs its key: add ?key=… to the address (`figcraft-server key` prints it on the server's machine)." = "Servern kräver sin nyckel: lägg till ?key=… i adressen (`figcraft-server key` skriver ut den på serverns dator)." "This server takes files only with its key: see Advanced." = "Servern tar bara emot filer med sin nyckel: se Avancerat." @@ -1651,6 +1671,7 @@ name = "Svenska" "Your copy stays open; the file stays on the server" = "Din kopia förblir öppen; filen ligger kvar på servern" "Your name" = "Ditt namn" "Your system asks for reduced motion: animations stay off." = "Systemet begär minskad rörelse: animeringar förblir avstängda." +"Your unsaved files were too large for the browser to keep." = "Dina osparade filer var för stora för att webbläsaren skulle kunna behålla dem." "Z" = "Z" "Z to A" = "Ö till A" "Zoom" = "Zoom" @@ -1718,12 +1739,15 @@ name = "Svenska" "invalid internal .fig text path data was ignored" = "ogiltiga interna .fig-data för text på bana ignorerades" "invalid or oversized vector networks import as outlines" = "ogiltiga eller för stora vektornätverk importeras som former" "invalid or oversized vector path data was skipped" = "ogiltiga eller för stora vektorbanedata hoppades över" +"invalid vector region fill paint list; layer fill used" = "ogiltig lista över fyllningsfärger för vektorregion; lagrets fyllning används" +"invalid vector region paints were skipped" = "ogiltiga färger i vektorregioner hoppades över" "just now" = "nyss" "key triggers without a supported key were skipped" = "tangentutlösare utan en tangent som stöds hoppades över" "key up triggers import as key presses" = "utlösare för släppt tangent importeras som tangenttryckningar" "layers without a position were placed at their parent's origin" = "lager utan position placerades i det överordnade objektets origo" "layers without a type were skipped" = "lager utan typ hoppades över" "legacy image filter container is unsupported; filter conversion was skipped" = "den äldre bildfilterbehållaren stöds inte; filterkonverteringen hoppades över" +"linked vector region fill styles keep resolved appearance; live style links were left out" = "länkade fyllningsstilar i vektorregioner behåller sitt beräknade utseende; levande stillänkar utelämnades" "locked" = "låst" "malformed image filters container was skipped" = "en felaktig bildfilterbehållare hoppades över" "malformed image paintFilter container was skipped" = "en felaktig paintFilter-behållare för bild hoppades över" @@ -1731,6 +1755,10 @@ name = "Svenska" "no bleed" = "inget utfall" "nobody else here yet" = "ingen annan här än" "non-default slot settings are not represented and were skipped" = "fackinställningar som avviker från standard återges inte och hoppades över" +"note or shape generated child has invalid bounds; child skipped" = "ett underobjekt som genererats av en anteckning eller form har ogiltiga gränser; underobjektet hoppades över" +"note or shape generated child is missing its style or bounds; child skipped" = "ett underobjekt som genererats av en anteckning eller form saknar stil eller gränser; underobjektet hoppades över" +"note or shape generated children exceed the layer or nesting limit; remaining children skipped" = "underobjekt som genererats av en anteckning eller form överskrider gränsen för lager eller nästling; återstående underobjekt hoppades över" +"note or shape generated layout is missing or unsupported; using its outer frame" = "layouten som genererats av en anteckning eller form saknas eller stöds inte; dess yttre frame används" "now" = "nu" "offer files to save if it asks to (you choose where each one goes)" = "erbjuda filer att spara om den ber om det (du väljer var varje fil hamnar)" "only in a text style or variable" = "endast i en textstil eller variabel" @@ -1754,6 +1782,7 @@ name = "Svenska" "set-variable actions with values that don't fit the variable were skipped" = "åtgärder för att ange variabel med värden som inte passar variabeln hoppades över" "set-variable actions without a value were skipped" = "åtgärder för att ange variabel utan värde hoppades över" "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "åtgärder för att ange variabelläge på samlingar eller lägen som inte finns i variabel-JSON:en hoppades över" +"shape generated outline is missing or damaged; using its rectangle" = "konturen som genererats av en form saknas eller är skadad; dess rektangel används" "slot content assignment target is missing or is not slot content" = "målet för tilldelningen av fackinnehåll saknas eller är inte fackinnehåll" "slot content assignment uses the empty GUID sentinel and was skipped" = "tilldelningen av fackinnehåll använder det tomma GUID:t som markörvärde och hoppades över" "slot layers without a matching slot property were imported as frames" = "facklager utan en motsvarande fackegenskap importerades som frames" @@ -1779,10 +1808,13 @@ name = "Svenska" "variable modes of collections that aren't in the file were skipped" = "variabellägen för samlingar som inte finns i filen hoppades över" "variable modes of collections that aren't in the variables JSON were skipped" = "variabellägen för samlingar som inte finns i variabel-JSON:en hoppades över" "variable values that don't match their type (or composed colours) were skipped" = "variabelvärden som inte matchar sin typ (eller sammansatta färger) hoppades över" +"variable-bound vector region paints keep resolved appearance; live bindings were left out" = "variabelbundna färger i vektorregioner behåller sitt beräknade utseende; levande bindningar utelämnades" "variables of unknown types were skipped" = "variabler av okända typer hoppades över" "variables whose collection isn't in the variables JSON were skipped" = "variabler vars samling inte finns i variabel-JSON:en hoppades över" "variables without readable values were skipped" = "variabler utan läsbara värden hoppades över" "vector points with unknown stroke joins use the layer join" = "vektorpunkter med okända hörntyper använder lagrets hörntyp" +"vector region paint data exceeded import limits and was skipped" = "färgdata för vektorregion överskred importgränserna och hoppades över" +"vector region references a missing fill style; layer fill used" = "vektorregionen refererar till en fyllningsstil som saknas; lagrets fyllning används" "vectors without geometry are empty" = "vektorer utan geometri är tomma" "vectors without geometry are empty (export the file JSON with geometry=paths)" = "vektorer utan geometri är tomma (exportera fil-JSON:en med geometry=paths)" "very long text was cut to 1,048,576 characters" = "mycket lång text kortades till 1 048 576 tecken" @@ -1969,6 +2001,7 @@ name = "Svenska" "comment.next" = "Nästa kommentar" "comment.open" = "Öppna kommentar" "comment.people" = "Personer i kommentarer" +"comment.prompt" = "Kopiera kommentarer för AI" "comment.react" = "Reagera på kommentar" "comment.reply" = "Svara på kommentar" "comment.resolve" = "Lös kommentar" @@ -2050,6 +2083,7 @@ name = "Svenska" "file.exportFramesPdf" = "Exportera frames som PDF" "file.exportLayers" = "Exportera lager" "file.exportSvg" = "Exportera som SVG" +"file.fontReport" = "Inspektera dokumentets typsnitt" "file.import" = "Importera" "file.markSaved" = "Märk som sparad" "file.moveTab" = "Flytta flik" @@ -2195,10 +2229,10 @@ name = "Svenska" "pref.language" = "Gränssnittsspråk" "pref.nudge" = "Steglängd" "pref.numberKeysOpacity" = "Använd siffertangenterna för opacitet" -"pref.scrollWheelZoom" = "Zooma med scrollhjulet" "pref.publishServer" = "Server för delning online" "pref.reduceMotion" = "Minska rörelse" "pref.renameDuplicates" = "Byt namn på duplicerade lager" +"pref.scrollWheelZoom" = "Zooma med scrollhjulet" "pref.showDimensions" = "Visa dimensioner på objekt" "pref.startOnHome" = "Börja på Start" "pref.uiScale" = "Gränssnittsstorlek" @@ -2219,6 +2253,7 @@ name = "Svenska" "proto.interaction.clear" = "Ta bort alla interaktioner" "proto.interaction.remove" = "Ta bort interaktion" "proto.interaction.set" = "Redigera interaktion" +"proto.linkNotice" = "Visa meddelande innan en länk öppnas" "proto.present" = "Presentera" "proto.presentOptions" = "Presentationsalternativ" "proto.preview" = "Öppna prototypförhandsgranskning" @@ -2365,6 +2400,7 @@ name = "Svenska" "tool.star" = "Stjärna" "tool.sticky" = "Lapp" "tool.text" = "Text" +"tool.textOnPath" = "Text längs en bana" "transform.move" = "Flytta" "transform.moveTo" = "Flytta till" "transform.nudge" = "Flytta stegvis" From bc76665c65e759aa9342dba75d5e2619c32395c6 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Fri, 30 Oct 2026 15:58:05 +0800 Subject: [PATCH 19/34] M3: colour variables can be calculated from others, faded or mixed per mode (#1280) A colour variable's value in a mode can now be {"alpha": {"variable", "opacity"}} - that variable's colour with its alpha replaced - or {"mix": {"from", "to", "t"}}, a per-channel blend of two colour variables or literals over the stored values (in the document's colour profile), alpha included. Calculations resolve lazily like aliases, so bound layers, mode switches, instances and undo follow their sources; they compose, and a loop, or a calculation that reads more than 32 variables in its collection's modes (other collections in their default modes), is refused when it is set. Operands are stored by id and read by exact id, as stored references are. Files keep them; REST, CSS, Penpot and .fig exports write the colour they show in each mode; deleting a source leaves its readers on the colour they showed; library import and swap re-point their operands. The Variables panel shows them with their formula and can swap one for an alias or detach it to its colour. --- crates/doc/src/colors.rs | 16 +- crates/doc/src/proto_expr.rs | 2 +- crates/doc/src/styles.rs | 201 +++++++- crates/doc/src/tests.rs | 236 ++++++--- crates/engine/src/assets/library.rs | 42 +- crates/engine/src/assets/library_swap.rs | 21 +- .../engine/src/assets/library_swap/tests.rs | 44 +- crates/engine/src/cmd/file.rs | 2 +- crates/engine/src/cmd/styles.rs | 44 +- crates/engine/src/cmd/variables.rs | 162 +++++- crates/engine/src/cmd/variables_io.rs | 11 +- crates/engine/src/lib.rs | 2 + crates/engine/src/library_variables_tests.rs | 61 +++ crates/engine/src/paint_tests.rs | 2 +- .../engine/src/variables_behaviour_tests.rs | 2 +- crates/engine/src/variables_colour_tests.rs | 473 ++++++++++++++++++ crates/format/src/fig/assets_tests.rs | 32 +- crates/format/src/fig/export/assets.rs | 10 +- crates/format/src/lib.rs | 3 + crates/format/src/penpot/export.rs | 10 +- crates/format/src/penpot/export_tests.rs | 68 ++- crates/format/src/rest/proto.rs | 2 +- crates/format/src/rest/variables.rs | 12 +- crates/ui-egui/locales/en.toml | 2 + crates/ui-egui/src/dialogs/variables_panel.rs | 79 ++- crates/ui-egui/src/panels/proto_editor.rs | 3 +- crates/ui-egui/src/panels/proto_panel.rs | 5 +- docs/components-parity.md | 7 +- docs/file-format-parity.md | 9 +- docs/mcp.md | 11 +- 30 files changed, 1361 insertions(+), 213 deletions(-) create mode 100644 crates/engine/src/variables_colour_tests.rs diff --git a/crates/doc/src/colors.rs b/crates/doc/src/colors.rs index a3168f8da..f4681601b 100644 --- a/crates/doc/src/colors.rs +++ b/crates/doc/src/colors.rs @@ -89,8 +89,20 @@ impl Style { impl Variables { pub fn for_each_color_mut(&mut self, f: &mut ColorVisitor) { for v in self.variables.iter_mut().flat_map(|v| v.values.values_mut()) { - if let VariableValue::Color(c) = v { - f(c); + match v { + VariableValue::Color(c) => f(c), + // A mix's literals are stored colours too (a calculation's alpha is just a + // number): a profile conversion that left them behind would blend converted + // operands with unconverted ones, and the bound layers would silently shift + // relative to the rest of the document. + VariableValue::ColorMix { from, to, .. } => { + for op in [from, to] { + if let crate::ColorOperand::Literal { literal } = op { + f(literal); + } + } + } + _ => {} } } } diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index e40d3ef7a..ca074d277 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -234,7 +234,7 @@ fn as_text(v: &VariableValue) -> Option { match v { VariableValue::String(s) => Some(s.clone()), VariableValue::Number(_) | VariableValue::Boolean(_) => Some(v.display()), - VariableValue::Color(_) | VariableValue::Alias(_) => None, + VariableValue::Color(_) | VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None, } } diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 9f8dbdb7e..a66a0935d 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -82,6 +82,51 @@ pub enum VariableValue { String(String), Boolean(bool), Alias(VariableId), + /// The referenced colour with its alpha channel replaced (a "50% variant"). Replace rather + /// than scale: applying it twice is the same value (stable under editing the opacity or the + /// chain it reads), and "at 50%" reads as the design ask. + ColorAlpha { + variable: VariableId, + opacity: f32, + }, + /// A linear blend of two colour operands at `t` (0 = `from`, 1 = `to`). The space is the stored + /// one — non-premultiplied components in the document's colour profile, alpha blended like the + /// channels — deliberately not perceptual: the values a hex panel can show blend exactly, and + /// 8-bit rounding stays where it already was (display and export) rather than being done twice. + /// The trade-off: converting the profile keeps the ends exact but moves the colours in between. + ColorMix { + from: ColorOperand, + to: ColorOperand, + t: f32, + }, +} + +/// One operand of a colour calculation: a variable (resolved when read, so it follows its own modes, +/// aliases and calculations) or a fixed colour. Tagged by key, not by type: a bare string is a +/// colour name just as easily as a variable name. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum ColorOperand { + Variable { variable: VariableId }, + Literal { literal: Color }, +} + +impl ColorOperand { + /// The variable it reads, if it reads one. + pub fn variable(&self) -> Option<&VariableId> { + match self { + ColorOperand::Variable { variable } => Some(variable), + ColorOperand::Literal { .. } => None, + } + } + + /// The variable id it reads, for a remap (library import and swap re-point ids). + pub fn variable_mut(&mut self) -> Option<&mut VariableId> { + match self { + ColorOperand::Variable { variable } => Some(variable), + ColorOperand::Literal { .. } => None, + } + } } impl VariableValue { @@ -101,16 +146,53 @@ impl VariableValue { VariableValue::Number(_) => Some(VariableType::Number), VariableValue::String(_) => Some(VariableType::String), VariableValue::Boolean(_) => Some(VariableType::Boolean), - VariableValue::Alias(_) => None, + // References have no type of their own: what they read may be missing or loop, so an + // expression can't take one as a typed literal. + VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None, } } pub fn display(&self) -> String { + self.display_with(&|id| id.to_string()) + } + + /// As [`Self::display`], with the variables it reads shown by `name` (the panel shows their + /// names where ids would mean nothing to a reader). + pub fn display_with(&self, name: &dyn Fn(&str) -> String) -> String { + let percent = |x: f32| (x * 100.0).round(); + let operand = |op: &ColorOperand| match op { + ColorOperand::Variable { variable } => name(variable), + // As the panel shows a colour (no `#`), with alpha digits when it isn't opaque. + ColorOperand::Literal { literal } => literal.to_css_hex().trim_start_matches('#').to_string(), + }; match self { VariableValue::Color(c) => c.to_hex(), VariableValue::Number(n) => crate::text::trim_num(*n), VariableValue::String(s) => s.clone(), VariableValue::Boolean(b) => b.to_string(), - VariableValue::Alias(a) => format!("→ {a}"), + VariableValue::Alias(a) => format!("→ {}", name(a)), + VariableValue::ColorAlpha { variable, opacity } => format!("α {}% · {}", percent(*opacity), name(variable)), + VariableValue::ColorMix { from, to, t } => format!("{} + {} @ {}%", operand(from), operand(to), percent(*t)), + } + } + + /// The variable ids this value reads (its alias target, or its calculation's variable operands; + /// literal operands read nothing). Used for reference walks: cycles, deletion, library remaps. + pub fn references(&self) -> Vec<&VariableId> { + match self { + VariableValue::Alias(a) => vec![a], + VariableValue::ColorAlpha { variable, .. } => vec![variable], + VariableValue::ColorMix { from, to, .. } => from.variable().into_iter().chain(to.variable()).collect(), + _ => Vec::new(), + } + } + + /// [`Self::references`], to re-point: library import and swap move them to other ids. + pub fn references_mut(&mut self) -> Vec<&mut VariableId> { + match self { + VariableValue::Alias(a) => vec![a], + VariableValue::ColorAlpha { variable, .. } => vec![variable], + VariableValue::ColorMix { from, to, .. } => from.variable_mut().into_iter().chain(to.variable_mut()).collect(), + _ => Vec::new(), } } } @@ -283,19 +365,114 @@ impl Variables { self.collections.iter().find(|c| c.id == id) } + /// The distinct variables one resolve may consult. Calculations read trees, not chains, so the + /// cap counts variables rather than hops; with the memo each one is resolved at most once per + /// call, so the work is bounded by this however the graph is shaped (a diamond re-sharing a + /// subtree does not walk it twice). + pub const RESOLVE_LIMIT: usize = 32; + /// Resolve `id` in `modes` (collection → chosen mode; default mode otherwise), following aliases - /// (bounded, cycles return None). + /// and evaluating colour calculations (bounded, cycles and over-deep graphs return None). pub fn resolve(&self, id: &str, modes: &BTreeMap) -> Option { - let mut cur = id.to_string(); - for _ in 0..32 { - let var = self.get_by_id(&cur)?; - let coll = self.collection_by_id(&var.collection)?; - let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?; - match var.values.get(mode).or_else(|| var.values.values().next())? { - VariableValue::Alias(next) => cur = next.clone(), - v => return Some(v.clone()), + self.resolve_memo(id, modes, &mut BTreeMap::new()) + } + + /// `val`, a value of `var` shown in `mode`, as a format without colour calculations writes it: + /// a calculation becomes the colour it evaluates to there (other collections in their default + /// modes), anything else stays as given. None: the calculation doesn't resolve. + pub fn flattened(&self, var: &Variable, mode: &str, val: &VariableValue) -> Option { + self.flattened_as(&var.id, &var.collection, mode, val) + } + + /// [`Self::flattened`] for a value of variable `id` in `collection`, which need not exist yet. + pub fn flattened_as(&self, id: &str, collection: &str, mode: &str, val: &VariableValue) -> Option { + match val { + VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => { + // `id` is in flight from the start, so a calculation that reads it back resolves to + // nothing, as it would through `resolve`. + let mut done = BTreeMap::from([(id.to_string(), None)]); + self.eval(val, &BTreeMap::from([(collection.to_string(), mode.to_string())]), &mut done) } + other => Some(other.clone()), + } + } + + /// The stored value of `id` in its collection's current mode (the mode chosen when it has a + /// value for it, else the default mode, else any value). + fn value_in(&self, id: &str, modes: &BTreeMap) -> Option<&VariableValue> { + let var = self.get_by_id(id)?; + let coll = self.collection_by_id(&var.collection)?; + let mode = modes.get(&coll.id).filter(|m| var.values.contains_key(*m)).or_else(|| coll.default_mode())?; + var.values.get(mode).or_else(|| var.values.values().next()) + } + + fn resolve_memo( + &self, + id: &str, + modes: &BTreeMap, + done: &mut BTreeMap>, + ) -> Option { + if let Some(hit) = done.get(id) { + return hit.clone(); + } + if done.len() >= Self::RESOLVE_LIMIT { + return None; + } + // Mark as being resolved before recursing: meeting it again mid-flight is a cycle, and the + // stored None unwinds it. Overwritten with the outcome below (a cached failure stays cached: + // a variable resolves the same however deep it is reached from — the limit only bails, it + // does not cache). + done.insert(id.to_string(), None); + let out = self.value_in(id, modes).and_then(|val| self.eval(val, modes, done)); + done.insert(id.to_string(), out.clone()); + out + } + + /// A stored value reduced to a plain one (aliases walked, calculations evaluated). + fn eval( + &self, + val: &VariableValue, + modes: &BTreeMap, + done: &mut BTreeMap>, + ) -> Option { + match val { + VariableValue::Alias(next) => self.resolve_memo(next, modes, done), + VariableValue::ColorAlpha { variable, opacity } => match self.resolve_memo(variable, modes, done)? { + VariableValue::Color(c) => Some(VariableValue::Color(c.with_alpha(*opacity))), + _ => None, + }, + VariableValue::ColorMix { from, to, t } => { + let a = self.operand_color(from, modes, done)?; + let b = self.operand_color(to, modes, done)?; + Some(VariableValue::Color(mix_colors(&a, &b, *t))) + } + other => Some(other.clone()), + } + } + + /// A colour operand: a literal as stored, or a variable that must itself resolve to a colour. + fn operand_color( + &self, + op: &ColorOperand, + modes: &BTreeMap, + done: &mut BTreeMap>, + ) -> Option { + match op { + ColorOperand::Literal { literal } => Some(literal.sanitized()), + ColorOperand::Variable { variable } => match self.resolve_memo(variable, modes, done)? { + VariableValue::Color(c) => Some(c), + _ => None, + }, } - None } } + +/// Linear per-channel interpolation (`t` clamped and NaN-guarded: a command validates, a crafted +/// file must still resolve to a sane colour). +fn mix_colors(a: &Color, b: &Color, t: f32) -> Color { + let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; + // Weighted at both ends so t = 0 and t = 1 give each operand exactly: `x + (y - x) * t` can + // miss `y` by a rounding step (from 1.0 to 1e-8 it lands on 0). + let lerp = |x: f32, y: f32| x * (1.0 - t) + y * t; + Color::new(lerp(a.r, b.r), lerp(a.g, b.g), lerp(a.b, b.b), lerp(a.a, b.a)) +} diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 5b1cf23c4..1bf115078 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -249,64 +249,14 @@ fn vector_network_paths() { #[test] fn variables_resolve_modes_and_aliases() { - let mut v = Variables::default(); - v.collections.push(Collection { - id: "c1".into(), - name: "Theme".into(), - modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }], - extends: None, - }); - let mut vals = std::collections::BTreeMap::new(); - vals.insert("light".to_string(), VariableValue::Color(Color::WHITE)); - vals.insert("dark".to_string(), VariableValue::Color(Color::BLACK)); - v.variables.push(Variable { - id: "bg".into(), - name: "bg".into(), - collection: "c1".into(), - kind: VariableType::Color, - values: vals, - description: String::new(), - scopes: None, - hidden_from_publishing: false, - extends: None, - overrides: Default::default(), - code_syntax: Default::default(), - }); - let mut alias = std::collections::BTreeMap::new(); - alias.insert("light".to_string(), VariableValue::Alias("bg".into())); - v.variables.push(Variable { - id: "surface".into(), - name: "surface".into(), - collection: "c1".into(), - kind: VariableType::Color, - values: alias, - description: String::new(), - scopes: None, - hidden_from_publishing: false, - extends: None, - overrides: Default::default(), - code_syntax: Default::default(), - }); + let mut v = vars_with_colours(&[("bg", VariableValue::Color(Color::WHITE), VariableValue::Color(Color::BLACK))]); + add(&mut v, "surface", VariableValue::Alias("bg".into())); let mut modes = std::collections::BTreeMap::new(); assert_eq!(v.resolve("surface", &modes), Some(VariableValue::Color(Color::WHITE))); modes.insert("c1".to_string(), "dark".to_string()); assert_eq!(v.resolve("bg", &modes), Some(VariableValue::Color(Color::BLACK))); // Cycle. - let mut cyc = std::collections::BTreeMap::new(); - cyc.insert("light".to_string(), VariableValue::Alias("loop".into())); - v.variables.push(Variable { - id: "loop".into(), - name: "loop".into(), - collection: "c1".into(), - kind: VariableType::Color, - values: cyc, - description: String::new(), - scopes: None, - hidden_from_publishing: false, - extends: None, - overrides: Default::default(), - code_syntax: Default::default(), - }); + add(&mut v, "loop", VariableValue::Alias("loop".into())); assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None); } @@ -319,19 +269,7 @@ fn variables_resolve_stored_ids_never_by_name() { modes: vec![Mode { id: "m".into(), name: "Mode 1".into() }], extends: None, }); - let var = |id: &str, name: &str, value: VariableValue| Variable { - id: id.into(), - name: name.into(), - collection: "c1".into(), - kind: VariableType::Color, - values: std::collections::BTreeMap::from([("m".to_string(), value)]), - description: String::new(), - scopes: None, - hidden_from_publishing: false, - extends: None, - overrides: Default::default(), - code_syntax: Default::default(), - }; + let var = |id: &str, name: &str, value: VariableValue| colour_var(id, name, &[("m", value)]); let red = Color::from_hex("#FF0000").unwrap(); let blue = Color::from_hex("#0D99FF").unwrap(); // Named like the next variable's id, and like an id that no longer exists. @@ -722,3 +660,169 @@ fn stored_variable_ids_resolve_only_their_exact_collection() { vars.collections.retain(|c| c.id != "c"); assert_eq!(vars.resolve("n", &Default::default()), None); } + +// ——— colour calculations ——— + +/// A colour variable of collection `c1` with `values` by mode id. +fn colour_var(id: &str, name: &str, values: &[(&str, VariableValue)]) -> Variable { + Variable { + id: id.into(), + name: name.into(), + collection: "c1".into(), + kind: VariableType::Color, + values: values.iter().map(|(m, v)| (m.to_string(), v.clone())).collect(), + description: String::new(), + scopes: None, + hidden_from_publishing: false, + extends: None, + overrides: Default::default(), + code_syntax: Default::default(), + } +} + +/// `c1` with Light/Dark (Light is the default mode) and colour variables `("id", light, dark)`. +fn vars_with_colours(cols: &[(&str, VariableValue, VariableValue)]) -> Variables { + let mut v = Variables::default(); + v.collections.push(Collection { + id: "c1".into(), + name: "C".into(), + modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }], + extends: None, + }); + for (id, light, dark) in cols { + v.variables.push(colour_var(id, id, &[("light", light.clone()), ("dark", dark.clone())])); + } + v +} + +/// Adds `id` with a value in the Light mode only. +fn add(v: &mut Variables, id: &str, light: VariableValue) { + v.variables.push(colour_var(id, id, &[("light", light)])); +} + +fn fade(source: &str, opacity: f32) -> VariableValue { + VariableValue::ColorAlpha { variable: source.into(), opacity } +} +fn mix(from: ColorOperand, to: ColorOperand, t: f32) -> VariableValue { + VariableValue::ColorMix { from, to, t } +} +fn lit(hex: &str) -> ColorOperand { + ColorOperand::Literal { literal: Color::from_hex_or_name(hex).unwrap() } +} +fn src(id: &str) -> ColorOperand { + ColorOperand::Variable { variable: id.into() } +} +fn red() -> VariableValue { + VariableValue::Color(Color::from_rgb8_const(255, 0, 0)) +} +fn resolved(v: &Variables, id: &str, mode: Option<&str>) -> Color { + let modes: std::collections::BTreeMap = + mode.map(|m| std::collections::BTreeMap::from([("c1".to_string(), m.to_string())])).unwrap_or_default(); + match v.resolve(id, &modes) { + Some(VariableValue::Color(c)) => c, + other => panic!("{id} did not resolve to a colour: {other:?}"), + } +} + +#[test] +fn an_alpha_calculation_replaces_alpha_and_follows_its_source() { + let mut v = vars_with_colours(&[("base", red(), VariableValue::Color(Color::from_rgb8_const(0, 0, 255)))]); + add(&mut v, "fade", fade("base", 0.5)); + assert_eq!(resolved(&v, "fade", None).to_rgba8(), [255, 0, 0, 128], "default mode: red at half alpha (replace)"); + assert_eq!(resolved(&v, "fade", Some("dark")).to_rgba8(), [0, 0, 255, 128], "the source's value in the chosen mode is what fades"); +} + +#[test] +fn an_alpha_calculation_replaces_even_a_faded_sources_alpha() { + // Replace (not scale): a fade of a source that already has alpha lands on exactly the requested + // opacity, so re-editing the source's alpha cannot compound with the fade's. + let quarter = VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.25)); + let mut v = vars_with_colours(&[("base", quarter.clone(), quarter)]); + add(&mut v, "fade", fade("base", 0.5)); + assert_eq!(resolved(&v, "fade", None).a, 0.5, "not 0.25 * 0.5"); +} + +#[test] +fn an_alpha_calculation_reads_through_an_alias() { + let mut v = vars_with_colours(&[("base", red(), red())]); + add(&mut v, "mirror", VariableValue::Alias("base".into())); + add(&mut v, "faded", fade("mirror", 0.5)); + assert_eq!(resolved(&v, "faded", None).to_rgba8(), [255, 0, 0, 128]); +} + +#[test] +fn a_mix_blends_stored_components_with_exact_edges() { + let mut v = vars_with_colours(&[("base", red(), VariableValue::Color(Color::from_rgb8_const(0, 0, 255)))]); + add(&mut v, "t0", mix(src("base"), lit("#FFFFFF"), 0.0)); + add(&mut v, "t1", mix(src("base"), lit("#FFFFFF"), 1.0)); + add(&mut v, "grey", mix(lit("#000000"), lit("#FFFFFF"), 0.4)); + add(&mut v, "afade", mix(lit("#FF0000"), lit("#FF000000"), 0.25)); + assert_eq!(resolved(&v, "t0", None).to_rgba8(), [255, 0, 0, 255], "t=0 is exactly the first operand"); + assert_eq!(resolved(&v, "t1", None).to_rgba8(), [255, 255, 255, 255], "t=1 is exactly the second"); + assert_eq!(resolved(&v, "grey", None).to_rgba8(), [102, 102, 102, 255], "0.4 of white rounds to 102 (0x66)"); + assert_eq!(resolved(&v, "afade", None).to_rgba8(), [255, 0, 0, 191], "alpha blends like the channels (0.75 -> 0xBF)"); + assert_eq!(resolved(&v, "t0", Some("dark")).to_rgba8(), [0, 0, 255, 255], "the variable operand follows the mode"); + // Exact in the stored components too, not only after 8-bit rounding. + let faint = ColorOperand::Literal { literal: Color::new(1e-8, 0.0, 0.0, 1.0) }; + add(&mut v, "to_faint", mix(lit("#FF0000"), faint.clone(), 1.0)); + add(&mut v, "from_faint", mix(faint, lit("#FF0000"), 0.0)); + assert_eq!(resolved(&v, "to_faint", None).r, 1e-8, "t=1 is the second operand to the last bit"); + assert_eq!(resolved(&v, "from_faint", None).r, 1e-8, "t=0 is the first operand to the last bit"); +} + +#[test] +fn calculations_compose_and_a_crafted_cycle_terminates() { + let mut v = vars_with_colours(&[("base", red(), red())]); + add(&mut v, "fade", fade("base", 0.5)); + add(&mut v, "toast", mix(src("fade"), lit("#FFFFFF"), 0.0)); + assert_eq!(resolved(&v, "toast", None).to_rgba8(), [255, 0, 0, 128], "a calculation may read a calculation"); + // A hand-crafted cycle (commands refuse these) must unwind, never spin. + add(&mut v, "loop", mix(src("loop2"), lit("#FFFFFF"), 0.5)); + add(&mut v, "loop2", fade("loop", 0.5)); + assert_eq!(v.resolve("loop", &std::collections::BTreeMap::new()), None); +} + +#[test] +fn calculation_resolution_is_memoised_and_bounded() { + // A doubling tree: each level reads the one below twice. Memoised per resolve, that is + // one pass over the levels; unmemoised it is 2^23 walks (the time bound catches a regression). + let mut v = vars_with_colours(&[("leaf", red(), red())]); + let mut prev = "leaf".to_string(); + for i in 0..23 { + let id = format!("f{i}"); + add(&mut v, &id, mix(src(&prev), src(&prev), 0.5)); + prev = id; + } + let started = std::time::Instant::now(); + assert_eq!(resolved(&v, &prev, None).to_rgba8(), [255, 0, 0, 255], "the tree reads the leaf"); + assert!(started.elapsed().as_millis() < 200, "memoised: {:?}", started.elapsed()); + // A diamond: two fades of one base under one mix (shared subtrees each resolved once). + let quarter = VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.25)); + let mut d = vars_with_colours(&[("base", quarter.clone(), quarter)]); + add(&mut d, "fa", fade("base", 0.25)); + add(&mut d, "fb", fade("base", 0.75)); + add(&mut d, "dia", mix(src("fa"), src("fb"), 0.5)); + assert_eq!(resolved(&d, "dia", None).a, 0.5, "0.25 + (0.75 - 0.25) * 0.5"); + // One resolve reads at most 32 variables, however they are chained. + let mut long = vars_with_colours(&[("v0", red(), red())]); + for i in 1..=40 { + add(&mut long, &format!("v{i}"), fade(&format!("v{}", i - 1), 0.99)); + } + assert_eq!(resolved(&long, "v31", None).to_rgba8(), [255, 0, 0, 252], "32 variables still resolve"); + assert_eq!(long.resolve("v32", &std::collections::BTreeMap::new()), None, "a 33rd reads nothing"); +} + +#[test] +fn the_colour_walker_visits_calculation_literals() { + // Document::map_colors (profile conversion) must reach the colours stored inside a mix: + // leaving them behind would blend converted operands with unconverted ones. + let mut v = vars_with_colours(&[("base", red(), red())]); + add(&mut v, "mx", mix(lit("#804020"), src("base"), 0.5)); + let mut doc = Document::new("x"); + doc.variables = v; + doc.map_colors(|c| c.with_alpha(0.5)); + let mx = doc.variables.variables.iter().find(|x| x.id == "mx").unwrap(); + let VariableValue::ColorMix { from, .. } = &mx.values["light"] else { panic!("still the mix") }; + let ColorOperand::Literal { literal } = from else { panic!("literal operand") }; + assert_eq!(literal.a, 0.5, "the mix's stored literal was converted like every other colour"); +} diff --git a/crates/engine/src/assets/library.rs b/crates/engine/src/assets/library.rs index 94500eac2..2761d4502 100644 --- a/crates/engine/src/assets/library.rs +++ b/crates/engine/src/assets/library.rs @@ -18,7 +18,7 @@ use figcraft_doc::proto::Action; use figcraft_doc::{ Collection, CollectionId, Document, ImportedComponent, ImportedRecord, ImportedStyle, LibraryAsset, LibraryCollection, LibraryLink, LibraryManifest, LibraryStyle, LibraryVariable, Node, NodeId, NodeKind, PropDef, PublishedLibrary, Style, StyleKind, Variable, VariableId, - VariableValue, Variables, node_images, paint_images, used_images, + Variables, node_images, paint_images, used_images, }; use serde_json::Value; @@ -249,7 +249,7 @@ pub fn default_selection(doc: &Document, prev: Option<&LibraryManifest>) -> Sele } } -/// `seeds` (variable ids) and every variable they alias, transitively, by id. +/// `seeds` (variable ids) and every variable they alias or calculate from, transitively, by id. fn alias_closure(vars: &Variables, seeds: impl IntoIterator) -> BTreeSet { let mut out = BTreeSet::new(); let mut stack: Vec = seeds.into_iter().collect(); @@ -259,8 +259,8 @@ fn alias_closure(vars: &Variables, seeds: impl IntoIterator) continue; } for val in v.values.values() { - if let VariableValue::Alias(a) = val { - stack.push(a.clone()); + for r in val.references() { + stack.push(r.clone()); } } } @@ -758,15 +758,19 @@ pub fn import_variables( let Some(collection) = out.collections.get(&v.collection).cloned() else { continue }; let mut values = BTreeMap::new(); for (mode, val) in &v.values { - let val = match val { - // An alias to a variable the library doesn't have resolves to nothing: dropped - // (never pointed at a variable of this file). - VariableValue::Alias(a) => match src.get_by_id(a).and_then(|t| out.variables.get(&t.id)) { - Some(l) => VariableValue::Alias(l.clone()), - None => continue, - }, - other => other.clone(), - }; + // A value reading a variable the library doesn't have resolves to nothing: dropped + // (never pointed at a variable of this file), for aliases and calculations alike. + let mut val = val.clone(); + let remapped = val.references_mut().into_iter().all(|r| match src.get_by_id(r.as_str()).and_then(|t| out.variables.get(&t.id)) { + Some(here) => { + *r = here.clone(); + true + } + None => false, + }); + if !remapped { + continue; + } values.insert(mode.clone(), val); } let copy = Variable { id: local.clone(), collection, values, extends: None, overrides: Default::default(), ..v.clone() }; @@ -1044,10 +1048,14 @@ pub fn prune(doc: &mut Document, library: &str) -> Result<()> { .flat_map(|n| n.bound.values().map(String::as_str).chain(action_variables(n))) .chain(doc.variables.variables.iter().filter_map(|v| v.extends.as_deref())) .map(str::to_string) - .chain(doc.variables.variables.iter().filter(|v| !is_remote_variable(doc, &v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x { - VariableValue::Alias(a) => Some(a.clone()), - _ => None, - })) + .chain( + doc.variables + .variables + .iter() + .filter(|v| !is_remote_variable(doc, &v.id)) + .flat_map(|v| v.values.values()) + .flat_map(|x| x.references().into_iter().cloned()), + ) .collect::>(); let used = alias_closure(&doc.variables, seeds); let unused: BTreeSet = diff --git a/crates/engine/src/assets/library_swap.rs b/crates/engine/src/assets/library_swap.rs index f4af1c34f..e3b312cf8 100644 --- a/crates/engine/src/assets/library_swap.rs +++ b/crates/engine/src/assets/library_swap.rs @@ -150,12 +150,15 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re // Variables: by collection and variable name and type; collections and modes by name. let coll_name = |d: &Document, id: &str| d.variables.collection_by_id(id).map(|c| c.name.clone()).unwrap_or_default(); - // Variables the document's own layers bind or its own variables alias. + // Variables the document's own layers bind or its own variables alias or calculate from. let remote: BTreeSet = doc.libraries.variables.iter().map(|r| r.local.clone()).collect(); - let aliases = doc.variables.variables.iter().filter(|v| !remote.contains(&v.id)).flat_map(|v| v.values.values()).filter_map(|x| match x { - VariableValue::Alias(a) => Some(a.clone()), - _ => None, - }); + let aliases = doc + .variables + .variables + .iter() + .filter(|v| !remote.contains(&v.id)) + .flat_map(|v| v.values.values()) + .flat_map(|x| x.references().into_iter().cloned()); let used: BTreeSet = doc.nodes().filter(|n| outside(doc, n.id)).flat_map(|n| n.bound.values().cloned()).chain(aliases).collect(); let var_recs: Vec = doc.libraries.variables.iter().filter(|r| r.library == from && used.contains(&r.local)).cloned().collect(); let lib_coll = |name: &str| to.doc.variables.collections.iter().find(|c| c.name == name && m.collection(&c.id).is_some_and(|x| x.published)); @@ -233,11 +236,9 @@ pub fn swap_library(doc: &mut Document, from: &str, to: &PublishedLibrary) -> Re .collect(); } for v in doc.variables.variables.iter_mut().filter(|v| !remote.contains(&v.id)) { - for val in v.values.values_mut() { - if let VariableValue::Alias(a) = val - && let Some(b) = vars.get(a) - { - *a = b.clone(); + for r in v.values.values_mut().flat_map(VariableValue::references_mut) { + if let Some(b) = vars.get(r.as_str()) { + *r = b.clone(); } } } diff --git a/crates/engine/src/assets/library_swap/tests.rs b/crates/engine/src/assets/library_swap/tests.rs index db769a0e6..d90996576 100644 --- a/crates/engine/src/assets/library_swap/tests.rs +++ b/crates/engine/src/assets/library_swap/tests.rs @@ -57,17 +57,23 @@ fn main_of(s: &Session, inst: NodeId) -> NodeId { } } -#[test] -fn swap_moves_instances_styles_and_variables_by_name() { - let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-{}", std::process::id()))); +/// A session with libraries A (blue, plus `extra_a` components) and B (green) enabled, files in a +/// scratch directory named by `tag`; returns the directory guard, the session and both library ids. +fn two_libraries(tag: &str, extra_a: &[&str]) -> (Scratch, Session, String, String) { + let dir = Scratch(std::env::temp_dir().join(format!("figcraft-libswap-{tag}-{}", std::process::id()))); std::fs::create_dir_all(&dir.0).unwrap(); let (pa, pb) = (dir.0.join("a.figcraft").to_string_lossy().into_owned(), dir.0.join("b.figcraft").to_string_lossy().into_owned()); - let a = library(&pa, "#0000ff", &["Only in A"]); + let a = library(&pa, "#0000ff", extra_a); let b = library(&pb, "#00ff00", &[]); - let mut s = Session::new(); ex(&mut s, "library.add", json!({"path": pa})); ex(&mut s, "library.add", json!({"path": pb})); + (dir, s, a, b) +} + +#[test] +fn swap_moves_instances_styles_and_variables_by_name() { + let (_dir, mut s, a, b) = two_libraries("main", &["Only in A"]); let inst = idv(&ex(&mut s, "library.place", json!({"library": a, "component": "Button", "x": 0, "y": 0}))); let label = s.doc().unwrap().node(inst).unwrap().children[0]; ex(&mut s, "text.set", json!({"id": label, "characters": "Buy"})); @@ -146,3 +152,31 @@ fn stored_variable_ids_keep_library_swap_matches_on_the_real_copies() { assert!(node.bound.values().all(|v| doc.libraries.variable_import(v).is_some_and(|r| r.library == b))); assert!(node.var_modes.keys().all(|c| doc.libraries.collection_import(c).is_some_and(|r| r.library == b))); } + +/// A local colour calculation reading a library variable moves to the other library's variable of +/// the same name, as a binding does. +#[test] +fn swap_repoints_colour_calculations() { + let (_dir, mut s, a, b) = two_libraries("calc", &[]); + let accent = ex(&mut s, "library.importVariable", json!({"library": a, "variable": "accent"}))["variable"].clone(); + ex(&mut s, "variable.createCollection", json!({"name": "App"})); + ex( + &mut s, + "variable.create", + json!({"collection": "App", "name": "veil", "type": "color", "value": {"alpha": {"variable": accent, "opacity": 0.5}}}), + ); + let wash = json!({"mix": {"from": {"variable": accent}, "to": {"literal": "#ffffff"}, "t": 0.5}}); + ex(&mut s, "variable.create", json!({"collection": "App", "name": "wash", "type": "color", "value": wash})); + + let r = ex(&mut s, "library.swap", json!({"from": a, "to": b})); + assert_eq!(r["variables"].as_u64(), Some(1), "{r}"); + let doc = s.doc().unwrap(); + let vars = &doc.variables; + let shown = |name: &str| match vars.resolve(&vars.get(name).unwrap().id, &Default::default()) { + Some(VariableValue::Color(c)) => c.to_css_hex(), + other => panic!("{name}: {other:?}"), + }; + assert_eq!((shown("veil"), shown("wash")), ("#00FF0080".to_string(), "#80FF80".to_string())); + let reads: Vec<&VariableId> = ["veil", "wash"].iter().flat_map(|n| vars.get(n).unwrap().values.values()).flat_map(|v| v.references()).collect(); + assert!(reads.iter().all(|r| doc.libraries.variable_import(r).is_some_and(|i| i.library == b)), "{reads:?}"); +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 4939bc2c6..0cfdd1ffa 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -203,7 +203,7 @@ pub fn specs() -> Vec { spec!( "file.colorProfile", "Color profile", - "{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}", + "{profile?: srgb|displayP3, convert? (default true: convert colour values so colours keep their appearance — a colour calculation's mix keeps its ends exact and its colours in between move slightly; false: keep the values and reinterpret them)} without `profile`, returns the current one (and the app opens the dialog). Returns {profile, changed}", true, color_profile ), diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index b88e31591..ee7506da0 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -164,21 +164,21 @@ pub fn specs() -> Vec { spec!( "variable.create", "Create variable", - "{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?}", + "{collection, name, type: color|number|string|boolean, value (default mode) | values {modeName: value}, description?} a value may also be {alias: variable} (same type); a colour value may also be {alpha: {variable, opacity 0..1}} (that variable's colour with its alpha replaced by the opacity) or {mix: {from, to, t}} (operands {variable: variable} or {literal: colour}; t is 0..1, 0 gives from and 1 to; blended per channel over the stored values, in the document's colour profile, including alpha; converting the profile keeps a mix's ends exact and moves its colours in between slightly), each alone in its object", true, var_create ), spec!( "variable.set", "Set variable value", - "{variable, mode? (name or id; default first), value | {alias: variable} (same type, no loops)}", + "{variable, mode? (name or id; default first), value | {alias: variable} (same type, no loops) | {alpha} | {mix} (colour variables; shapes as in variable.create; no loops)}", true, var_set ), spec!( "variable.delete", "Delete variable", - "{variable} (bound layers keep their values; variables aliasing it take its values)", + "{variable} (bound layers keep their values; variables aliasing it take its values; colour calculations reading it keep the colour they show)", true, |s, p| { let v = req_string("variable.delete", p, "variable")?; @@ -881,15 +881,24 @@ pub fn set_mode(s: &mut Session, p: &Value) -> Result { ok() } -fn var_value(kind: VariableType, v: &Value) -> Option { - if let Some(a) = v.get("alias").and_then(Value::as_str) { - return Some(VariableValue::Alias(a.to_string())); +/// A `value`/`values` entry of `kind`: a plain value of that type, `{alias: variable}`, or (colour +/// variables) an `{alpha}`/`{mix}` calculation. An alias stays a name here — the caller binds it to +/// an id; a calculation binds its own operands as they are checked. `for_id` is the variable being +/// set when it already exists (a calculation may not lead back to it). +fn var_value(cmd: &str, vars: &Variables, kind: VariableType, for_id: Option<&VariableId>, v: &Value) -> Result { + // Calculations first: they refuse sibling keys, so `{alias, alpha}` is an error rather than + // quietly one of the two. + if v.get("alpha").is_some() || v.get("mix").is_some() { + return super::variables::colour_calc(cmd, vars, kind, for_id, v); } - Some(match kind { - VariableType::Color => VariableValue::Color(color_of(v)?), - VariableType::Number => VariableValue::Number(f64_of(v)?), + if let Some(a) = v.get("alias").and_then(Value::as_str) { + return Ok(VariableValue::Alias(a.to_string())); + } + Ok(match kind { + VariableType::Color => VariableValue::Color(color_of(v).ok_or_else(|| bad(cmd, "invalid value"))?), + VariableType::Number => VariableValue::Number(f64_of(v).ok_or_else(|| bad(cmd, "invalid value"))?), VariableType::String => VariableValue::String(v.as_str().map(str::to_string).unwrap_or_else(|| v.to_string())), - VariableType::Boolean => VariableValue::Boolean(v.as_bool()?), + VariableType::Boolean => VariableValue::Boolean(v.as_bool().ok_or_else(|| bad(cmd, "invalid value"))?), }) } @@ -915,11 +924,11 @@ fn var_create(s: &mut Session, p: &Value) -> Result { .find(|x| x.id == *mode || x.name == *mode) .map(|x| x.id.clone()) .ok_or_else(|| bad(cmd, format!("no mode `{mode}`")))?; - values.insert(mid, var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?); + values.insert(mid, var_value(cmd, &doc.variables, kind, None, v)?); } } if let Some(v) = get(p, "value") { - let val = var_value(kind, v).ok_or_else(|| bad(cmd, "invalid value"))?; + let val = var_value(cmd, &doc.variables, kind, None, v)?; for m in &coll.modes { values.entry(m.id.clone()).or_insert_with(|| val.clone()); } @@ -936,6 +945,10 @@ fn var_create(s: &mut Session, p: &Value) -> Result { *a = t.id.clone(); } } + // The new variable has no id yet and nothing reads it, so any stand-in id will do. + for (m, val) in &values { + super::variables::check_resolves(cmd, &doc.variables, "", &coll.id, m, val)?; + } let id = doc.new_variable_id(); doc.variables.variables.push(Variable { id: id.clone(), @@ -963,10 +976,12 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result { Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?, None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?, }; - let val = match get(p, "value").and_then(|x| var_value(var.kind, x)).ok_or_else(|| bad(cmd, "invalid value"))? { + let raw = get(p, "value").ok_or_else(|| bad(cmd, "invalid value"))?; + let val = match var_value(cmd, &doc.variables, var.kind, Some(&var.id), raw)? { VariableValue::Alias(a) => VariableValue::Alias(super::variables::alias_target(&doc.variables, &var, &a).map_err(|e| bad(cmd, e))?), v => v, }; + super::variables::check_resolves(cmd, &doc.variables, &var.id, &var.collection, &mode, &val)?; if let Some(slot) = doc.variables.variables.iter_mut().find(|x| x.id == var.id) { // An inherited variable's value of its own overrides the inherited one in that mode. if slot.extends.is_some() { @@ -1206,7 +1221,8 @@ fn apply_bindings_matching(doc: &mut Document, only: Option<&std::collections::B VariableValue::Number(x) => json!(x), VariableValue::String(x) => json!(x), VariableValue::Boolean(b) => json!(b), - VariableValue::Alias(_) => continue, + // Resolved values are plain, so these never occur here. + VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue, }; sets.insert(field, jv); } diff --git a/crates/engine/src/cmd/variables.rs b/crates/engine/src/cmd/variables.rs index 723633f9f..0956eaf5f 100644 --- a/crates/engine/src/cmd/variables.rs +++ b/crates/engine/src/cmd/variables.rs @@ -5,8 +5,8 @@ use std::collections::BTreeSet; use figcraft_doc::{ - CODE_PLATFORMS, Collection, CollectionId, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue, Variables, - normalize_scopes, scope_known, + CODE_PLATFORMS, Collection, CollectionId, ColorOperand, Document, ModeId, NodeId, NodeKind, Variable, VariableId, VariableType, VariableValue, + Variables, normalize_scopes, scope_known, }; use serde_json::{Value, json}; @@ -32,7 +32,7 @@ pub fn specs() -> Vec { spec!( "variable.deleteCollection", "Delete collection", - "{collection} (with its variables; bound layers keep their values)", + "{collection} (with its variables; bound layers keep their values; variables elsewhere aliasing them take their values and colour calculations reading them keep the colour they show)", true, delete_collection ), @@ -296,17 +296,21 @@ fn delete_collection(s: &mut Session, p: &Value) -> Result { } /// Delete variables `gone`: layers bound to them keep the values they show but no longer follow -/// them, and variables aliasing them take their values (per mode: the aliased variable's value in -/// that mode when it's in the same collection, else in its default mode). +/// them; a variable aliasing one takes its value and a colour calculation reading one keeps the +/// colour it shows (per mode: what the reader resolves to in that mode, other collections in their +/// default modes). pub(crate) fn forget_variables(doc: &mut Document, gone: &BTreeSet) -> Result<()> { + // Every reader is resolved before anything is written, so a reader of a reader resolves + // through the values as they were. let vars = &doc.variables; let mut detached: Vec<(VariableId, ModeId, VariableValue)> = Vec::new(); for v in vars.variables.iter().filter(|v| !gone.contains(&v.id)) { for (m, val) in &v.values { - let VariableValue::Alias(a) = val else { continue }; - let Some(t) = vars.get_by_id(a).filter(|t| gone.contains(&t.id)) else { continue }; - let modes = std::collections::BTreeMap::from([(t.collection.clone(), m.clone())]); - if let Some(r) = vars.resolve(&t.id, &modes) { + if !val.references().iter().any(|r| gone.contains(*r)) { + continue; + } + let modes = std::collections::BTreeMap::from([(v.collection.clone(), m.clone())]); + if let Some(r) = vars.resolve(&v.id, &modes) { detached.push((v.id.clone(), m.clone(), r)); } } @@ -429,6 +433,32 @@ pub fn sync_extensions(doc: &mut Document) { } } +/// Why a variable can't be read by an alias or a calculation. +enum TargetError { + Missing, + Kind(VariableType), + Loop, +} + +/// The id of `target` (the variable a value would read, None when there is none) as something a +/// value may read: it is of `kind` and doesn't lead back to `from` (the variable being set, once it +/// exists). +fn reference_target( + vars: &Variables, + target: Option<&Variable>, + kind: VariableType, + from: Option<&VariableId>, +) -> std::result::Result { + let t = target.ok_or(TargetError::Missing)?; + if t.kind != kind { + return Err(TargetError::Kind(t.kind)); + } + if reaches_back(vars, &t.id, from) { + return Err(TargetError::Loop); + } + Ok(t.id.clone()) +} + /// Check that `var` may alias `target` (id or name): it exists, has the same type and doesn't /// lead back to `var`. Returns the target's id. pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::result::Result { @@ -438,28 +468,116 @@ pub fn alias_target(vars: &Variables, var: &Variable, target: &str) -> std::resu /// Check an alias whose target is already a stored or remapped id, without matching names. pub(crate) fn alias_target_by_id(vars: &Variables, var: &Variable, target: &str) -> std::result::Result { - let t = vars.get_by_id(target).ok_or_else(|| format!("no variable `{target}` to alias"))?; - if t.kind != var.kind { - return Err(format!("can't alias a {} variable to a {} one", var.kind.id(), t.kind.id())); - } - let mut stack = vec![t.id.clone()]; + reference_target(vars, vars.get_by_id(target), var.kind, Some(&var.id)).map_err(|e| match e { + TargetError::Missing => format!("no variable `{target}` to alias"), + TargetError::Kind(k) => format!("can't alias a {} variable to a {} one", var.kind.id(), k.id()), + TargetError::Loop => "the alias would refer back to itself".into(), + }) +} + +/// Whether what `seed` references (aliases and colour-calculation operands, in every mode) can +/// lead back to `only`. Bounded at 10,000 variables for hand-crafted files with bigger graphs. +fn reaches_back(vars: &Variables, seed: &VariableId, only: Option<&VariableId>) -> bool { + let Some(only) = only else { return false }; + let mut stack = vec![seed.clone()]; let mut seen = BTreeSet::new(); while let Some(cur) = stack.pop() { - if cur == var.id { - return Err("the alias would refer back to itself".into()); + if cur == *only { + return true; } if !seen.insert(cur.clone()) || seen.len() > 10_000 { continue; } for v in vars.get_by_id(&cur).map(|x| x.values.values()).into_iter().flatten() { - if let VariableValue::Alias(a) = v - && let Some(next) = vars.get_by_id(a) - { - stack.push(next.id.clone()); - } + stack.extend(v.references().into_iter().cloned()); } } - Ok(t.id.clone()) + false +} + +/// `{"alpha": …}` / `{"mix": …}` values: a colour derived from what it reads, checked the moment it +/// is set (unknown operand, non-colour operand, bad parameter, a reference back to the variable +/// being defined). Names bind to ids here, the way aliases bind at the call sites. +pub(crate) fn colour_calc(cmd: &str, vars: &Variables, kind: VariableType, for_id: Option<&VariableId>, v: &Value) -> Result { + if kind != VariableType::Color { + return Err(bad(cmd, "colour calculations only fit colour variables")); + } + // One operation per value: a second key beside it (another operation, `alias`, a typo) would + // otherwise be dropped without a word. + let obj = v.as_object().filter(|o| o.len() == 1).ok_or_else(|| bad(cmd, "a colour calculation is {alpha: …} or {mix: …} on its own"))?; + match obj.get("alpha") { + Some(a) => { + // Only these two keys: an extra one (a typo beside `opacity`, an option this doesn't take) + // must not be dropped without a word. + let a = a + .as_object() + .filter(|a| a.keys().all(|k| k == "variable" || k == "opacity")) + .ok_or_else(|| bad(cmd, "alpha: {variable, opacity}"))?; + let name = a.get("variable").and_then(Value::as_str).ok_or_else(|| bad(cmd, "alpha needs the variable to fade"))?; + // Parameters before operands: their errors don't depend on what the operands are. + let opacity = unit(cmd, a, "opacity")?; + Ok(VariableValue::ColorAlpha { variable: calc_operand(cmd, vars, for_id, name)?, opacity }) + } + None => match obj.get("mix") { + Some(m) => { + let m = m + .as_object() + .filter(|m| m.keys().all(|k| matches!(k.as_str(), "from" | "to" | "t"))) + .ok_or_else(|| bad(cmd, "mix: {from, to, t}"))?; + let t = unit(cmd, m, "t")?; + Ok(VariableValue::ColorMix { + from: mix_operand(cmd, vars, for_id, m.get("from"))?, + to: mix_operand(cmd, vars, for_id, m.get("to"))?, + t, + }) + } + None => Err(bad(cmd, "not a colour calculation")), + }, + } +} + +/// A calculation whose operands all exist can still read more variables than one resolve may (a +/// mix of two long alias chains): refused when it is set, rather than left showing nothing. It is +/// checked in `mode` with every other collection in its default mode, the modes exports write it +/// in; another mode of a collection it reads, or a later change to what it reads, can still take +/// it past the limit, as an alias chain can. +pub(crate) fn check_resolves(cmd: &str, vars: &Variables, id: &str, collection: &str, mode: &str, val: &VariableValue) -> Result<()> { + let calc = matches!(val, VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }); + if calc && vars.flattened_as(id, collection, mode, val).is_none() { + return Err(bad(cmd, format!("the colour calculation reads more than {} variables", Variables::RESOLVE_LIMIT))); + } + Ok(()) +} + +/// `obj[key]` as a number from 0 to 1. Strict, unlike `fraction_of` (which reads numbers past 1 as +/// percentages): a mistyped 1.5 is refused instead of being stored as 1.5%. -0.0 is stored as 0.0 +/// so saved files read cleanly. +fn unit(cmd: &str, obj: &serde_json::Map, key: &str) -> Result { + let x = obj.get(key).and_then(Value::as_f64).filter(|x| (0.0..=1.0).contains(x)); + let x = x.ok_or_else(|| bad(cmd, format!("`{key}` must be a number from 0 to 1")))?; + Ok(if x == 0.0 { 0.0 } else { x as f32 }) +} + +/// A calculation operand given by name: an existing colour variable, never one that leads back to +/// the variable being set. Returns its id. +fn calc_operand(cmd: &str, vars: &Variables, for_id: Option<&VariableId>, name: &str) -> Result { + reference_target(vars, vars.get(name), VariableType::Color, for_id).map_err(|e| match e { + TargetError::Missing => bad(cmd, format!("no variable `{name}` to calculate from")), + TargetError::Kind(k) => bad(cmd, format!("colour calculations take colour variables: `{name}` is a {} one", k.id())), + TargetError::Loop => bad(cmd, "the colour calculation would refer back to itself"), + }) +} + +/// A mix operand: `{variable: name-or-id}` or `{literal: colour}` — a bare string is a colour name +/// as easily as a variable name, so the kind is always spelled out. +fn mix_operand(cmd: &str, vars: &Variables, for_id: Option<&VariableId>, v: Option<&Value>) -> Result { + let shape = || bad(cmd, "mix operands are {variable: …} or {literal: colour}"); + let o = v.and_then(Value::as_object).filter(|o| o.len() == 1).ok_or_else(shape)?; + if let Some(name) = o.get("variable").and_then(Value::as_str) { + return Ok(ColorOperand::Variable { variable: calc_operand(cmd, vars, for_id, name)? }); + } + let literal = crate::params::color_of(o.get("literal").ok_or_else(shape)?).ok_or_else(|| bad(cmd, "mix literal must be a colour"))?; + Ok(ColorOperand::Literal { literal }) } /// The variable scope of bindable property `field` on `node` (None: any variable of the type fits). diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 3bbadcc7a..483d92657 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -18,7 +18,7 @@ pub fn specs() -> Vec { spec!( "variable.export", "Export variables", - "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads | css: custom properties, the default mode on :root and each other mode on [data-=\"\"]; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file) | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases resolved), path? (write it there)} → {text}, or {path} when written", + "{collections? [ids or names; default every collection of this file, not library copies], format? (json (default): the local variables JSON that variable.import reads, colour calculations as the colour they show in each mode with other collections in their default modes | css: custom properties, the default mode on :root and each other mode on [data-=\"\"], colour calculations as the colour they show in that mode with other collections in their default modes; numbers in px unless layers or scopes use them as opacity or font weight | tailwind: a Tailwind CSS v4 stylesheet, the default mode as an @theme block named the way inspect.code's tailwind utilities read them: --color-*, --radius-*, --text-*, --spacing-*, --font-*, unique per file; each other mode as in css; colour calculations as the colour they show in that mode, other collections in their default modes) | dart: a Dart file for Flutter, a class of constants per collection and mode (Color, double, String, bool), aliases and colour calculations resolved), path? (write it there)} → {text}, or {path} when written", false, export ), @@ -67,7 +67,7 @@ fn css_safe(text: &str) -> String { /// Each collection's variables as custom properties (named as generated code names them): the /// default mode's values on `:root`, every other mode's on `[data-=""]`; -/// aliases are `var(--name)`. +/// aliases are `var(--name)` and colour calculations resolve to their colour in the mode. fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { let vars = &doc.variables; // Tailwind theme names, unique per file and the same ones page code reads. @@ -89,7 +89,10 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { }; out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name))); for v in vars.variables.iter().filter(|v| v.collection == c.id) { - let value = match v.values.get(&m.id) { + // A calculation is written as the colour it resolves to in this mode (no color-mix() + // or relative-colour output). + let flat = v.values.get(&m.id).and_then(|x| vars.flattened(v, &m.id, x)); + let value = match flat.as_ref() { Some(VariableValue::Color(col)) => col.to_css(), Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x), Some(VariableValue::Number(x)) => format!("{}px", trim_num(*x)), @@ -99,7 +102,7 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { Some(t) => format!("var({})", name(t)), None => continue, }, - None => continue, + Some(VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }) | None => continue, }; out.push_str(&format!(" {}: {value};\n", name(v))); } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e5b924fcd..1666ae2b8 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -559,6 +559,8 @@ mod variable_tests; #[cfg(test)] mod variables_behaviour_tests; #[cfg(test)] +mod variables_colour_tests; +#[cfg(test)] mod variables_editor_tests; #[cfg(test)] mod variables_import_shapes_tests; diff --git a/crates/engine/src/library_variables_tests.rs b/crates/engine/src/library_variables_tests.rs index e2d8535e5..5a6b329d3 100644 --- a/crates/engine/src/library_variables_tests.rs +++ b/crates/engine/src/library_variables_tests.rs @@ -264,6 +264,67 @@ fn bind_alias_mode_read_only_and_updates() { let _ = std::fs::remove_dir_all(&d); } +/// A library's colour calculations come in reading this file's copies of their operands (the +/// library's ids mean nothing here), keep following the library's updates, and keep the copies they +/// read when the library is removed. +#[test] +fn library_colour_calculations_read_the_copies() { + let d = dir("calc"); + let lib_path = path_str(&d.join("theme.figcraft")); + let mut lib = Session::new(); + theme(&mut lib); + ex( + &mut lib, + "variable.create", + json!({"collection": "Theme", "name": "hover", "type": "color", "value": {"alpha": {"variable": "base", "opacity": 0.5}}}), + ); + let tint = json!({"mix": {"from": {"variable": "primary"}, "to": {"literal": "#ffffff"}, "t": 0.5}}); + ex(&mut lib, "variable.create", json!({"collection": "Theme", "name": "tint", "type": "color", "value": tint})); + let lid = publish_to(&mut lib, &lib_path); + let lib_ids: Vec = lib.doc().unwrap().variables.variables.iter().map(|v| v.id.clone()).collect(); + + let mut s = Session::new(); + // Local variables first, so the copies get ids the library's don't share. + ex(&mut s, "variable.createCollection", json!({"name": "App"})); + for name in ["one", "two", "three", "four", "five", "six", "seven"] { + ex(&mut s, "variable.create", json!({"collection": "App", "name": name, "type": "number", "value": 1})); + } + ex(&mut s, "library.add", json!({"path": lib_path})); + let a = idv(&ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10}))); + let b = idv(&ex(&mut s, "shape.rectangle", json!({"x": 20, "y": 0, "width": 10, "height": 10}))); + ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "hover", "field": "fill", "ids": [a]})); + ex(&mut s, "library.bindVariable", json!({"library": lid, "variable": "tint", "field": "fill", "ids": [b]})); + let shown = |s: &Session, id: NodeId| Some(crate::paint_tests::fill(s, id).to_css_hex()); + assert_eq!((shown(&s, a), shown(&s, b)), (Some("#00FF0080".into()), Some("#8080FF".into()))); + let (base, primary) = (copy_of(&s, "base").id, copy_of(&s, "primary").id); + assert!(!lib_ids.contains(&base) && !lib_ids.contains(&primary), "copies have ids of their own"); + let reads = |name: &str| copy_of(&s, name).values.values().flat_map(|v| v.references().into_iter().cloned()).collect::>(); + assert_eq!(reads("hover"), [base.clone(), base]); + assert_eq!(reads("tint"), [primary.clone(), primary.clone()]); + + ex(&mut lib, "variable.set", json!({"variable": "base", "value": "#ff0000"})); + ex(&mut lib, "variable.set", json!({"variable": "primary", "mode": "Light", "value": "#000000"})); + republish(&mut lib); + ex(&mut s, "library.acceptUpdates", json!({})); + assert_eq!((shown(&s, a), shown(&s, b)), (Some("#FF000080".into()), Some("#808080".into()))); + + // A local calculation is the only reader of `primary` once the layers let go of the library. + ex( + &mut s, + "variable.create", + json!({"collection": "App", "name": "wash", "type": "color", "value": {"mix": {"from": {"variable": primary}, "to": {"literal": "#ffffff"}, "t": 0.5}}}), + ); + ex(&mut s, "variable.bind", json!({"ids": [a, b], "field": "fill", "variable": null})); + ex(&mut s, "library.remove", json!({"id": lid})); + let doc = s.doc().unwrap(); + assert!(doc.variables.get_by_id(&primary).is_some(), "the copy a local calculation reads stays"); + let wash = doc.variables.get("wash").unwrap(); + let Some(VariableValue::Color(c)) = doc.variables.resolve(&wash.id, &Default::default()) else { panic!("wash resolves to a colour") }; + assert_eq!(c.to_rgba8(), [128, 128, 128, 255]); + healthy(&s); + let _ = std::fs::remove_dir_all(&d); +} + #[test] fn library_components_keep_their_variable_bindings() { let d = dir("components"); diff --git a/crates/engine/src/paint_tests.rs b/crates/engine/src/paint_tests.rs index e13d426f1..1e97b05ca 100644 --- a/crates/engine/src/paint_tests.rs +++ b/crates/engine/src/paint_tests.rs @@ -13,7 +13,7 @@ fn red_rect(s: &mut Session) -> figcraft_doc::NodeId { r } -fn fill(s: &Session, id: figcraft_doc::NodeId) -> Color { +pub(crate) fn fill(s: &Session, id: figcraft_doc::NodeId) -> Color { s.doc().unwrap().node(id).unwrap().fills[0].solid_color().unwrap() } diff --git a/crates/engine/src/variables_behaviour_tests.rs b/crates/engine/src/variables_behaviour_tests.rs index 9c4853620..10a81bdba 100644 --- a/crates/engine/src/variables_behaviour_tests.rs +++ b/crates/engine/src/variables_behaviour_tests.rs @@ -15,7 +15,7 @@ fn id(v: Value) -> NodeId { id_of(&v["id"]).unwrap() } -fn vid(v: Value) -> String { +pub(crate) fn vid(v: Value) -> String { v["id"].as_str().unwrap().to_string() } diff --git a/crates/engine/src/variables_colour_tests.rs b/crates/engine/src/variables_colour_tests.rs new file mode 100644 index 000000000..ffc1ff357 --- /dev/null +++ b/crates/engine/src/variables_colour_tests.rs @@ -0,0 +1,473 @@ +//! Colour calculations: `{alpha: …}` (a referenced colour with its alpha replaced) and `{mix: …}` +//! (a blend of two colour operands) as per-mode variable values — set and create shapes, lazy +//! propagation to bound layers and mode switches, composition over aliases and other +//! calculations, set-time refusal of cycles and bad operands (leaving the variable untouched), +//! deletion flattening, undo/redo exactness, save/load round trip, exports and junk input. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::components::tests::{ex, idv}; +use crate::error::EngineError; +use crate::paint_tests::fill; +use crate::plugins_tests::rect; +use crate::variables_behaviour_tests::vid; +use crate::widgets_tests::err; +use figcraft_color::Color; + +/// A "C" collection (modes M) with `ink` #FF0000 and `edge` #00FF00 (the error-case set's seeds). +fn seeds() -> Session { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "C", "modes": ["M"]})); + ex(&mut s, "variable.create", json!({"collection": "C", "name": "ink", "type": "color", "value": "#FF0000"})); + ex(&mut s, "variable.create", json!({"collection": "C", "name": "edge", "type": "color", "value": "#00FF00"})); + ex(&mut s, "variable.create", json!({"collection": "C", "name": "size", "type": "number", "value": 5})); + s +} + +#[test] +fn create_accepts_alpha_and_mix_and_they_propagate_to_bound_layers() { + let mut s = seeds(); + let hover = vid(ex( + &mut s, + "variable.create", + json!({"collection": "C", "name": "hover", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}), + )); + let box_id = rect(&mut s, 0.0); + ex(&mut s, "variable.bind", json!({"ids": [box_id], "field": "fill", "variable": "hover"})); + assert_eq!(fill(&s, box_id).to_rgba8(), [255, 0, 0, 128], "alpha replaces the source's alpha"); + ex(&mut s, "variable.set", json!({"variable": "ink", "value": "#00AA00"})); + assert_eq!(fill(&s, box_id).to_rgba8(), [0, 170, 0, 128], "the bound layer follows the source, no re-set needed"); + // A mix of two variables, one literal, and the t edges. + ex(&mut s, "variable.set", json!({"variable": hover, "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 0.0}}})); + assert_eq!(fill(&s, box_id).to_rgba8(), [0, 170, 0, 255], "t=0 gives from exactly"); + ex( + &mut s, + "variable.set", + json!({"variable": hover, "value": {"mix": {"from": {"literal": "#000000"}, "to": {"literal": "#FFFFFF"}, "t": 0.4}}}), + ); + assert_eq!(fill(&s, box_id).to_rgba8(), [102, 102, 102, 255], "literal operands blend (0.4 -> 0x66)"); + // Compose: a mix reading the calculation, and alpha over an alias. + ex(&mut s, "variable.create", json!({"collection": "C", "name": "mirror", "type": "color", "value": {"alias": "ink"}})); + ex( + &mut s, + "variable.create", + json!({"collection": "C", "name": "faded-mirror", "type": "color", "value": {"alpha": {"variable": "mirror", "opacity": 0.5}}}), + ); + let b2 = rect(&mut s, 0.0); + ex(&mut s, "variable.bind", json!({"ids": [b2], "field": "fill", "variable": "faded-mirror"})); + assert_eq!(fill(&s, b2).to_rgba8(), [0, 170, 0, 128], "alpha over an alias reads the aliased value"); + let toast = vid(ex( + &mut s, + "variable.create", + json!({"collection": "C", "name": "toast", "type": "color", "value": {"alpha": {"variable": "hover", "opacity": 0.25}}}), + )); + let b3 = rect(&mut s, 0.0); + ex(&mut s, "variable.bind", json!({"ids": [b3], "field": "fill", "variable": toast})); + assert_eq!(fill(&s, b3).to_rgba8(), [102, 102, 102, 64], "a calculation may read a calculation"); +} + +#[test] +fn per_mode_operands_follow_each_collection_chosen_mode() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Core", "modes": ["Light", "Dark"]})); + ex(&mut s, "variable.createCollection", json!({"name": "Extra", "modes": ["Light", "Dark"]})); + ex(&mut s, "variable.create", json!({"collection": "Core", "name": "p", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}})); + ex(&mut s, "variable.create", json!({"collection": "Extra", "name": "s", "type": "color", "values": {"Light": "#000000", "Dark": "#FFFFFF"}})); + ex( + &mut s, + "variable.create", + json!({"collection": "Core", "name": "panel", "type": "color", + "values": {"Light": {"mix": {"from": {"variable": "p"}, "to": {"variable": "s"}, "t": 0.5}}, + "Dark": {"mix": {"from": {"variable": "p"}, "to": {"variable": "s"}, "t": 0.5}}}}), + ); + let b = rect(&mut s, 0.0); + ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "panel"})); + assert_eq!(fill(&s, b).to_rgba8(), [128, 0, 0, 255], "Light mixes Light values of BOTH collections"); + ex(&mut s, "variable.setMode", json!({"ids": [b], "collection": "Core", "mode": "Dark"})); + assert_eq!(fill(&s, b).to_rgba8(), [0, 0, 128, 255], "a Core switch moves only the Core operand (Extra stays Light)"); + ex(&mut s, "variable.setMode", json!({"ids": [b], "collection": "Extra", "mode": "Dark"})); + assert_eq!(fill(&s, b).to_rgba8(), [128, 128, 255, 255], "the Extra switch then moves its operand, independently"); +} + +#[test] +fn bad_shapes_and_operands_are_refused_without_touching_the_variable() { + let mut s = seeds(); + let before = json!(s.doc().unwrap().variables); + let want_t = "`t` must be a number from 0 to 1"; + let want_opacity = "`opacity` must be a number from 0 to 1"; + let want_operand = "mix operands are {variable: …} or {literal: colour}"; + let cases: Vec<(&str, Value, &str)> = vec![ + ( + "self alpha", + json!({"variable": "ink", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}), + "the colour calculation would refer back to itself", + ), + ( + "unknown operand", + json!({"variable": "ink", "value": {"alpha": {"variable": "ghost", "opacity": 0.5}}}), + "no variable `ghost` to calculate from", + ), + ( + "number operand", + json!({"variable": "ink", "value": {"alpha": {"variable": "size", "opacity": 0.5}}}), + "colour calculations take colour variables: `size` is a number one", + ), + ( + "number mix operand", + json!({"variable": "ink", "value": {"mix": {"from": {"variable": "size"}, "to": {"variable": "edge"}, "t": 0.5}}}), + "colour calculations take colour variables: `size` is a number one", + ), + ( + "calculation on a number variable", + json!({"variable": "size", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}), + "colour calculations only fit colour variables", + ), + ("t too big", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 1.5}}}), want_t), + ("t missing", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}}}}), want_t), + ( + "t as text", + json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": "half"}}}), + want_t, + ), + ("opacity above 1", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opacity": 2}}}), want_opacity), + ("opacity missing", json!({"variable": "ink", "value": {"alpha": {"variable": "edge"}}}), want_opacity), + ("alpha on a literal", json!({"variable": "ink", "value": {"alpha": {"literal": "#FF0000", "opacity": 0.5}}}), "alpha: {variable, opacity}"), + ("mix with a bare string", json!({"variable": "ink", "value": {"mix": {"from": "ink", "to": {"variable": "edge"}, "t": 0.5}}}), want_operand), + ( + "mix operand with two keys", + json!({"variable": "ink", "value": {"mix": {"from": {"variable": "ink", "literal": "#000000"}, "to": {"variable": "edge"}, "t": 0.5}}}), + want_operand, + ), + ("unknown key in alpha", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opactiy": 0.5}}}), "alpha: {variable, opacity}"), + // A bad parameter is named even beside an operand that loops: it is checked first. + ( + "bad t beside a loop", + json!({"variable": "ink", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 2}}}), + want_t, + ), + ("bad opacity beside a loop", json!({"variable": "ink", "value": {"alpha": {"variable": "ink", "opacity": -1}}}), want_opacity), + ]; + for (label, p, want) in cases { + let msg = err(&mut s, "variable.set", p.clone()); + assert!(msg.contains(want), "{label}: {msg}"); + } + // A second key beside the operation is refused, not dropped. + let alpha = json!({"variable": "edge", "opacity": 0.5}); + let mix = json!({"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}); + for (label, value) in [ + ("alpha and mix", json!({"alpha": alpha.clone(), "mix": mix.clone()})), + ("alias beside alpha", json!({"alias": "edge", "alpha": alpha})), + ("unknown key beside mix", json!({"mix": mix, "space": "oklch"})), + ] { + let msg = err(&mut s, "variable.set", json!({"variable": "ink", "value": value})); + assert!(msg.contains("on its own"), "{label}: {msg}"); + let msg = err(&mut s, "variable.create", json!({"collection": "C", "name": "extra", "type": "color", "value": value})); + assert!(msg.contains("on its own"), "{label} on create: {msg}"); + } + assert_eq!(json!(s.doc().unwrap().variables), before, "the refusals left every variable as it was"); + // A cycle reached through binary operands too: ink mixes edge; then edge must not read ink. + ex(&mut s, "variable.set", json!({"variable": "ink", "value": {"mix": {"from": {"variable": "edge"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}})); + let msg = err(&mut s, "variable.set", json!({"variable": "edge", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}})); + assert!(msg.contains("the colour calculation would refer back to itself"), "cycle named as such: {msg}"); + let msg = err( + &mut s, + "variable.set", + json!({"variable": "edge", "value": {"mix": {"from": {"variable": "ink"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}}), + ); + assert!(msg.contains("the colour calculation would refer back to itself"), "mix-cycle named as such: {msg}"); + // None of the refusals touched the variables: ink still holds its last accepted value, and a + // fresh calculation on it works. + let b = rect(&mut s, 0.0); + ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "ink"})); + assert_eq!(fill(&s, b).to_rgba8(), [128, 255, 128, 255], "ink keeps the accepted mix (edge + white at 0.5)"); + ex(&mut s, "variable.set", json!({"variable": "ink", "value": {"alpha": {"variable": "edge", "opacity": 0.5}}})); + assert_eq!(fill(&s, b).to_rgba8(), [0, 255, 0, 128], "and calculations still set afterwards"); +} + +#[test] +fn undo_and_redo_are_exact_through_calculations() { + let mut s = seeds(); + let hover = vid(ex( + &mut s, + "variable.create", + json!({"collection": "C", "name": "hover", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}), + )); + let b = rect(&mut s, 0.0); + ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": hover})); + let first = fill(&s, b).to_rgba8(); + ex(&mut s, "variable.set", json!({"variable": "hover", "value": {"alpha": {"variable": "edge", "opacity": 0.25}}})); + let second = fill(&s, b).to_rgba8(); + assert_ne!(first, second); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(fill(&s, b).to_rgba8(), first, "undo restores the stored alpha calculation exactly"); + ex(&mut s, "edit.redo", json!({})); + assert_eq!(fill(&s, b).to_rgba8(), second, "redo replays the new one exactly"); +} + +#[test] +fn deleting_a_source_flattens_its_calculations() { + let mut s = seeds(); + ex( + &mut s, + "variable.create", + json!({"collection": "C", "name": "fade", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}), + ); + let b = rect(&mut s, 0.0); + ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "fade"})); + let before = json!(s.doc().unwrap().variables); + ex(&mut s, "variable.delete", json!({"variable": "ink"})); + assert_eq!(fill(&s, b).to_rgba8(), [255, 0, 0, 128], "the bound layer keeps the colour it showed"); + let fade: Vec<_> = s.doc().unwrap().variables.get("fade").unwrap().values.values().cloned().collect(); + assert_eq!(fade, [figcraft_doc::VariableValue::Color(Color::new(1.0, 0.0, 0.0, 0.5))], "the calculation became the colour it showed"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(json!(s.doc().unwrap().variables), before, "undo restores every variable exactly"); +} + +/// Deleting a collection at once flattens every reader outside it — an alias of a calculation, a +/// calculation of two deleted variables — and deleting a calculation flattens a calculation that +/// reads it; undo restores the variables exactly each time. +#[test] +fn deleting_sources_together_flattens_every_reader() { + let mut s = seeds(); + let calc = |name: &str, value: Value| json!({"collection": "C", "name": name, "type": "color", "value": value}); + ex(&mut s, "variable.create", calc("fade", json!({"alpha": {"variable": "ink", "opacity": 0.5}}))); + ex(&mut s, "variable.create", calc("wash", json!({"mix": {"from": {"variable": "fade"}, "to": {"literal": "#FFFFFF"}, "t": 0.5}}))); + ex(&mut s, "variable.createCollection", json!({"name": "App"})); + ex(&mut s, "variable.create", json!({"collection": "App", "name": "echo", "type": "color", "value": {"alias": "wash"}})); + let tint = json!({"mix": {"from": {"variable": "edge"}, "to": {"variable": "ink"}, "t": 0.5}}); + ex(&mut s, "variable.create", json!({"collection": "App", "name": "tint", "type": "color", "value": tint})); + let stored = |s: &Session, name: &str| s.doc().unwrap().variables.get(name).unwrap().values.values().next().cloned().unwrap(); + let plain = |s: &Session, name: &str| match stored(s, name) { + figcraft_doc::VariableValue::Color(c) => c.to_rgba8(), + other => panic!("{name} is not a plain colour: {other:?}"), + }; + let before = json!(s.doc().unwrap().variables); + + ex(&mut s, "variable.deleteCollection", json!({"collection": "C"})); + assert_eq!(plain(&s, "echo"), [255, 128, 128, 191], "the alias of a calculation keeps what it showed"); + assert_eq!(plain(&s, "tint"), [128, 128, 0, 255], "a calculation of two deleted variables keeps its colour"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(json!(s.doc().unwrap().variables), before, "undo restores the collection and its readers exactly"); + + ex(&mut s, "variable.delete", json!({"variable": "fade"})); + assert_eq!(plain(&s, "wash"), [255, 128, 128, 191], "a calculation of the deleted calculation keeps its colour"); + assert!(matches!(stored(&s, "echo"), figcraft_doc::VariableValue::Alias(_)), "an alias of a surviving variable stays an alias"); + ex(&mut s, "edit.undo", json!({})); + assert_eq!(json!(s.doc().unwrap().variables), before); +} + +/// One resolve reads at most `Variables::RESOLVE_LIMIT` variables: a mix of two alias chains that +/// would read more is refused when it is set rather than left showing nothing. +#[test] +fn a_calculation_reading_too_many_variables_is_refused() { + let mut s = seeds(); + for side in ["a", "b"] { + ex(&mut s, "variable.create", json!({"collection": "C", "name": format!("{side}0"), "type": "color", "value": {"alias": "ink"}})); + for i in 1..16 { + let value = json!({"alias": format!("{side}{}", i - 1)}); + ex(&mut s, "variable.create", json!({"collection": "C", "name": format!("{side}{i}"), "type": "color", "value": value})); + } + } + // Reading `a{k}` and `b{k}` takes 2k + 4 variables: itself, both chains and the `ink` they share. + let mix = |k: usize| json!({"mix": {"from": {"variable": format!("a{k}")}, "to": {"variable": format!("b{k}")}, "t": 0.5}}); + ex(&mut s, "variable.create", json!({"collection": "C", "name": "fits", "type": "color", "value": mix(14)})); + let b = rect(&mut s, 0.0); + ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "fits"})); + assert_eq!(fill(&s, b).to_rgba8(), [255, 0, 0, 255], "32 variables still resolve"); + let too_many = "the colour calculation reads more than 32 variables"; + let msg = err(&mut s, "variable.create", json!({"collection": "C", "name": "over", "type": "color", "value": mix(15)})); + assert!(msg.contains(too_many), "create: {msg}"); + let msg = err(&mut s, "variable.set", json!({"variable": "fits", "value": mix(15)})); + assert!(msg.contains(too_many), "set: {msg}"); +} + +/// Junk anywhere inside a calculation is refused cleanly: the variables exist, so it reaches the +/// calculation parsers (the command-wide sweep starts from a document without variables). +#[test] +fn calculation_parsers_survive_junk() { + let junk = [ + json!(null), + json!(1e308), + json!(-0.0), + json!("NaN"), + json!("\u{0}"), + json!([]), + json!({}), + json!([1e308, -1e308, "x"]), + json!({"variable": [1]}), + json!({"variable": "ink", "literal": "#000"}), + json!({"literal": {"r": 1e308, "g": "x"}}), + json!({"mix": {"from": {"literal": {"mix": 1}}}}), + json!({"alpha": {"variable": "ink", "opacity": 0.5}}), + ]; + for j in &junk { + for value in [ + json!({"alpha": j}), + json!({"mix": j}), + json!({"mix": {"from": j, "to": j, "t": j}}), + json!({"alpha": {"variable": j, "opacity": j}}), + json!({"alpha": {"variable": "ink", "opacity": j}}), + json!({"mix": {"from": {"variable": "ink"}, "to": {"literal": j}, "t": 0.5}}), + ] { + for (cmd, p) in [ + ("variable.set", json!({"variable": "edge", "value": value})), + ("variable.set", json!({"variable": "size", "value": value})), + ("variable.create", json!({"collection": "C", "name": "junk", "type": "color", "values": {"M": value}})), + ] { + let mut s = seeds(); + if let Err(EngineError::Internal { cmd, msg }) = s.execute(cmd, &p) { + panic!("{cmd} panicked on {p}: {msg}"); + } + } + } + } +} + +#[test] +fn calculations_survive_save_and_load() { + let mut s = seeds(); + ex( + &mut s, + "variable.create", + json!({"collection": "C", "name": "mixv", "type": "color", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": 0.4}}}), + ); + let dir = std::env::temp_dir().join(format!("figcraft-colour-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("d.figcraft"); + ex(&mut s, "file.save", json!({"path": path.to_string_lossy()})); + let mut t = Session::new(); + ex(&mut t, "file.open", json!({"path": path.to_string_lossy()})); + let _ = std::fs::remove_dir_all(&dir); + let list = ex(&mut t, "variable.list", json!({})); + let mixv = list["variables"].as_array().unwrap().iter().find(|v| v["name"] == "mixv").unwrap(); + assert_eq!(mixv["values"].as_object().unwrap().values().next().unwrap()["type"], "colorMix", "still a calculation after a round trip"); + // And it still propagates in the reloaded document. + let b = idv(&ex(&mut t, "shape.rectangle", json!({"x": 0, "y": 0, "width": 4, "height": 4}))); + ex(&mut t, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "mixv"})); + assert_eq!(fill(&t, b).to_rgba8(), [153, 102, 0, 255], "ink + edge at 0.4: r=255*(1-0.4), g=255*0.4"); +} + +#[test] +fn rest_and_css_exports_write_calculations_as_their_colours() { + let mut s = seeds(); + // The exported REST variable `name` of `collections`, and the CSS text for them. + let json_variable = |s: &mut Session, collections: Value, name: &str| -> Value { + let text = ex(s, "variable.export", json!({"format": "json", "collections": collections})); + let doc: Value = serde_json::from_str(text["text"].as_str().unwrap()).unwrap(); + let meta = doc.get("meta").unwrap_or(&doc); + meta["variables"].as_object().unwrap().values().find(|v| v["name"] == name).unwrap().clone() + }; + let css = |s: &mut Session, collections: Value| { + ex(s, "variable.export", json!({"format": "css", "collections": collections}))["text"].as_str().unwrap().to_string() + }; + ex( + &mut s, + "variable.create", + json!({"collection": "C", "name": "fade", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": 0.5}}}), + ); + let fade = json_variable(&mut s, json!(["C"]), "fade"); + let value = fade["valuesByMode"].as_object().unwrap().values().next().unwrap(); + assert_eq!(value["r"], 1.0); + assert_eq!(value["a"], 0.5); + assert!(value.get("type").is_none(), "no calculation shape leaks into the REST JSON: {value}"); + let text = css(&mut s, json!(["C"])); + assert!(text.contains("rgba(255, 0, 0, 0.5"), "the CSS export writes the colour it resolves to: {text}"); + // Each mode is written as the colour it shows in that mode. + ex(&mut s, "variable.createCollection", json!({"name": "Theme", "modes": ["Light", "Dark"]})); + ex( + &mut s, + "variable.create", + json!({"collection": "Theme", "name": "brand", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}}), + ); + let veil = json!({"alpha": {"variable": "brand", "opacity": 0.5}}); + ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "veil", "type": "color", "value": veil})); + let veil = json_variable(&mut s, json!(["Theme"]), "veil"); + let mut per_mode: Vec<(f64, f64, f64)> = veil["valuesByMode"] + .as_object() + .unwrap() + .values() + .map(|v| (v["r"].as_f64().unwrap(), v["b"].as_f64().unwrap(), v["a"].as_f64().unwrap())) + .collect(); + per_mode.sort_by(|x, y| x.partial_cmp(y).unwrap()); + assert_eq!(per_mode, [(0.0, 1.0, 0.5), (1.0, 0.0, 0.5)], "Light and Dark each written as their own colour"); + let text = css(&mut s, json!(["Theme"])); + assert!(text.contains("rgba(255, 0, 0, 0.5") && text.contains("rgba(0, 0, 255, 0.5"), "both modes in CSS: {text}"); +} + +/// A calculation is a variable's value, not a literal an expression may hold: nothing would check +/// what it reads. Every way an expression arrives refuses one. +#[test] +fn prototype_expressions_refuse_a_calculation_literal() { + let mut s = seeds(); + let btn = rect(&mut s, 0.0); + let dest = idv(&ex(&mut s, "frame.create", json!({"x": 200, "y": 0, "width": 100, "height": 100}))); + ex(&mut s, "proto.interaction.add", json!({"id": btn, "dest": dest})); + let calc = json!({"value": {"type": "colorAlpha", "value": {"variable": "ink", "opacity": 9}}}); + let msg = err(&mut s, "proto.evaluate", json!({"expr": calc})); + assert!(msg.contains("Not a value"), "evaluate: {msg}"); + let set = json!({"type": "setVariable", "variable": "edge", "value": null, "expr": calc}); + let msg = err(&mut s, "proto.action.add", json!({"id": btn, "index": 0, "action": set})); + assert!(msg.contains("Not a value"), "set variable action: {msg}"); + let added = ex( + &mut s, + "proto.action.add", + json!({"id": btn, "index": 0, "action": {"type": "conditional", "blocks": [{"expression": "size > 1", "actions": []}]}}), + ); + let same = json!({"op": {"op": "equals", "args": [{"value": {"type": "alias", "value": "ink"}}, calc]}}); + let msg = err(&mut s, "proto.conditional.setCondition", json!({"id": btn, "index": 0, "path": added["path"], "block": 0, "condition": same})); + assert!(msg.contains("Not a value"), "condition: {msg}"); +} + +#[test] +fn rest_import_skips_a_hand_written_calculation_with_a_warning() { + // The variables JSON has no shape for calculations; a hand-written one must not import or corrupt. + let mut s = seeds(); + let text = json!({ + "variableCollections": {"COL:X": {"id": "COL:X", "name": "Imp", "modes": [{"modeId": "M:1", "name": "M"}], "defaultModeId": "M:1", "variableIds": ["V:9"]}}, + "variables": {"V:9": {"id": "V:9", "name": "ghost", "variableCollectionId": "COL:X", "resolvedType": "COLOR", + "valuesByMode": {"M:1": {"type": "colorAlpha", "value": {"variable": "V:1", "opacity": 0.5}}}}} + }); + let out = ex(&mut s, "variable.import", json!({"variables": text})); + assert!(out["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap_or("").contains("composed colours")), "skip warning present: {out}"); + let list = ex(&mut s, "variable.list", json!({})); + assert!(list["variables"].as_array().unwrap().iter().all(|v| v["name"] != "ghost"), "the calculation was skipped"); +} + +#[test] +fn profile_conversion_reaches_the_colours_stored_in_a_calculation() { + // A mix of identical literals shows that colour; after a converting profile change the + // mix-bound layer must match a layer bound to the plain colour (the literal converted too). + let mut s = seeds(); + ex(&mut s, "variable.create", json!({"collection": "C", "name": "p", "type": "color", "value": "#804020"})); + ex( + &mut s, + "variable.create", + json!({"collection": "C", "name": "dl", "type": "color", "value": {"mix": {"from": {"literal": "#804020"}, "to": {"literal": "#804020"}, "t": 0.5}}}), + ); + let a = rect(&mut s, 0.0); + let b = rect(&mut s, 0.0); + ex(&mut s, "variable.bind", json!({"ids": [a], "field": "fill", "variable": "p"})); + ex(&mut s, "variable.bind", json!({"ids": [b], "field": "fill", "variable": "dl"})); + ex(&mut s, "file.colorProfile", json!({"profile": "displayP3"})); + let (fa, fb) = (fill(&s, a), fill(&s, b)); + assert_ne!(fa.to_rgba8(), [128, 64, 32, 255], "the profile change converted the plain colour"); + assert_eq!(fa.to_rgba8(), fb.to_rgba8(), "the colour stored inside the calculation converted the same way"); +} + +#[test] +fn negative_zero_bounds_are_stored_as_plain_zero() { + let mut s = seeds(); + ex( + &mut s, + "variable.create", + json!({"collection": "C", "name": "n0", "type": "color", "value": {"alpha": {"variable": "ink", "opacity": -0.0}}}), + ); + ex( + &mut s, + "variable.create", + json!({"collection": "C", "name": "m0", "type": "color", "value": {"mix": {"from": {"variable": "ink"}, "to": {"variable": "edge"}, "t": -0.0}}}), + ); + let list = serde_json::to_string(&ex(&mut s, "variable.list", json!({}))).unwrap(); + assert!(!list.contains("-0"), "no negative zeros persisted: {list}"); +} diff --git a/crates/format/src/fig/assets_tests.rs b/crates/format/src/fig/assets_tests.rs index 6ca616746..ce4192008 100644 --- a/crates/format/src/fig/assets_tests.rs +++ b/crates/format/src/fig/assets_tests.rs @@ -5,7 +5,7 @@ //! laid out for it. Everything written reads back; what the format can't say is written as its //! value with a warning; damaged files never panic. -use figcraft_doc::{Collection, Mode, Style, StyleKind, Variable, VariableType, VariableValue}; +use figcraft_doc::{Collection, ColorOperand, Mode, Style, StyleKind, Variable, VariableType, VariableValue}; use serde_json::json; use super::*; @@ -383,6 +383,36 @@ fn bindings_the_format_cant_say_keep_their_value_with_a_warning() { assert!(back.variables.get("lost").is_none()); } +/// A colour calculation has no encoding here: it is written as the colour it shows in each mode, +/// and one that can't resolve is left out with a warning rather than written as something else. +#[test] +fn colour_calculations_are_written_as_the_colour_they_show() { + let mut doc = themed(); + let fade = |source: &str| VariableValue::ColorAlpha { variable: source.into(), opacity: 0.5 }; + let mix = VariableValue::ColorMix { + from: ColorOperand::Variable { variable: "v-fg".into() }, + to: ColorOperand::Literal { literal: Color::new(1.0, 0.0, 0.0, 1.0) }, + t: 0.5, + }; + doc.variables.variables.push(var("v-veil", "color/veil", "c-theme", VariableType::Color, &[("m-light", fade("v-bg")), ("m-dark", mix)])); + doc.variables.variables.push(var("v-broken", "color/broken", "c-theme", VariableType::Color, &[("m-light", fade("v-gone"))])); + let (bytes, warnings) = export(&doc, None).unwrap(); + assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}"); + let (back, _) = import(&bytes, "x").unwrap(); + assert!(back.variables.get("color/broken").is_none_or(|v| v.values.is_empty()), "left out, not written as something else"); + let theme = back.variables.collections.iter().find(|c| c.name == "Theme").unwrap(); + let veil = back.variables.get("color/veil").unwrap(); + let shown: Vec = theme + .modes + .iter() + .map(|m| match veil.values.get(&m.id) { + Some(VariableValue::Color(c)) => c.to_css_hex(), + other => panic!("{}: {other:?}", m.name), + }) + .collect(); + assert_eq!(shown, ["#FFFFFF80", "#DF6060"]); +} + /// A style of each kind linked from a layer, and one that nothing uses. fn styled() -> Document { let mut doc = Document::new("Styled"); diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs index 4bf790fd7..63aef407a 100644 --- a/crates/format/src/fig/export/assets.rs +++ b/crates/format/src/fig/export/assets.rs @@ -211,7 +211,13 @@ impl<'d> Exporter<'d> { let mut values = Vec::new(); for m in &c.modes { let (Some(mode), Some(val)) = (self.mode_ids.get(&(c.id.as_str(), m.id.as_str())), v.values.get(&m.id)) else { continue }; - let (ty, value) = match val { + // The Figma binary has no encoding FigCraft writes for calculations: the colour + // they show in this mode goes out as a plain colour value. + let Some(val) = doc.variables.flattened(v, &m.id, val) else { + self.warn(crate::CALCULATIONS_LEFT_OUT); + continue; + }; + let (ty, value) = match &val { VariableValue::Color(x) => ("COLOR", ("colorValue", color(*x))), VariableValue::Number(x) => ("FLOAT", ("floatValue", fl(*x))), // NULs can't be stored in a string. @@ -224,6 +230,8 @@ impl<'d> Exporter<'d> { continue; } }, + // `flattened` leaves none: this format has no shape for a calculation. + VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue, }; let data = o(vec![("value", o(vec![value])), ("dataType", e(ty)), ("resolvedDataType", e(resolved))]); values.push(o(vec![("modeID", guid(AUX, *mode)), ("variableData", data)])); diff --git a/crates/format/src/lib.rs b/crates/format/src/lib.rs index 701fc4b38..3a69ca848 100644 --- a/crates/format/src/lib.rs +++ b/crates/format/src/lib.rs @@ -56,6 +56,9 @@ const MAX_DOCUMENT: u64 = 4 * 1024 * 1024 * 1024; const MAX_DOCUMENT: u64 = 1024 * 1024 * 1024; /// Most versions a file keeps: saving more is refused, extra entries in a file are skipped. pub const MAX_VERSIONS: usize = 100; +/// The export warning for colour calculations that resolve to nothing (an operand missing, or more +/// than 32 variables to read), shared by the Penpot and `.fig` exports. +pub(crate) const CALCULATIONS_LEFT_OUT: &str = "colour calculations that don't resolve were left out"; /// Longest version name and description kept (chars). pub const MAX_VERSION_NAME: usize = 200; pub const MAX_VERSION_DESCRIPTION: usize = 2000; diff --git a/crates/format/src/penpot/export.rs b/crates/format/src/penpot/export.rs index 1cec7eaf9..5f9adc109 100644 --- a/crates/format/src/penpot/export.rs +++ b/crates/format/src/penpot/export.rs @@ -1727,7 +1727,13 @@ impl<'a> Exporter<'a> { let mut tokens = Map::new(); for var in variables.variables.iter().filter(|v| v.collection == collection.id) { let Some(value) = var.values.get(&mode.id).or_else(|| var.values.values().next()) else { continue }; - let (kind, value) = match value { + // Penpot tokens have no shape for colour calculations: the colour they resolve + // to in this mode goes out as a plain colour token. + let Some(value) = variables.flattened(var, &mode.id, value) else { + self.warn(crate::CALCULATIONS_LEFT_OUT); + continue; + }; + let (kind, value) = match &value { VariableValue::Alias(target) => { let Some(target_name) = self.token_name(target) else { continue }; let kind = match var.kind { @@ -1742,6 +1748,8 @@ impl<'a> Exporter<'a> { let [r, g, b, a] = c.to_rgba8(); ("color", json!(if a == 255 { format!("#{r:02x}{g:02x}{b:02x}") } else { format!("#{r:02x}{g:02x}{b:02x}{a:02x}") })) } + // `flattened` leaves none: this format has no shape for a calculation. + VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => continue, VariableValue::Number(n) => ("number", json!(num_text(*n))), VariableValue::String(s) => ("string", json!(s)), VariableValue::Boolean(b) => ("boolean", json!(b.to_string())), diff --git a/crates/format/src/penpot/export_tests.rs b/crates/format/src/penpot/export_tests.rs index 1432a8c57..576574e06 100644 --- a/crates/format/src/penpot/export_tests.rs +++ b/crates/format/src/penpot/export_tests.rs @@ -5,8 +5,9 @@ use figcraft_doc::color::{Color, ColorStop, Gradient, GradientKind, ImageScaleMo use figcraft_doc::geom::{Affine, Point, Shape}; use figcraft_doc::proto::{Action, Direction, Easing, Flow, Interaction, OverlayPosition, Transition, Trigger}; use figcraft_doc::{ - AutoLayout, Blur, Collection, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId, NodeKind, - Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue, VectorNetwork, + AutoLayout, Blur, Collection, ColorOperand, CrossAlign, Document, Effect, EffectKind, GridTrack, LayoutMode, LineHeight, MainAlign, Mode, NodeId, + NodeKind, Shadow, Sizing, StrokeAlign, StrokeCap, StyleRun, TextAlignH, TextAutoResize, TextData, Variable, VariableType, VariableValue, + VectorNetwork, }; use serde_json::{Value, json}; @@ -523,15 +524,25 @@ fn variable(id: &str, name: &str, collection: &str, kind: VariableType, values: } } -#[test] -fn native_variables_become_token_sets_themes_and_applied_tokens() { - let mut doc = Document::new("Tokens"); - doc.variables.collections.push(Collection { +/// A "Color" collection `c1` with Light and Dark modes. +fn light_dark_colors() -> Collection { + Collection { id: "c1".into(), name: "Color".into(), modes: vec![Mode { id: "light".into(), name: "Light".into() }, Mode { id: "dark".into(), name: "Dark".into() }], extends: None, - }); + } +} + +/// The export's `tokens.json`. +fn tokens(bytes: &[u8]) -> Value { + entries(bytes).into_iter().find(|(name, _)| name.ends_with("tokens.json")).map(|(_, v)| v).unwrap() +} + +#[test] +fn native_variables_become_token_sets_themes_and_applied_tokens() { + let mut doc = Document::new("Tokens"); + doc.variables.collections.push(light_dark_colors()); doc.variables.collections.push(Collection { id: "c2".into(), name: "Size".into(), @@ -560,11 +571,7 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() { doc.node_mut(id).unwrap().bound.insert("width".into(), "v2".into()); let (bytes, _) = export(&doc).unwrap(); - let mut z = zip::ZipArchive::new(Cursor::new(&bytes[..])).unwrap(); - let tokens_path = z.file_names().find(|n| n.ends_with("tokens.json")).unwrap().to_owned(); - let mut text = String::new(); - z.by_name(&tokens_path).unwrap().read_to_string(&mut text).unwrap(); - let tokens: Value = serde_json::from_str(&text).unwrap(); + let tokens = tokens(&bytes); assert_eq!(tokens["Color/Light"]["brand"]["primary"]["$value"], "#ff0000"); assert_eq!(tokens["Color/Dark"]["brand"]["primary"]["$value"], "#880000"); assert_eq!(tokens["Color/Light"]["brand"]["alias"]["$value"], "{brand.primary}"); @@ -580,6 +587,43 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() { assert!(bound.bound.contains_key("width")); } +/// Penpot tokens have no shape for a colour calculation: each mode's token is the colour it shows, +/// and one that resolves to nothing is left out with a warning. +#[test] +fn colour_calculations_export_as_the_colour_they_show() { + let mut doc = Document::new("Calculated"); + doc.variables.collections.push(light_dark_colors()); + let hex = |h: &str| Color::from_hex(h).unwrap(); + doc.variables.variables.push(variable( + "v1", + "brand/primary", + "c1", + VariableType::Color, + &[("light", VariableValue::Color(hex("#ff0000"))), ("dark", VariableValue::Color(hex("#0000ff")))], + )); + let mix = VariableValue::ColorMix { + from: ColorOperand::Variable { variable: "v1".into() }, + to: ColorOperand::Literal { literal: hex("#00ff0080") }, + t: 0.5, + }; + doc.variables.variables.push(variable( + "v2", + "brand/veil", + "c1", + VariableType::Color, + &[("light", VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }), ("dark", mix)], + )); + let gone = VariableValue::ColorAlpha { variable: "v-gone".into(), opacity: 0.5 }; + doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone)])); + let (bytes, warnings) = export(&doc).unwrap(); + assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}"); + let tokens = tokens(&bytes); + assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost").is_none()), "left out, not written"); + assert_eq!(tokens["Color/Light"]["brand"]["veil"]["$value"], "#ff000080"); + assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$value"], "#008080c0"); + assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$type"], "color"); +} + #[test] fn imported_penpot_tokens_are_written_back_verbatim() { let mut doc = Document::new("Kept tokens"); diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs index 5ba0c5ad2..bbd6287b1 100644 --- a/crates/format/src/rest/proto.rs +++ b/crates/format/src/rest/proto.rs @@ -355,7 +355,7 @@ impl Ctx<'_> { Some(VariableValue::Number(n)) => json!(n), Some(VariableValue::String(s)) => json!(s), Some(VariableValue::Boolean(b)) => json!(b), - Some(VariableValue::Alias(_)) | None => { + Some(VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }) | None => { self.warn("set-variable actions with values that don't fit the variable were skipped"); return None; } diff --git a/crates/format/src/rest/variables.rs b/crates/format/src/rest/variables.rs index d58da409b..94ca03f6b 100644 --- a/crates/format/src/rest/variables.rs +++ b/crates/format/src/rest/variables.rs @@ -67,7 +67,8 @@ pub(super) fn value(kind: VariableType, v: &Value) -> Option { /// `collections` of `vars` (with their variables) as a local variables response, which /// [`Ctx::variables`] reads back. Aliases to variables outside them are written as the value they -/// resolve to in the default modes. +/// resolve to in the default modes. Colour calculations have no shape in this JSON, so they go out +/// as the colour they resolve to in the mode they are written for. pub fn export(vars: &Variables, collections: &[&Collection]) -> Value { let ids: BTreeSet<&str> = collections.iter().map(|c| c.id.as_str()).collect(); let members: Vec<&Variable> = vars.variables.iter().filter(|v| ids.contains(v.collection.as_str())).collect(); @@ -91,7 +92,10 @@ pub fn export(vars: &Variables, collections: &[&Collection]) -> Value { Some(r) => r, None => continue, }, - other => other.clone(), + other => match vars.flattened(v, mode, other) { + Some(flat) => flat, + None => continue, + }, }; values.insert(mode.clone(), raw(&val)); } @@ -112,7 +116,8 @@ pub fn export(vars: &Variables, collections: &[&Collection]) -> Value { json!({"status": 200, "error": false, "meta": {"variableCollections": colls, "variables": out}}) } -/// A value as the variables JSON writes it (the inverse of [`value`]). +/// A value as the variables JSON writes it (the inverse of [`value`]). The export resolves +/// calculations before calling this, so they have no shape of their own here. fn raw(v: &VariableValue) -> Value { match v { VariableValue::Color(c) => json!({"r": c.r, "g": c.g, "b": c.b, "a": c.a}), @@ -120,6 +125,7 @@ fn raw(v: &VariableValue) -> Value { VariableValue::String(s) => json!(s), VariableValue::Boolean(b) => json!(b), VariableValue::Alias(a) => json!({"type": "VARIABLE_ALIAS", "id": a}), + VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => Value::Null, } } diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 7529d93b8..f2a2b8c90 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -239,6 +239,7 @@ name = "English" "Convert to section" = "Convert to section" "CSS custom properties…" = "CSS custom properties…" "Dart constants for Flutter…" = "Dart constants for Flutter…" +"Calculated from other variables; it follows them" = "Calculated from other variables; it follows them" "Can edit" = "Can edit" "Can't read this library: {e}" = "Can't read this library: {e}" "Cancel" = "Cancel" @@ -423,6 +424,7 @@ name = "English" "Desktop" = "Desktop" "Destination" = "Destination" "Detach alias" = "Detach alias" +"Detach calculation" = "Detach calculation" "Detach instance" = "Detach instance" "Detach property" = "Detach property" "Detach style" = "Detach style" diff --git a/crates/ui-egui/src/dialogs/variables_panel.rs b/crates/ui-egui/src/dialogs/variables_panel.rs index 710c0db55..996f42465 100644 --- a/crates/ui-egui/src/dialogs/variables_panel.rs +++ b/crates/ui-egui/src/dialogs/variables_panel.rs @@ -762,14 +762,13 @@ fn kind_glyph(ui: &Ui, vars: &Variables, v: &Variable, coll: &Collection, rect: } } -/// `val` as a `variable.set` value. -fn value_json(val: &VariableValue) -> Value { - match val { - VariableValue::Color(c) => json!(c.to_css_hex()), - VariableValue::Number(x) => json!(x), - VariableValue::String(s) => json!(s), - VariableValue::Boolean(b) => json!(b), - VariableValue::Alias(a) => json!({"alias": a}), +/// The button that replaces the reference or calculation in `mode` with the value it shows now. +fn detach(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &Collection, mode: &str, (key, at): (Id, Rect), label: &str) { + if widgets::icon_button_at(ui, key, at, Icon::Detach, label) { + let modes = [(coll.id.clone(), mode.to_string())].into_iter().collect(); + if let Some(resolved) = view.vars.resolve(&v.id, &modes) { + set(app, v, mode, crate::panels::proto_panel::value_json(&resolved)); + } } } @@ -821,12 +820,29 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C if resp.clicked() { crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom()); } - // Detach: keep the value it resolves to now. - if hovered && widgets::icon_button_at(ui, id.with("detach"), alias_btn, Icon::Detach, tr("Detach alias")) { - let modes = [(coll.id.clone(), mode.to_string())].into_iter().collect(); - if let Some(resolved) = view.vars.resolve(&v.id, &modes) { - set(app, v, mode, value_json(&resolved)); - } + if hovered { + detach(app, ui, view, v, coll, mode, (id.with("detach"), alias_btn), tr("Detach alias")); + } + return; + } + // A calculation shows what it resolves to and names its operands. It is set through the + // commands; here, like an alias, it can be swapped for an alias or detached to its colour. + if matches!(val, VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. }) { + let name = |id: &str| view.vars.get_by_id(id).map_or_else(|| tr("Missing variable").to_string(), |x| x.name.clone()); + let formula = val.display_with(&name); + let resp = ui.interact(body, id.with("calc"), if editable { Sense::click() } else { Sense::hover() }); + let resp = resp.tip(format!("{formula}\n{}", tr("Calculated from other variables; it follows them"))); + let shown = match view.vars.flattened(v, mode, val) { + Some(VariableValue::Color(c)) => Some(c), + _ => None, + }; + // The formula is the point of the cell: it keeps the width an alpha readout would take. + read_only_cell(ui, body, shown, formula, false); + if resp.clicked() { + crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom()); + } + if hovered { + detach(app, ui, view, v, coll, mode, (id.with("detach"), alias_btn), tr("Detach calculation")); } return; } @@ -874,7 +890,7 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C set(app, v, mode, json!(!b)); } } - VariableValue::Alias(_) => {} + VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => {} } if hovered && widgets::icon_button_at(ui, id.with("mkalias"), alias_btn, Icon::Variable, tr("Create alias")) { crate::variable_ui::open_alias(ui, &v.id, mode, v.kind, body.left_bottom()); @@ -890,20 +906,33 @@ fn alpha_text(ui: &Ui, c: &figcraft_color::Color, body: Rect) { /// A value that only shows (a library's): its swatch and hex for colours, else its text. fn read_only_value(ui: &Ui, val: &VariableValue, body: Rect) { - let t = Tokens::get(ui.ctx()); - ui.painter().rect_filled(body, CornerRadius::same(5), t.field); - let mut x = body.min.x + 8.0; - if let VariableValue::Color(c) = val { - let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0)); - widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(*c)); - x = sw.max.x + 8.0; - alpha_text(ui, c, body); - } + let swatch = match val { + VariableValue::Color(c) => Some(*c), + _ => None, + }; let text = match val { VariableValue::Boolean(b) => (if *b { tr("True") } else { tr("False") }).to_string(), other => other.display(), }; - let text_r = Rect::from_min_max(pos2(x, body.min.y), pos2(body.max.x - 44.0, body.max.y)); + read_only_cell(ui, body, swatch, text, true); +} + +/// A cell that only shows: the colour's swatch (and its alpha at the right end when `alpha`), then +/// `text`. +fn read_only_cell(ui: &Ui, body: Rect, swatch: Option, text: String, alpha: bool) { + let t = Tokens::get(ui.ctx()); + ui.painter().rect_filled(body, CornerRadius::same(5), t.field); + let mut x = body.min.x + 8.0; + if let Some(c) = swatch { + let sw = Rect::from_center_size(pos2(body.min.x + 13.0, body.center().y), vec2(14.0, 14.0)); + widgets::paint_swatch(ui.painter(), sw, &figcraft_color::Paint::solid(c)); + x = sw.max.x + 8.0; + if alpha { + alpha_text(ui, &c, body); + } + } + let right = if alpha && swatch.is_some() { 44.0 } else { 4.0 }; + let text_r = Rect::from_min_max(pos2(x, body.min.y), pos2(body.max.x - right, body.max.y)); ui.painter().with_clip_rect(text_r).text(text_r.left_center(), Align2::LEFT_CENTER, text, regular(11.0), t.text); } diff --git a/crates/ui-egui/src/panels/proto_editor.rs b/crates/ui-egui/src/panels/proto_editor.rs index 32817e840..9dad94270 100644 --- a/crates/ui-egui/src/panels/proto_editor.rs +++ b/crates/ui-egui/src/panels/proto_editor.rs @@ -560,7 +560,8 @@ fn literal_field(ui: &mut Ui, id: Id, cur: &VariableValue, w: f32) -> Option widgets::text_field(ui, id, s, w, tr("Text")).filter(|n| n != s).map(VariableValue::String), - VariableValue::Alias(_) => None, + // References are picked, not typed; calculations can't be expression values. + VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None, } } diff --git a/crates/ui-egui/src/panels/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs index e43815bdf..f5334f064 100644 --- a/crates/ui-egui/src/panels/proto_panel.rs +++ b/crates/ui-egui/src/panels/proto_panel.rs @@ -220,14 +220,15 @@ pub(crate) fn action_summary(doc: &Document, a: &Action) -> String { } } -/// A variable value as a set variable action stores it. +/// A plain variable value as commands take it (a set variable action, `variable.set`). References +/// and calculations give null: they have no plain form, so a caller resolves them first. pub(crate) fn value_json(v: &VariableValue) -> Value { match v { VariableValue::Color(c) => json!(c.to_css_hex()), VariableValue::Number(n) => json!(n), VariableValue::Boolean(b) => json!(b), VariableValue::String(s) => json!(s), - VariableValue::Alias(_) => Value::Null, + VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => Value::Null, } } diff --git a/docs/components-parity.md b/docs/components-parity.md index b32e9ef96..ba7208fa7 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -63,9 +63,9 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | 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: colour, number, string, boolean; collections, modes, aliases, scopes, groups | done | behaviour suite: `variables_behaviour_tests.rs`; aliases same type, no loops (also on create); scopes filter pickers only | +| 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 | | 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 | +| 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 | | Code syntax per platform (Web, iOS, Android, Flutter) | done | generated CSS uses the Web name; in the variables JSON; SwiftUI/Compose/ArkUI/Flutter code doesn't name variables yet | | Bindings: fills, strokes, effects, opacity, padding, gaps, sizes, radius, font family/style, font size/weight, line height, letter spacing, paragraph spacing/indent, text, visibility, layout grids, instance properties | done | type-checked per field; number variables fill text content; the Design panel's font family and font style menus start with Apply variable (string variables), and a bound one shows its variable in place of the menu; a colour variable bound to a solid paint brings its whole colour, alpha included, once (the paint's opacity restarts at 100% on binding, an opacity set while bound multiplies on top; unbinding moves the alpha back into the opacity) | | Explicit modes per frame, library collections' modes | done | stored variable and collection references resolve by exact id in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker and prototype playback; a collection or variable named like another's id no longer stands in for it there | @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M3, #1280: a colour variable can be calculated from others, per mode: `{"alpha": {"variable", "opacity"}}` is that variable's colour with its alpha replaced (replace, not multiply, so re-editing the source's alpha never compounds), `{"mix": {"from", "to", "t"}}` blends two operands (`{"variable"}` or `{"literal"}`) per channel over the stored values in the document's colour profile, alpha included (converting the profile keeps a mix's ends exact and moves its colours in between slightly). Calculations resolve lazily like aliases, so bound layers, mode switches, instances and undo follow their sources; they compose (a calculation of a calculation), a loop is refused when it is set, and one resolve consults at most 32 variables. Files keep 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, so a CSS calculation doesn't follow another collection's `data-` mode); deleting a source leaves its readers on the colour they showed; library import and swap re-point their operands. The panel shows them read-only (swatch and formula) and can swap one for an alias or detach it to its colour. Tests: `engine/src/variables_colour_tests.rs`, `doc/src/tests.rs`. Left: editing calculations in the panel; files using them need this build to open. Numbers unchanged | | 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged | | 2026-10-30 | minor | M8: the component set's Properties section fits the narrowest right panel: variant values wrap onto more lines and every + and − button stays visible. Grade unchanged. | | 2026-10-30 | minor | M8, #1097: library updates from a team library a file names are offered, not fetched from its server by themselves: only team libraries you added or checked are checked in the background (**Check for updates** in Assets ▸ Libraries). Numbers unchanged. | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b14ac20a9..96c477f53 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-10-30 · **Change:** minor (`.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-10-30 · **Change:** minor (`.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 @@ -111,7 +111,8 @@ source paint coordinates. follows from those as derived data: each layer's size, place and outline and the colours its variables give in the instance's mode; an instance scaled as a whole says its scale factor); variable collections with their modes and variables (colour, number, string and boolean values - per mode, aliases, descriptions, scopes) as nodes on a hidden page, the variables bound to + per mode, aliases, colour calculations as the colour they show in each mode, descriptions, + scopes) as nodes on a hidden page, the variables bound to paints, gradient stops, effect and layout grid fields and to layer and text fields (size, gaps, padding, radii, stroke weights, opacity, visibility, characters, font and paragraph settings), and the modes frames and pages set; shared paint, text, effect and grid styles as nodes on that page @@ -123,7 +124,8 @@ source paint coordinates. controller buttons; navigate, overlays with their position, outside-click setting and background, swap, change to, scroll to, back, close, open link; transitions and easing, custom curves included), scrolling and fixed layers, flow starting points. -- **Left out, with a warning each:** variable bindings the format has no field for (component +- **Left out, with a warning each:** colour calculations that resolve to nothing (an operand + missing, or more than 32 variables to read), variable bindings the format has no field for (component properties, arc fields, font weight: written as the values they give), text styles on part of a text (written as values), variables' publishing, code syntax and inheritance settings, extended collections (written as collections of their own), comments, Dev Mode annotations, statuses, @@ -187,6 +189,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M3, #1280: `.fig` export writes a colour calculation (a variable's `{alpha}` or `{mix}` value) as the colour it shows in each mode of its collection, other collections in their default modes; one that resolves to nothing (an operand missing, or more than 32 variables to read) is left out with a warning. Tests: `crates/format/src/fig/assets_tests.rs`. Numbers unchanged. | | 2026-10-30 | minor | M8, #574: `.fig` import budgets four decoded values per compressed canvas byte instead of one; the 20M floor and the 128M ceiling (32M on 32-bit targets) are unchanged. Dense legitimate files went past one value per compressed byte (further since every scalar field counts) and were refused whole as "not a valid FigCraft file: .fig file: message: too many values". Generated 21 MB and 15 MB canvases (220,044 and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole without warnings; a 29 KB canvas that unpacks to 30 million values is still refused. Files that now open instead of being refused take the memory they need: the 49.7 MB canvas's test peaks at about 9 GB. Tests: `crates/format/src/fig/value_budget_tests.rs` (the heavy ones with `--ignored`) and `large_files_get_a_larger_value_budget` in `crates/format/src/fig/tests.rs`. Numbers unchanged. | | 2026-10-30 | minor | M8, #1076: `.fig` ZIP images now share a budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images. The 256 MiB member cap stays; members that do not fit are skipped before allocation with an import warning. Failed reads are charged too, repeated references keep their cached resolution, and smaller later members can still fit. Embedded images and decoded pixel memory are outside this budget. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | | 2026-10-30 | minor | M8, #1114: a document opened or imported by path must be a regular file of at most 16 GiB, and an image placed by path one of at most 256 MiB: a folder, a device or a pipe is refused before anything is read. Real files open as before. Numbers unchanged. | diff --git a/docs/mcp.md b/docs/mcp.md index 44c68c5d9..12b0861e1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -237,7 +237,8 @@ Or in a project's `.mcp.json`: - **Colour profile**: colour values are in the file's profile, sRGB (default) or Display P3 (`inspect_document` reports `colorProfile`). `file.colorProfile {profile: srgb|displayP3, convert?}` changes it: `convert` (default true) converts every colour value so colours keep their - appearance; `false` keeps the values and reinterprets them. Images are converted from their own colour + appearance (a colour calculation's mix keeps its ends exact; its colours in between move + slightly); `false` keeps the values and reinterprets them. Images are converted from their own colour profile into the file's space: an embedded ICC profile (PNG, JPG, WebP, TIFF; RGB or grey), a PNG's `sRGB` / `gAMA` / `cHRM` chunks, else sRGB (also for CMYK or damaged profiles); `image.place` reports the profile's name as `imageProfile` (`null`: none usable, taken as sRGB). PNG exports are tagged (`sRGB` chunk, or a Display P3 ICC profile plus @@ -349,7 +350,7 @@ a later step can use `{"$step": 0, "field": "ids/0"}` to target the first match. | Tool | What it does | |---|---| | `list_commands {filter?}` | Every command: id, label, menu, shortcut, undoable, params doc. | -| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}`, `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). | +| `run_command {command, params}` | Run any command (e.g. `align.left`, `layout.add`, `component.create`, `instance.place` (drop an instance at a page point into the frame or auto layout slot under it), `component.exposeInstance` / `instance.exposed` (nested instance properties on instances), `component.makeSlot` / `instance.slotInsert` / `instance.resetSlot` (slots, below), `inspect.code` (CSS, HTML + CSS for a whole frame, React, Leptos, Vue, SwiftUI, Jetpack Compose, ArkUI or Flutter for a layer; components take their properties as parameters and instances are calls, see Dev Mode below), `inspect.properties` (layout, auto layout, typography, fills, strokes and effects with their style and variable names; `colorFormat`: hex, rgb, css, hsl or hsb), `inspect.assets` (layers under the selection with export settings, icons and images, each with its `kind`), `inspect.code` takes `unit: rem` and `rootFontSize` for CSS, HTML, React, Leptos and Vue, `style.create`, `variable.bind`, `variable.update` (rename, describe, scope), `variable.set` with `{alias}` or a colour calculation (`{alpha}`, `{mix}`), `variable.renameMode` / `variable.removeMode`, `variable.deleteCollection`, `proto.interaction.add`, `codeConnect.set` (components linked to real code, see Dev Mode below)). | | `batch {commands: [{command, params}], label?}` | Several commands as one undo step (`command.batch`), which keeps one copy of the document however many steps there are (an `edit.undo` step undoes the batch's edits so far together); all-or-nothing for open documents and their document state. Failed inner commands are removed from the journal; preferences, clipboard, tool state and UI requests are not rolled back. A step can use what an earlier step returned: name it with `"as": "card"` and `{"$ref": "card"}` anywhere in a later step's params becomes the `id` in its result (`{"$step": n}` does the same by zero-based position); `"field"` takes another key or a slash path such as `ids/0` (see Step references below). | | `create_node {type, x, y, width, height, parent, index, name, text, path, props}` | Create a frame, rectangle, ellipse, polygon, star, line, arrow, text, vector, section, component or slice; returns `{id, node}`. | | `set_properties {ids, props}` | `node.set` on layers (geometry, fills, strokes, effects, corner radius, auto layout, sizing, constraints, masks (`isMask`, `maskType: alpha\|vector\|luminance`), handoff status (`devStatus: none\|readyForDev\|completed`; marking again clears "Changed"), a section's `sectionContentsHidden`, text…); returns the updated layers. | @@ -1155,8 +1156,10 @@ frame back to Auto, inheriting its parent's mode). The same JSON carries variables between FigCraft files: `variable.export {collections?, format?, path?}` writes this file's collections (default: all but library copies) as a local variables -response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default -mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`, numbers +response (`format: "json"`, the default; colour calculations as the colour they show in each mode, +other collections in their default modes) or as CSS custom properties (`format: "css"`: the default +mode on `:root`, each other mode on `[data-=""]`, aliases as `var(--name)`, colour +calculations as the colour they show in that mode (other collections in their default modes), numbers in px unless the layers bound to them, their scopes or the variables aliasing them make them an opacity or a font weight, so `border-radius: var(--radius)` reads `12px`) or as Dart constants for Flutter (`format: "dart"`: an `abstract final class` per collection and mode, the From 17845674976fad2c31bb238d5c0bc6658d9c129f Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Fri, 30 Oct 2026 16:24:57 +0800 Subject: [PATCH 20/34] M3: Penpot tokens never reference a calculation a set leaves out (#1280) A colour calculation that resolves to nothing in a mode is left out of that mode's token set, but an alias to it still went out as a {name} reference to the missing token. Such an alias, and any alias leading to one, now goes out as the colour it shows in the mode, or is left out with the calculation's warning when it shows nothing. A calculation left out in only one mode of its collection counts too: a reference to it from another collection would dangle whenever that mode's theme is active. --- crates/format/src/penpot/export.rs | 57 +++++++++++++++++++++++- crates/format/src/penpot/export_tests.rs | 28 +++++++++++- 2 files changed, 81 insertions(+), 4 deletions(-) diff --git a/crates/format/src/penpot/export.rs b/crates/format/src/penpot/export.rs index 5f9adc109..4e1432128 100644 --- a/crates/format/src/penpot/export.rs +++ b/crates/format/src/penpot/export.rs @@ -8,7 +8,7 @@ use figcraft_doc::proto::{Action, Direction, Easing, Interaction, OverlayPositio use figcraft_doc::{ BoolOp, CellAlign, ContentAlign, CrossAlign, Document, EffectKind, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextDecoration, - TextStyle, VConstraint, VariableType, VariableValue, + TextStyle, VConstraint, Variable, VariableType, VariableValue, Variables, }; use serde_json::{Map, Value, json}; @@ -1701,6 +1701,11 @@ impl<'a> Exporter<'a> { (!out.is_empty()).then_some(Value::Object(out)) } + /// The archive's `tokens.json`: the file's own Penpot tokens when it came from Penpot and its + /// variables are unchanged since, else one token set per collection (per mode, with a theme + /// for each, when it has several). Aliases go out as `{name}` references and colour + /// calculations as the colour they show; a token that resolves to nothing is left out with a + /// warning, so no reference may name it. fn tokens_json(&mut self) -> Option { let first = self.doc.pages.first().and_then(|p| self.node(*p)); let source = first.and_then(|p| p.plugin_data.iter().find(|(k, _)| k.starts_with("penpot.tokens.")).map(|(_, v)| v.clone())); @@ -1715,6 +1720,7 @@ impl<'a> Exporter<'a> { if variables.variables.is_empty() { return None; } + let unreferenceable = unreferenceable(variables); let mut root = Map::new(); let mut themes = Vec::new(); let mut order = Vec::new(); @@ -1726,13 +1732,26 @@ impl<'a> Exporter<'a> { let set_name = if many { format!("{}/{}", collection.name, mode.name) } else { collection.name.clone() }; let mut tokens = Map::new(); for var in variables.variables.iter().filter(|v| v.collection == collection.id) { - let Some(value) = var.values.get(&mode.id).or_else(|| var.values.values().next()) else { continue }; + let Some(value) = mode_value(var, &mode.id) else { continue }; // Penpot tokens have no shape for colour calculations: the colour they resolve // to in this mode goes out as a plain colour token. let Some(value) = variables.flattened(var, &mode.id, value) else { self.warn(crate::CALCULATIONS_LEFT_OUT); continue; }; + // A reference to a token some set leaves out would dangle whenever that set is + // active: such an alias goes out as the colour it shows here instead. + let value = match value { + VariableValue::Alias(target) if unreferenceable.contains(target.as_str()) => { + let here = BTreeMap::from([(collection.id.clone(), mode.id.clone())]); + let Some(shown) = variables.resolve(&var.id, &here) else { + self.warn(crate::CALCULATIONS_LEFT_OUT); + continue; + }; + shown + } + other => other, + }; let (kind, value) = match &value { VariableValue::Alias(target) => { let Some(target_name) = self.token_name(target) else { continue }; @@ -1786,6 +1805,40 @@ impl<'a> Exporter<'a> { } } +/// The value a variable's token takes in `mode` (its first value when it has none for that mode). +fn mode_value<'v>(var: &'v Variable, mode: &str) -> Option<&'v VariableValue> { + var.values.get(mode).or_else(|| var.values.values().next()) +} + +/// Variables a token reference must not name: colour calculations some token set leaves out (they +/// resolve to nothing in a mode of their collection) and every alias that leads to one. +fn unreferenceable(variables: &Variables) -> BTreeSet<&str> { + let mut out = BTreeSet::new(); + for var in &variables.variables { + let Some(coll) = variables.collection_by_id(&var.collection) else { continue }; + if coll.modes.iter().any(|m| mode_value(var, &m.id).is_some_and(|v| variables.flattened(var, &m.id, v).is_none())) { + out.insert(var.id.as_str()); + } + } + let mut readers: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for var in &variables.variables { + for value in var.values.values() { + if let VariableValue::Alias(target) = value { + readers.entry(target.as_str()).or_default().push(var.id.as_str()); + } + } + } + let mut stack: Vec<&str> = out.iter().copied().collect(); + while let Some(id) = stack.pop() { + for reader in readers.get(id).into_iter().flatten() { + if out.insert(reader) { + stack.push(reader); + } + } + } + out +} + /// A layer's visibility, lock, opacity, blend, proportion lock and constraints as Penpot fields. fn appearance_fields(node: &Node, fields: &mut Map) { if !node.visible { diff --git a/crates/format/src/penpot/export_tests.rs b/crates/format/src/penpot/export_tests.rs index 576574e06..4dacd4eae 100644 --- a/crates/format/src/penpot/export_tests.rs +++ b/crates/format/src/penpot/export_tests.rs @@ -588,7 +588,8 @@ fn native_variables_become_token_sets_themes_and_applied_tokens() { } /// Penpot tokens have no shape for a colour calculation: each mode's token is the colour it shows, -/// and one that resolves to nothing is left out with a warning. +/// and one that resolves to nothing is left out with a warning. No reference names a token a set +/// leaves out: an alias to one goes out as the colour it shows, or is left out with it. #[test] fn colour_calculations_export_as_the_colour_they_show() { let mut doc = Document::new("Calculated"); @@ -614,11 +615,34 @@ fn colour_calculations_export_as_the_colour_they_show() { &[("light", VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }), ("dark", mix)], )); let gone = VariableValue::ColorAlpha { variable: "v-gone".into(), opacity: 0.5 }; - doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone)])); + doc.variables.variables.push(variable("v3", "brand/lost", "c1", VariableType::Color, &[("light", gone.clone()), ("dark", gone.clone())])); + // Aliases: to the lost one (same collection), to one lost only in Dark (from a collection with + // one mode, which reads Color in its default mode, Light), to an alias that is left out, and to + // one that always resolves. + let alias = |to: &str| VariableValue::Alias(to.into()); + doc.variables.variables.push(variable("v4", "brand/lost-too", "c1", VariableType::Color, &[("light", alias("v3")), ("dark", alias("v3"))])); + let half = VariableValue::ColorAlpha { variable: "v1".into(), opacity: 0.5 }; + doc.variables.variables.push(variable("v5", "brand/half", "c1", VariableType::Color, &[("light", half), ("dark", gone)])); + doc.variables.collections.push(Collection { + id: "c2".into(), + name: "Theme".into(), + modes: vec![Mode { id: "one".into(), name: "One".into() }], + extends: None, + }); + doc.variables.variables.push(variable("v6", "theme/accent", "c2", VariableType::Color, &[("one", alias("v5"))])); + doc.variables.variables.push(variable("v7", "theme/veil", "c2", VariableType::Color, &[("one", alias("v2"))])); + doc.variables.variables.push(variable("v8", "theme/deep", "c2", VariableType::Color, &[("one", alias("v4"))])); let (bytes, warnings) = export(&doc).unwrap(); assert!(warnings.iter().any(|w| w == crate::CALCULATIONS_LEFT_OUT), "{warnings:?}"); let tokens = tokens(&bytes); assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost").is_none()), "left out, not written"); + assert!(["Color/Light", "Color/Dark"].iter().all(|set| tokens[set]["brand"].get("lost-too").is_none()), "an alias to it too"); + assert!(tokens["Color/Dark"]["brand"].get("half").is_none() && tokens["Color/Light"]["brand"]["half"]["$value"] == "#ff000080"); + assert_eq!(tokens["Theme"]["theme"]["accent"]["$value"], "#ff000080", "the colour it shows, not {{brand.half}}"); + assert_eq!(tokens["Theme"]["theme"]["veil"]["$value"], "{brand.veil}", "a reference that always resolves stays one"); + assert!(tokens["Theme"]["theme"].get("deep").is_none(), "an alias to an alias that is left out is left out too"); + let text = tokens.to_string(); + assert!(!text.contains("{brand.lost") && !text.contains("{brand.half}"), "no reference to a token a set leaves out: {text}"); assert_eq!(tokens["Color/Light"]["brand"]["veil"]["$value"], "#ff000080"); assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$value"], "#008080c0"); assert_eq!(tokens["Color/Dark"]["brand"]["veil"]["$type"], "color"); From 55e5f31eb507b3525fd113fbb28f30dff719f491 Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Fri, 30 Oct 2026 16:24:57 +0800 Subject: [PATCH 21/34] M3: document the colour-calculation functions this change touches (#1280) Why type_of gives references no type, what display shows, how resolve_memo bounds one resolve, and what variable.create and variable.set check before anything changes. --- crates/doc/src/styles.rs | 11 +++++++++-- crates/engine/src/cmd/styles.rs | 8 ++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index a66a0935d..e7d104b93 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -140,17 +140,21 @@ impl VariableValue { } } + /// The type this value has on its own, which a typed literal needs. None for references + /// (aliases and colour calculations): what they read may be missing or loop, so only + /// resolving them gives a type, and an expression can't take one as a literal. pub fn type_of(&self) -> Option { match self { VariableValue::Color(_) => Some(VariableType::Color), VariableValue::Number(_) => Some(VariableType::Number), VariableValue::String(_) => Some(VariableType::String), VariableValue::Boolean(_) => Some(VariableType::Boolean), - // References have no type of their own: what they read may be missing or loop, so an - // expression can't take one as a typed literal. VariableValue::Alias(_) | VariableValue::ColorAlpha { .. } | VariableValue::ColorMix { .. } => None, } } + /// The value on one line, as the panel shows it: `→ id` for an alias, `α 50% · id` and + /// `from + to @ 40%` for colour calculations, variables by id ([`Self::display_with`] names + /// them). pub fn display(&self) -> String { self.display_with(&|id| id.to_string()) } @@ -406,6 +410,9 @@ impl Variables { var.values.get(mode).or_else(|| var.values.values().next()) } + /// [`Self::resolve`] with `done` shared by every variable one call reads: each is resolved once + /// and its outcome kept, one met again while still in flight is a cycle (None), and the call + /// reads at most [`Self::RESOLVE_LIMIT`] variables. fn resolve_memo( &self, id: &str, diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index ee7506da0..7253cf331 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -902,6 +902,10 @@ fn var_value(cmd: &str, vars: &Variables, kind: VariableType, for_id: Option<&Va }) } +/// `variable.create`: a variable with a value for every mode of its collection (`values` by mode, +/// then `value` for the modes left, then its type's default). Every value is checked before the +/// variable exists (alias targets, calculation operands, the 32-variable limit), so a refused one +/// leaves nothing behind. fn var_create(s: &mut Session, p: &Value) -> Result { let cmd = "variable.create"; let c = req_string(cmd, p, "collection")?; @@ -966,6 +970,10 @@ fn var_create(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": id})) } +/// `variable.set`: one mode's value (the default mode when none is named), checked against the +/// variable as it stands before anything changes: an alias or calculation may not lead back to it, +/// and a calculation must fit the 32-variable limit. On a variable an extended collection inherits, +/// the value becomes that mode's override. pub fn var_set(s: &mut Session, p: &Value) -> Result { let cmd = "variable.set"; let v = req_string(cmd, p, "variable")?; From c1451fecd53f0d640a1af14c1505e798854d4c71 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 15:44:09 +0700 Subject: [PATCH 22/34] M4: an imported file finds an installed family under the other names its fonts give it (#1292) (#1628) Fonts name a family by its typographic name and by a legacy name per weight; fonts without typographic names are installed under the legacy ones only. FontDb::resolve finds either from the other, and matches names across case, spaces, hyphens and underscores; every lookup (import, paste, REST import, rendering, missing fonts, the font report) uses it. A weight-number style goes by that weight, and .fig and REST imports wait for the installed fonts to be indexed. --- ROADMAP.md | 3 +- crates/doc/src/text.rs | 7 + crates/engine/src/cmd/file/load.rs | 12 +- crates/engine/src/fig_font_names_tests.rs | 38 ++++ crates/engine/src/lib.rs | 2 + crates/text/src/fontdb.rs | 219 +++++++++++++++++++++- crates/text/src/fontdb_names_tests.rs | 106 +++++++++++ docs/file-format-parity.md | 5 +- docs/roadmap.md | 3 +- 9 files changed, 381 insertions(+), 14 deletions(-) create mode 100644 crates/engine/src/fig_font_names_tests.rs create mode 100644 crates/text/src/fontdb_names_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 9cc118eaa..b153e1ca1 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-10-30 · **Change:** minor (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; 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; 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); 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); 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-10-30 · **Change:** minor (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; 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; 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); 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); 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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | | 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged | | 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | | 2026-10-30 | minor | M8, #1114 (the rest): `file.open` and `file.import` (and recovery copies), `image.place {path}`, the image fill's file picker and `figcraft-cli run`'s script read the path they were given whole, so a device (`/dev/zero`), a pipe or an absurd file could fill memory or wait forever. Every whole-file read of a path now goes through `file_access::read_capped`, which looks at the path before opening it and refuses a folder, a device or pipe, or a file over its cap, with a message saying which, and reads at most the cap even if the file grows; the uncapped `file_access::read` is gone. Caps: documents 16 GiB (a `.figcraft` keeps its images as they are: #1313's file is 7.4 GB on disk), placed images 256 MiB (what a document takes from outside, `ImageData::MAX_BYTES`), scripts 1 GiB; fonts (256 MiB), plugin files and variables files already had theirs. Numbers unchanged. Tests: `path_read_caps_tests`, `file_access` tests, the CLI's `a_script_that_isnt_a_regular_file_is_refused`. | diff --git a/crates/doc/src/text.rs b/crates/doc/src/text.rs index b0c3d7eaf..0ba0aeeca 100644 --- a/crates/doc/src/text.rs +++ b/crates/doc/src/text.rs @@ -379,6 +379,13 @@ impl TextStyle { /// Weight from a style name ("Semi Bold Italic" → 600). pub fn style_weight(style: &str) -> u16 { let s = style.to_ascii_lowercase().replace([' ', '-'], ""); + // A weight number ("600", "600 Italic"), as some apps name styles. + let digits: String = s.chars().take_while(char::is_ascii_digit).collect(); + if let Ok(n) = digits.parse::() + && (1..=1000).contains(&n) + { + return n; + } let table: [(&str, u16); 10] = [ ("thin", 100), ("extralight", 200), diff --git a/crates/engine/src/cmd/file/load.rs b/crates/engine/src/cmd/file/load.rs index 1ed6dafe3..a5c4167b8 100644 --- a/crates/engine/src/cmd/file/load.rs +++ b/crates/engine/src/cmd/file/load.rs @@ -140,6 +140,7 @@ fn load_any( if let Some(r) = figcraft_format::rest::try_import_with(bytes, variables, images) { let (mut doc, warnings) = r.map_err(|e| EngineError::Other(e.to_string()))?; link_instances(&mut doc); + installed_fonts_first(); return Ok((figcraft_format::Loaded { issues: warnings, ..doc.into() }, true)); } let mut loaded = figcraft_format::load_bytes_with_versions(bytes).map_err(|e| EngineError::Other(e.to_string()))?; @@ -381,11 +382,19 @@ pub(crate) fn installed_family(family: &str, installed: &[String]) -> Option Option> { let r = figcraft_format::fig::try_import(bytes, name)?; Some(r.map_err(|e| EngineError::Other(e.to_string())).map(|(mut doc, warnings)| { link_instances(&mut doc); + installed_fonts_first(); figcraft_format::Loaded { issues: warnings, ..doc.into() } })) } @@ -562,7 +571,8 @@ fn font_report_with_families(doc: &Document, installed: &[String]) -> Value { } else if known.is_empty() { None } else { - Some(known.contains(&lower)) + // Also under the other name its fonts give the family ("Economia Medium"). + Some(known.contains(&lower) || figcraft_text::has_family(&family)) }; json!({"family":family,"available":available,"layers":layers.into_iter().collect::>()}) }) diff --git a/crates/engine/src/fig_font_names_tests.rs b/crates/engine/src/fig_font_names_tests.rs new file mode 100644 index 000000000..a445624d2 --- /dev/null +++ b/crates/engine/src/fig_font_names_tests.rs @@ -0,0 +1,38 @@ +//! A `.fig` file whose text names an installed family another way than the font list does (#1292) +//! isn't reported missing and draws in that family: by the legacy name of one of its weights +//! (" Bold", style Regular) or spelt with other case and spaces. Through the real import +//! (a file the app exported), with an embedded family standing in for an installed one. + +use base64::Engine as _; +use figcraft_doc::NodeKind; +use serde_json::json; + +use crate::Session; + +fn text(s: &mut Session, family: &str) { + let id = s.execute("text.create", &json!({"text": "Hamburgefonstiv", "x": 0, "y": 0})).unwrap()["id"].as_u64().unwrap(); + if let NodeKind::Text(t) = &mut s.doc_mut().unwrap().get_mut(figcraft_doc::NodeId(id)).unwrap().kind { + t.style.font_family = family.into(); + } +} + +#[test] +fn an_imported_file_finds_installed_families_by_their_other_names() { + let Some(family) = figcraft_text::family_names().into_iter().find(|f| figcraft_text::family_styles(f).iter().any(|s| s == "Bold")) else { + return; + }; + let legacy = format!("{family} Bold"); + let squashed = family.to_uppercase().replace(' ', ""); + let mut s = Session::new(); + text(&mut s, &legacy); + text(&mut s, &squashed); + text(&mut s, "FigCraft Surely Missing"); + let r = s.execute("file.export", &json!({"format": "FIG"})).unwrap(); + let bytes = base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap(); + let (doc, _) = crate::cmd::file::load_bytes(&bytes, "names.fig").unwrap(); + let missing: Vec = crate::fonts::missing(&doc).into_iter().map(|m| m.family).collect(); + assert_eq!(missing, ["FigCraft Surely Missing"], "{family}"); + let bold = figcraft_text::find_face(&legacy, "Regular").unwrap(); + assert_eq!((bold.family.to_lowercase(), bold.weight), (family.to_lowercase(), 700)); + assert_eq!(figcraft_text::find_face(&squashed, "Bold").unwrap().family.to_lowercase(), family.to_lowercase()); +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6e570f54c..0f192557e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -307,6 +307,8 @@ mod fig_export_assets_tests; #[cfg(test)] mod fig_export_tests; #[cfg(test)] +mod fig_font_names_tests; +#[cfg(test)] mod fig_import_tests; #[cfg(test)] mod fill_none_tests; diff --git a/crates/text/src/fontdb.rs b/crates/text/src/fontdb.rs index 3ccaa8ef1..d64c6774a 100644 --- a/crates/text/src/fontdb.rs +++ b/crates/text/src/fontdb.rs @@ -365,6 +365,48 @@ fn norm_style(s: &str) -> String { s.to_lowercase().replace([' ', '-', '_'], "") } +/// A family name without spaces, hyphens and underscores (lower-case already). +fn squash(s: &str) -> String { + s.chars().filter(|c| !c.is_whitespace() && !matches!(c, '-' | '_')).collect() +} + +/// `s` is only words of a weight or slope (or a weight number): what a legacy family name adds to +/// the typographic one ("Medium", "Semi Bold", "600"). Not widths: a condensed cut is usually a +/// family of its own ("Roboto Condensed"), so a missing one isn't taken for its wider sibling. +fn style_words(s: &str) -> bool { + const WORDS: [&str; 23] = [ + "thin", + "hairline", + "extralight", + "ultralight", + "light", + "book", + "regular", + "normal", + "roman", + "medium", + "semibold", + "demibold", + "bold", + "extrabold", + "ultrabold", + "black", + "heavy", + "italic", + "oblique", + "semi", + "demi", + "extra", + "ultra", + ]; + let mut words = s.split_whitespace().peekable(); + words.peek().is_some() + && words.all(|w| { + let w = w.to_lowercase(); + WORDS.contains(&w.as_str()) || w.parse::().is_ok_and(|n| (1..=1000).contains(&n)) + }) +} + /// A style name that says italic. fn italic_name(s: &str) -> bool { s.to_lowercase().contains("italic") @@ -545,8 +587,25 @@ pub struct FontDb { wanted: Vec, /// Browser fonts asked for whose bytes haven't come. asked: std::collections::BTreeSet, + /// Bumped whenever families may have come or gone, so `names` is made again. + generation: u64, + /// Name lookups worked out as of a generation ([`key`](Self::key), [`resolve`](Self::resolve)). + names: std::sync::Mutex, } +/// [`FontDb::key`]'s and [`FontDb::resolve`]'s answers for the families known in one generation. +#[derive(Default)] +struct Names { + generation: u64, + /// Each family's key by its squashed name (none yet: not made since the generation began). + squashed: Option>, + /// (family, style) asked for ? (key, style) to draw with; at most [`MAX_RESOLVED`]. + resolved: std::collections::HashMap<(String, String), (String, String)>, +} + +/// The most name lookups kept (a file naming more starts the list again). +const MAX_RESOLVED: usize = 4096; + /// What a later walk of the font folders found ([`rescan_system_fonts`]). #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct Rescan { @@ -590,6 +649,7 @@ fn raw_db() -> &'static RwLock { impl FontDb { pub fn add(&mut self, face: Face) -> Arc { + self.generation += 1; let key = face.family.to_lowercase(); let display = face.family.clone(); let face = Arc::new(face); @@ -653,29 +713,156 @@ impl FontDb { v.push((w, f.italic || italic_name(&inst.name), k, inst.name.clone())); } } + // A family installed as one family per weight offers those weights too. + for (_, style) in self.split_families(&key) { + v.push((pending_weight(&style).clamp(1, 1000) as u16, italic_name(&style), 0, style)); + } v.sort(); let mut seen = std::collections::HashSet::new(); v.retain(|x| seen.insert(norm_style(&x.3))); v.into_iter().map(|x| x.3).collect() } - /// The key `family` is known under: itself, or for a name designs use for Apple's system font - /// (SF Pro, SF UI Text, …) the system font when no font of that name is installed. + /// The key `family` is known under: itself in any case; for a name designs use for Apple's + /// system font (SF Pro, SF UI Text, …) the system font when no font of that name is installed; + /// else a family spelled the same but for spaces, hyphens and underscores ("SFPro", "Font + /// Awesome"). Unknown: `family` in lower case. pub fn key(&self, family: &str) -> String { - let k = family.to_lowercase(); - let known = |k: &str| self.families.contains_key(k) || self.pending.contains_key(k); - if known(&k) { + let k = family.trim().to_lowercase(); + if self.known(&k) { return k; } - match apple_alias(&k) { - Some(target) if known(target) => target.to_string(), - _ => k, + if let Some(target) = apple_alias(&k).filter(|t| self.known(t)) { + return target.to_string(); } + let mut names = self.names(); + let squashed = names.squashed.get_or_insert_with(|| { + let mut map = std::collections::HashMap::new(); + for key in self.families.keys().chain(self.pending.keys()) { + map.entry(squash(key)).or_insert_with(|| key.clone()); + } + map + }); + squashed.get(&squash(&k)).cloned().unwrap_or(k) } + /// The name lookups of this generation (those of an earlier one are dropped). + fn names(&self) -> std::sync::MutexGuard<'_, Names> { + let mut names = self.names.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if names.generation != self.generation { + *names = Names { generation: self.generation, ..Names::default() }; + } + names + } + + fn known(&self, key: &str) -> bool { + self.families.contains_key(key) || self.pending.contains_key(key) + } + + /// Family `family` is installed: by that name ([`key`](Self::key)), or under the other way + /// its fonts name it ([`resolve`](Self::resolve)). pub fn has_family(&self, family: &str) -> bool { let k = self.key(family); - self.families.contains_key(&k) || self.pending.contains_key(&k) + self.known(&k) || self.split_request(family, "Regular").is_some() || !self.split_families(&k).is_empty() + } + + /// The family key and style to draw `family` + `style` with. Fonts name a family two ways: by + /// its typographic name ("Economia", style "Medium") and, for apps that only know four styles + /// a family, by a legacy name per weight ("Economia Medium", style "Regular"); fonts without + /// typographic names are installed under the legacy ones only. A design may use either, so + /// either finds the other: a family named with style words finds the family without them, + /// and a style the family lacks finds the family named with it. Else `family`'s key as is. + pub fn resolve(&self, family: &str, style: &str) -> (String, String) { + let asked = (family.to_string(), style.to_string()); + if let Some(found) = self.names().resolved.get(&asked) { + return found.clone(); + } + let found = self.resolve_now(family, style); + let mut names = self.names(); + if names.resolved.len() >= MAX_RESOLVED { + names.resolved.clear(); + } + names.resolved.insert(asked, found.clone()); + found + } + + fn resolve_now(&self, family: &str, style: &str) -> (String, String) { + let k = self.key(family); + // Most families have no family named after them and a weight: nothing more to look at. + if self.known(&k) && (!self.has_weight_families(&k) || self.style_known(&k, style)) { + return (k, style.to_string()); + } + if let Some(found) = self.weight_family(&k, style) { + return found; + } + if self.known(&k) { + return (k, style.to_string()); + } + self.split_request(family, style).unwrap_or((k, style.to_string())) + } + + /// Some family is named `key` and then more words ("economia medium" for "economia"). + fn has_weight_families(&self, key: &str) -> bool { + let prefix = format!("{key} "); + let after = |k: Option<&String>| k.is_some_and(|k| k.starts_with(&prefix)); + after(self.families.range(prefix.clone()..).next().map(|(k, _)| k)) || after(self.pending.range(prefix.clone()..).next().map(|(k, _)| k)) + } + + /// Family `key` has a face (or an indexed one) named `style`. + fn style_known(&self, key: &str, style: &str) -> bool { + let faces = self.families.get(key).map(|(_, idx)| idx.as_slice()).unwrap_or_default(); + faces.iter().filter_map(|i| self.faces.get(*i)).any(|f| has_style(f, style)) + || self.pending.get(key).is_some_and(|(_, entries)| entries.iter().any(|e| norm_style(&e.style) == norm_style(style))) + } + + /// The legacy family for `style` of family `key` ("economia medium" for Medium; for Medium + /// Italic, "economia medium italic", else "economia medium" Italic). + fn weight_family(&self, key: &str, style: &str) -> Option<(String, String)> { + let upright: Vec<&str> = style.split_whitespace().filter(|w| !matches!(w.to_lowercase().as_str(), "italic" | "oblique")).collect(); + if upright.is_empty() || !style_words(&upright.join(" ")) || norm_style(&upright.join("")) == "regular" { + return None; + } + let whole = self.key(&format!("{key} {style}")); + if self.known(&whole) { + return Some((whole, "Regular".into())); + } + let weight = self.key(&format!("{key} {}", upright.join(" "))); + self.known(&weight).then(|| (weight, if italic_name(style) { "Italic" } else { "Regular" }.into())) + } + + /// `family` named with style words ("Economia Medium") when only the family without them is + /// known: that family, with those words for its style (and `style` after them unless it is + /// Regular). + fn split_request(&self, family: &str, style: &str) -> Option<(String, String)> { + let words: Vec<&str> = family.split_whitespace().collect(); + (1..words.len()).rev().find_map(|i| { + let (base, rest) = (words[..i].join(" "), words[i..].join(" ")); + let k = self.key(&base); + (style_words(&rest) && self.known(&k)).then(|| { + let style = match norm_style(style).as_str() { + "" | "regular" | "normal" => rest, + _ => format!("{rest} {style}"), + }; + (k, style) + }) + }) + } + + /// Known families named `key` and then style words ("economia medium" for "economia"): with + /// those words as the family's display name spells them ("Medium"). + fn split_families(&self, key: &str) -> Vec<(String, String)> { + let prefix = format!("{key} "); + let mut out: Vec<(String, String)> = Vec::new(); + let after = |k: &&String| k.starts_with(&prefix); + let families = self.families.range(prefix.clone()..).map(|(k, (d, _))| (k, d)).take_while(|(k, _)| after(k)); + let pending = self.pending.range(prefix.clone()..).map(|(k, (d, _))| (k, d)).take_while(|(k, _)| after(k)); + for (k, display) in families.chain(pending) { + let rest = display.chars().skip(prefix.chars().count()).collect::(); + if style_words(&rest) && !out.iter().any(|(o, _)| o == k) { + out.push((k.clone(), rest.trim().to_string())); + } + } + out } /// Read a system family's faces from disk; with `only`, just the face nearest that style (the @@ -704,6 +891,7 @@ impl FontDb { vec![one] } }; + self.generation += 1; let entries = self.keep_local(&key, display.as_deref().unwrap_or(family), entries, only.is_some()); let mut by_path: BTreeMap>> = BTreeMap::new(); for e in entries { @@ -727,6 +915,8 @@ impl FontDb { /// The best face for `family` + `style` (exact style name, else nearest weight/italic). pub fn find(&mut self, family: &str, style: &str) -> Option> { + let (family, style) = self.resolve(family, style); + let (family, style) = (family.as_str(), style.as_str()); self.load_family(family, None); // A font the browser named is read one style at a time: the one asked for, unless a face // read already has it. @@ -740,6 +930,8 @@ impl FontDb { /// `style`: a fallback for missing characters needs one face, not every file of a CJK family /// (10–20 MB each). The family's other faces load when it is used by name. pub fn find_one(&mut self, family: &str, style: &str) -> Option> { + let (family, style) = self.resolve(family, style); + let (family, style) = (family.as_str(), style.as_str()); if !self.families.contains_key(&self.key(family)) { self.load_family(family, Some(style)); } @@ -819,6 +1011,7 @@ impl FontDb { /// Of `entries` (taken from a family's pending ones), those to read from disk: the browser's /// go back to wait (under `display`), and with `ask` are asked for ([`take_wanted_local_fonts`]). fn keep_local(&mut self, key: &str, display: &str, entries: Vec, ask: bool) -> Vec { + self.generation += 1; let (local, files): (Vec, Vec) = entries.into_iter().partition(|e| e.local.is_some()); if local.is_empty() { return files; @@ -846,6 +1039,7 @@ impl FontDb { /// Offer the fonts the browser named: they join the families by name (a family the app has /// keeps its faces, and the styles it lacks wait beside them). The families that were unknown. pub fn add_local(&mut self, fonts: Vec) -> Vec { + self.generation += 1; let before: std::collections::BTreeSet = self.families().into_iter().collect(); let mut found: BTreeMap)> = BTreeMap::new(); for f in fonts.into_iter().filter(|f| !f.family.trim().is_empty()) { @@ -869,6 +1063,7 @@ impl FontDb { pub fn load_local(&mut self, id: usize, bytes: Vec) -> Option { self.asked.remove(&id); let (key, i) = self.pending.iter().find_map(|(k, (_, v))| v.iter().position(|e| e.local == Some(id)).map(|i| (k.clone(), i)))?; + self.generation += 1; let (display, entries) = self.pending.get_mut(&key)?; let (display, e) = (display.clone(), entries.remove(i)); if entries.is_empty() { @@ -885,6 +1080,7 @@ impl FontDb { /// family not loaded yet takes the walk's files, and a loaded family keeps its faces and gets /// the files it hasn't read (styles installed since) as pending, read when it is next used. fn merge_rescan(&mut self, found: BTreeMap)>) -> Rescan { + self.generation += 1; let mut out = Rescan::default(); for (k, (display, entries)) in found { if self.families.contains_key(&k) { @@ -926,6 +1122,7 @@ impl FontDb { /// its faces, which win, and the installed styles it lacks wait beside them (#1246: release /// builds embed Poppins Regular, Italic and Bold, so an installed Poppins SemiBold went unseen). fn merge_scan(&mut self, found: BTreeMap)>) { + self.generation += 1; for (k, (display, mut entries)) in found { if let Some((_, idx)) = self.families.get(&k) { let have: Vec<&Arc> = idx.iter().filter_map(|i| self.faces.get(*i)).collect(); @@ -1360,3 +1557,7 @@ mod tests; #[cfg(test)] #[path = "fontdb_local_tests.rs"] mod local_tests; + +#[cfg(test)] +#[path = "fontdb_names_tests.rs"] +mod names_tests; diff --git a/crates/text/src/fontdb_names_tests.rs b/crates/text/src/fontdb_names_tests.rs new file mode 100644 index 000000000..0d31b7c56 --- /dev/null +++ b/crates/text/src/fontdb_names_tests.rs @@ -0,0 +1,106 @@ +//! A family is found however a design names it (#1292): fonts name a family by its typographic +//! name ("Economia", style "Medium") and by a legacy name per weight ("Economia Medium", style +//! "Regular"), and fonts without typographic names are installed under the legacy ones only. A +//! design (an imported file, a paste) may use either, or spell the family with other spaces or +//! case; the picker lists what is installed. Synthetic family names only, over pending entries +//! (no files) and the embedded faces when the build has them. + +use std::sync::Arc; + +use super::{CRAFT_FONTS, Face, FontBytes, FontDb, Pending, style_words}; + +fn entry(style: &str) -> Pending { + Pending { path: format!("no-such-dir/{style}.ttf").into(), index: 0, style: style.into(), postscript: None, local: None } +} + +fn installed(families: &[(&str, &[&str])]) -> FontDb { + let mut db = FontDb::default(); + for (family, styles) in families { + db.pending.insert(family.to_lowercase(), ((*family).into(), styles.iter().map(|s| entry(s)).collect())); + } + db +} + +fn resolved(db: &FontDb, family: &str, style: &str) -> (String, String) { + db.resolve(family, style) +} + +fn pair(family: &str, style: &str) -> (String, String) { + (family.into(), style.into()) +} + +#[test] +fn a_legacy_family_name_finds_the_typographic_family() { + // The fonts have typographic names: one family with every weight. + let db = installed(&[("Synthetic Grotesk", &["Regular", "Medium", "SemiBold", "Bold", "SemiBold Italic"])]); + assert_eq!(resolved(&db, "Synthetic Grotesk Medium", "Regular"), pair("synthetic grotesk", "Medium")); + assert_eq!(resolved(&db, "Synthetic Grotesk SemiBold", "Italic"), pair("synthetic grotesk", "SemiBold Italic")); + assert_eq!(resolved(&db, "Synthetic Grotesk Regular", "Regular"), pair("synthetic grotesk", "Regular")); + assert!(db.has_family("Synthetic Grotesk Medium")); + // The typographic name as is. + assert_eq!(resolved(&db, "Synthetic Grotesk", "Semibold"), pair("synthetic grotesk", "Semibold")); + // A width isn't a weight: a missing condensed family stays missing. + assert!(!db.has_family("Synthetic Grotesk Condensed")); + assert!(!db.has_family("Synthetic"), "a family isn't found by a part of its name"); +} + +#[test] +fn a_typographic_name_finds_a_family_installed_per_weight() { + // The fonts have only legacy names: a family per weight beyond the four basic styles. + let db = installed(&[ + ("Synthetic Serif", &["Regular", "Italic", "Bold", "Bold Italic"]), + ("Synthetic Serif Medium", &["Regular", "Italic"]), + ("Synthetic Serif SemiBold", &["Regular"]), + ]); + assert_eq!(resolved(&db, "Synthetic Serif", "Medium"), pair("synthetic serif medium", "Regular")); + assert_eq!(resolved(&db, "Synthetic Serif", "Medium Italic"), pair("synthetic serif medium", "Italic")); + assert_eq!(resolved(&db, "Synthetic Serif", "Semi Bold"), pair("synthetic serif semibold", "Regular")); + assert_eq!(resolved(&db, "Synthetic Serif", "Bold"), pair("synthetic serif", "Bold"), "a style the family has stays in it"); + // A weight number keeps the family and goes by weight. + assert_eq!(resolved(&db, "Synthetic Serif", "600"), pair("synthetic serif", "600")); + assert_eq!(figcraft_doc::style_weight("600"), 600); + assert_eq!(figcraft_doc::style_weight("600 Italic"), 600); + // Only some weights installed: the family is there all the same. + let db = installed(&[("Synthetic Serif Medium", &["Regular"])]); + assert!(db.has_family("Synthetic Serif")); + assert_eq!(resolved(&db, "Synthetic Serif", "Medium"), pair("synthetic serif medium", "Regular")); +} + +#[test] +fn case_and_spacing_dont_matter() { + let db = installed(&[("Synthetic Mono", &["Regular"])]); + for name in ["synthetic mono", "SYNTHETIC MONO", " Synthetic Mono ", "SyntheticMono", "Synthetic-Mono", "Synthetic_Mono"] { + assert!(db.has_family(name), "{name}"); + assert_eq!(db.key(name), "synthetic mono", "{name}"); + } + assert!(!db.has_family("Synthetic Monospace")); +} + +#[test] +fn style_words_are_weights_and_slopes() { + for s in ["Medium", "Semi Bold", "SemiBold Italic", "600", "Extra Light", "Black Oblique"] { + assert!(style_words(s), "{s}"); + } + for s in ["", "Sans", "Condensed", "Display", "Text", "1200"] { + assert!(!style_words(s), "{s}"); + } +} + +#[test] +fn faces_are_found_and_listed_under_either_name() { + // An embedded face stands in for an installed one, filed under synthetic legacy names. + let Some(f) = CRAFT_FONTS.first() else { return }; + let face = |family: &str, style: &str| Face::from_bytes(FontBytes::Static(f.bytes), 0, Some(family), Some(style)).unwrap(); + let mut db = FontDb::default(); + db.add(face("Synthetic Display", "Regular")); + let medium = db.add(face("Synthetic Display Medium", "Regular")); + let found = db.find("Synthetic Display", "Medium").unwrap(); + assert!(Arc::ptr_eq(&found, &medium), "the legacy family's face"); + assert!(db.styles("Synthetic Display").contains(&"Medium".to_string()), "{:?}", db.styles("Synthetic Display")); + // The other way round: a design names the legacy family, the typographic one is installed. + let mut db = FontDb::default(); + db.add(face("Synthetic Text", "Regular")); + let semibold = db.add(face("Synthetic Text", "SemiBold")); + assert!(Arc::ptr_eq(&db.find("Synthetic Text SemiBold", "Regular").unwrap(), &semibold)); + assert!(Arc::ptr_eq(&db.find("synthetictext", "Semibold").unwrap(), &semibold)); +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b14ac20a9..e98ed21ad 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-10-30 · **Change:** minor (`.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-10-30 · **Change:** minor (imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.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 @@ -181,12 +181,13 @@ source paint coordinates. | Local fonts | yes (font helper / desktop app) | yes (system scan) | | Bundled/shared fonts | Google Fonts in the cloud | optional `CRAFT_FONTS_DIR` build input (storytold/craft-fonts) | | Variable fonts | yes | yes (axes, named instances, per-range axis values) | -| Missing-font replacement on open | yes | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font. A family the font store has (Google Fonts, Noto) downloads from the window once the user allows downloading fonts (`font.download`, #754): pinned, checksum-verified files into the Craft apps' shared font folder, after which the text draws in it. No substitute picker | +| Missing-font replacement on open | yes | listed, not replaced: in the desktop app the Missing fonts window opens after a file that uses them opens and from Text ▸ Missing fonts (`text.missingFonts`; `figcraft-cli info` prints `missing font` lines), and their text draws in a fallback font. A family the font store has (Google Fonts, Noto) downloads from the window once the user allows downloading fonts (`font.download`, #754): pinned, checksum-verified files into the Craft apps' shared font folder, after which the text draws in it. A family is found however a file names it: by the legacy name of one of its weights ("Family Medium") or by the typographic name of a family installed one family per weight, and spelt with other case, spaces or hyphens (#1292). No substitute picker | ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Fonts: the missing-font row adds that a family is found under the other names its fonts give it (legacy per-weight names and typographic ones) and with other case and spacing, so a `.fig`, REST or pasted file no longer reports an installed family missing (#1292). Numbers unchanged | | 2026-10-30 | minor | M8, #574: `.fig` import budgets four decoded values per compressed canvas byte instead of one; the 20M floor and the 128M ceiling (32M on 32-bit targets) are unchanged. Dense legitimate files went past one value per compressed byte (further since every scalar field counts) and were refused whole as "not a valid FigCraft file: .fig file: message: too many values". Generated 21 MB and 15 MB canvases (220,044 and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole without warnings; a 29 KB canvas that unpacks to 30 million values is still refused. Files that now open instead of being refused take the memory they need: the 49.7 MB canvas's test peaks at about 9 GB. Tests: `crates/format/src/fig/value_budget_tests.rs` (the heavy ones with `--ignored`) and `large_files_get_a_larger_value_budget` in `crates/format/src/fig/tests.rs`. Numbers unchanged. | | 2026-10-30 | minor | M8, #1076: `.fig` ZIP images now share a budget of 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images. The 256 MiB member cap stays; members that do not fit are skipped before allocation with an import warning. Failed reads are charged too, repeated references keep their cached resolution, and smaller later members can still fit. Embedded images and decoded pixel memory are outside this budget. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | | 2026-10-30 | minor | M8, #1114: a document opened or imported by path must be a regular file of at most 16 GiB, and an image placed by path one of at most 256 MiB: a folder, a device or a pipe is refused before anything is read. Real files open as before. Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 9260eba56..1aacb7325 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (a panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | | 2026-10-30 | minor | M8, #1085 (the rest): a panic in a `Session::grouped` closure outside any command (plugin, widget, data fill and CLI groups) skipped the group rollback, leaving documents half edited, the history limit unbounded, live documents held and the group depth raised. `checkpointed` now catches the unwind, runs the same rollback as a failed group and resumes the panic, so the outer guard still reports it. Journal rollover and the browser (wasm aborts) are unchanged. 3 regression tests (`group_panic_tests`). | | 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged | | 2026-10-30 | minor | M8, #1076: `.fig` ZIP image reads share a total budget, 4 GiB on 64-bit targets and 1 GiB on 32-bit targets, keeping the 256 MiB per-image cap. Members that do not fit are skipped before allocation with an import warning; failed reads consume their bytes, repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | From d17de284da5683840e8a6e35559ab86dce9c3831 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 15:53:33 +0700 Subject: [PATCH 23/34] Tests: read the docs' examples whatever the checkout's line endings (#1645) inspect_fields::compact_examples_run_verbatim_from_the_docs split docs/mcp.md and docs/control-protocol.md on text ending in a newline, so on a Windows checkout with core.autocrlf (CRLF docs) it panicked and cargo xtask ci failed locally. The test now reads both with LF line ends. Found while gating on Windows. --- crates/mcp/tests/it/inspect_fields.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/crates/mcp/tests/it/inspect_fields.rs b/crates/mcp/tests/it/inspect_fields.rs index 19119aeb8..930ba3c78 100644 --- a/crates/mcp/tests/it/inspect_fields.rs +++ b/crates/mcp/tests/it/inspect_fields.rs @@ -154,6 +154,11 @@ fn compact_children_keep_window_markers_and_page_in_order() { assert_eq!(&seen, expected); } +/// `text` with Unix line ends: a Windows checkout with `core.autocrlf` has the docs with CRLF. +fn lf(text: &str) -> String { + text.replace("\r\n", "\n") +} + fn json_example(section: &str) -> &str { section.split("```json\n").nth(1).unwrap().split("```").next().unwrap() } @@ -164,7 +169,7 @@ fn compact_examples_run_verbatim_from_the_docs() { for _ in 0..2 { c.create(json!({"type": "rectangle"})); } - let docs = include_str!("../../../../docs/mcp.md"); + let docs = lf(include_str!("../../../../docs/mcp.md")); let section = docs.split("### Compact layer trees\n").nth(1).unwrap(); for line in json_example(section).lines() { let reply: Value = serde_json::from_str(&c.server.handle_line(line).unwrap()).unwrap(); @@ -174,7 +179,7 @@ fn compact_examples_run_verbatim_from_the_docs() { for _ in 0..2 { h.call("engine.execute", json!({"command": "node.create", "params": {"type": "rectangle"}})).unwrap(); } - let docs = include_str!("../../../../docs/control-protocol.md"); + let docs = lf(include_str!("../../../../docs/control-protocol.md")); let section = docs.split("### `node.get ").nth(1).unwrap(); for line in json_example(section).lines() { let request: Value = serde_json::from_str(line).unwrap(); From 34fce9bfcfdc6cf381d23d3b4aa8495d96f0fe0a Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 15:59:13 +0700 Subject: [PATCH 24/34] M12: create the next board shape already connected (#1631) * M12: create the next board shape already connected (#1597) A selected sticky or shape shows a + beside each side; hovering one previews where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector, and starts typing in it, in one undo step. board.addConnected {id?, side?, edit?} does it from commands. * M12: the connected-shape handle and command in every full catalog (#1597) * M12: Shift+Cmd/Ctrl+Enter on a board shape adds the next one already connected (#1597) --- ROADMAP.md | 4 +- crates/engine/src/board_connected_tests.rs | 106 +++++++++++++++++++ crates/engine/src/boards/board.rs | 12 ++- crates/engine/src/boards/board/items.rs | 83 +++++++++++++-- crates/engine/src/keymap.rs | 2 +- crates/engine/src/lib.rs | 2 + crates/ui-egui/locales/ar.toml | 2 + crates/ui-egui/locales/bn.toml | 2 + crates/ui-egui/locales/cs.toml | 2 + crates/ui-egui/locales/de.toml | 2 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 2 + crates/ui-egui/locales/fi.toml | 2 + crates/ui-egui/locales/fr.toml | 2 + crates/ui-egui/locales/id.toml | 2 + crates/ui-egui/locales/it.toml | 2 + crates/ui-egui/locales/ja.toml | 2 + crates/ui-egui/locales/ka.toml | 2 + crates/ui-egui/locales/ko.toml | 2 + crates/ui-egui/locales/nl.toml | 2 + crates/ui-egui/locales/pl.toml | 2 + crates/ui-egui/locales/pt-BR.toml | 2 + crates/ui-egui/locales/ru.toml | 2 + crates/ui-egui/locales/sv.toml | 2 + crates/ui-egui/locales/th.toml | 2 + crates/ui-egui/locales/tr.toml | 2 + crates/ui-egui/locales/vi.toml | 2 + crates/ui-egui/locales/zh-Hans.toml | 2 + crates/ui-egui/src/board_connect_tests.rs | 103 ++++++++++++++++++ crates/ui-egui/src/board_ui_tests.rs | 17 +++ crates/ui-egui/src/canvas.rs | 2 + crates/ui-egui/src/canvas/board_bar.rs | 6 +- crates/ui-egui/src/canvas/board_connect.rs | 115 +++++++++++++++++++++ crates/ui-egui/src/lib.rs | 3 + docs/boards-parity.md | 5 +- docs/mcp.md | 8 +- docs/roadmap.md | 4 +- 37 files changed, 500 insertions(+), 15 deletions(-) create mode 100644 crates/engine/src/board_connected_tests.rs create mode 100644 crates/ui-egui/src/board_connect_tests.rs create mode 100644 crates/ui-egui/src/canvas/board_connect.rs diff --git a/ROADMAP.md b/ROADMAP.md index b153e1ca1..614856bba 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-10-30 · **Change:** minor (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; 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; 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); 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); 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-10-30 · **Change:** minor (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; 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; 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); 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); 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 @@ -356,6 +356,8 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M12, #1597 (the third point): Shift+Cmd/Ctrl+Enter (`board.nextSticky`) now works on a selected or edited shape too: it adds the next shape of the same kind, size and colour on its right (or the `side` given), joined to it by a connector and with typing going on in it, as the "+" beside it does (`board.addConnected`, #1631). On a sticky it adds a sticky beside it without a connector, as before. Numbers unchanged. Tests: `board_connected_tests::the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were`, `board_ui_tests::the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing`. | +| 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | | 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | | 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged | | 2026-10-30 | minor | M8, #1076: bound total `.fig` ZIP image reads to 4 GiB (1 GiB on 32-bit targets) of decompressed ZIP-member bytes containing encoded images, keeping the 256 MiB member cap. Members that do not fit are skipped before allocation and counted in the import warnings; failed reads still consume their bytes, and repeated references keep their cached resolution. Embedded message images, decoded pixel memory and native `.figcraft` reads are unchanged. Regression tests in `fig::tests::image_budget` (format). Numbers unchanged. | diff --git a/crates/engine/src/board_connected_tests.rs b/crates/engine/src/board_connected_tests.rs new file mode 100644 index 000000000..0b91aad82 --- /dev/null +++ b/crates/engine/src/board_connected_tests.rs @@ -0,0 +1,106 @@ +//! `board.addConnected` (#1597): the next sticky or shape on a side of the selected one, of the +//! same kind, size and colour, joined to it by a connector, in one undo step. + +use figcraft_doc::{BoardItem, BoardShape, Connector, End, NodeId, Side}; +use serde_json::json; + +use crate::Session; +use crate::board::CONNECTED_GAP; +use crate::board_tests::{board, id, node}; + +fn connector(s: &Session, c: NodeId) -> Connector { + match &node(s, c).board { + Some(BoardItem::Connector(k)) => *k, + other => panic!("not a connector: {other:?}"), + } +} + +fn at(s: &Session, n: NodeId) -> (f64, f64) { + let t = node(s, n).transform.translation(); + (t.x, t.y) +} + +#[test] +fn a_shape_gets_the_next_one_joined_on_each_side() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120, "color": "blue"})).unwrap(), "id"); + let colour = |s: &Session, n: NodeId| node(s, node(s, n).children[0]).fills[0].solid_color(); + let g = CONNECTED_GAP; + for (side, want, ends) in [ + ("right", (200.0 + g, 0.0), (Side::Right, Side::Left)), + ("below", (0.0, 120.0 + g), (Side::Bottom, Side::Top)), + ("left", (-200.0 - g, 0.0), (Side::Left, Side::Right)), + ("above", (0.0, -120.0 - g), (Side::Top, Side::Bottom)), + ] { + let r = s.execute("board.addConnected", &json!({"id": a, "side": side})).unwrap(); + let (b, c) = (id(&r, "id"), id(&r, "connector")); + assert_eq!(at(&s, b), want, "{side}"); + let nb = node(&s, b); + assert_eq!((nb.width, nb.height), (200.0, 120.0), "{side}: the same size"); + assert!(matches!(nb.board, Some(BoardItem::Shape { shape: BoardShape::Diamond })), "{side}: the same shape"); + assert_eq!(colour(&s, b), colour(&s, a), "{side}: the same colour"); + let k = connector(&s, c); + assert_eq!((k.from, k.to), (End::Node { node: a, side: ends.0 }, End::Node { node: b, side: ends.1 }), "{side}"); + assert_eq!(k.routing, s.tool.board.routing, "{side}: as the connector tool makes one"); + assert_eq!(s.selection(), vec![b], "{side}: the new one is selected"); + } +} + +#[test] +fn a_sticky_gets_a_sticky_of_its_colour_and_typing_goes_on_in_it() { + let mut s = board(); + let a = id(&s.execute("board.sticky", &json!({"x": 0, "y": 0, "color": "pink", "text": "First"})).unwrap(), "id"); + s.ui_requests.clear(); + // The selection is the default, and editing is asked for. + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let r = s.execute("board.addConnected", &json!({"side": "below", "edit": true})).unwrap(); + let b = id(&r, "id"); + assert!(matches!(node(&s, b).board, Some(BoardItem::Sticky { .. }))); + assert_eq!(node(&s, b).fills[0].solid_color(), node(&s, a).fills[0].solid_color()); + assert_eq!(at(&s, b).1, node(&s, a).height + CONNECTED_GAP); + assert!(s.ui_requests.iter().any(|q| q.kind == "board.editText" && q.data["id"] == json!(b))); +} + +#[test] +fn it_is_one_undo_step_and_refuses_what_it_cant_join() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"shape": "ellipse", "x": 0, "y": 0})).unwrap(), "id"); + let before = s.doc().unwrap().ids().count(); + let r = s.execute("board.addConnected", &json!({"id": a})).unwrap(); + assert_eq!(at(&s, id(&r, "id")).0, 160.0 + CONNECTED_GAP, "right by default"); + assert!(s.doc().unwrap().ids().count() > before); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().ids().count(), before, "the shape and its connector go together"); + // Not a sticky or shape, not a side, nothing selected. + let c = id(&s.execute("board.stamp", &json!({"x": 400, "y": 0, "stamp": "star"})).unwrap(), "id"); + assert!(s.execute("board.addConnected", &json!({"id": c})).is_err()); + assert!(s.execute("board.addConnected", &json!({"id": a, "side": "diagonal"})).is_err()); + s.execute("select.set", &json!({"ids": []})).unwrap(); + assert!(s.execute("board.addConnected", &json!({})).is_err()); + // Not on a design file. + let mut d = Session::new(); + assert!(d.execute("board.addConnected", &json!({})).is_err()); +} + +#[test] +fn the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were() { + let mut s = board(); + let a = id(&s.execute("board.shape", &json!({"shape": "rectangle", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap(), "id"); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let undo = s.ds().unwrap().history.undo.len(); + // Shift+Cmd/Ctrl+Enter on a shape: the next shape on the right, joined by a connector (#1597). + let r = s.execute("board.nextSticky", &json!({})).unwrap(); + let (b, c) = (id(&r, "id"), id(&r, "connector")); + assert!(matches!(node(&s, b).board, Some(BoardItem::Shape { shape: BoardShape::Rectangle }))); + assert_eq!(at(&s, b), (200.0 + CONNECTED_GAP, 0.0)); + assert_eq!(connector(&s, c).from, End::Node { node: a, side: Side::Right }); + assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1, "one undo step"); + // Another side, as the command takes one. + let r = s.execute("board.nextSticky", &json!({"id": a, "side": "below"})).unwrap(); + assert_eq!(at(&s, id(&r, "id")), (0.0, 120.0 + CONNECTED_GAP)); + // A sticky still gets a sticky beside it, without a connector. + let st = id(&s.execute("board.sticky", &json!({"x": 1000, "y": 0})).unwrap(), "id"); + let r = s.execute("board.nextSticky", &json!({"id": st})).unwrap(); + assert!(r.get("connector").is_none(), "{r}"); + assert!(matches!(node(&s, id(&r, "id")).board, Some(BoardItem::Sticky { .. }))); +} diff --git a/crates/engine/src/boards/board.rs b/crates/engine/src/boards/board.rs index b40bcc7d6..b1ebe81b9 100644 --- a/crates/engine/src/boards/board.rs +++ b/crates/engine/src/boards/board.rs @@ -36,8 +36,9 @@ pub use edit::label_of; pub(crate) use edit::{copied_items, copy_map, rejoin}; use edit::{label_cmd, set_color, set_style}; pub(crate) use items::add_text; +pub use items::{CONNECTED_GAP, connected_place}; +use items::{add_connected, ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd}; pub use items::{edit_text, owner_of_text, sticky_font_size, text_of}; -use items::{ink_cmd, next_sticky, shape_cmd, stamp_cmd, sticky_cmd}; /// Why a board command refuses on a design file. pub const NEEDS_BOARD: &str = "this is for boards: make one with File ▸ New board"; @@ -103,10 +104,17 @@ pub fn specs() -> Vec { spec!( "board.nextSticky", "Next sticky note", - "{id? (a sticky; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text} a sticky of the same colour beside it", + "{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector?} a sticky of the same colour beside a sticky; after a shape, the next shape joined to it by a connector, as board.addConnected", true, next_sticky ), + spec!( + "board.addConnected", + "Add connected shape", + "{id? (a sticky or shape; default the selected or edited one), side?: right|below|left|above (default right), edit? (start typing in it; default false)} → {id, text, connector} the next one of the same kind, size and colour on that side, joined by a connector", + true, + add_connected + ), spec!( "board.shape", "Shape with text", diff --git a/crates/engine/src/boards/board/items.rs b/crates/engine/src/boards/board/items.rs index b76fc8986..28b2cfec9 100644 --- a/crates/engine/src/boards/board/items.rs +++ b/crates/engine/src/boards/board/items.rs @@ -1,9 +1,10 @@ +use super::connectors::{CAPS, make}; use super::{ - AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Constraints, CrossAlign, Document, Effect, EffectKind, EngineError, - HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, STICKY_GAP, - STICKY_PAD, Session, Shadow, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, Value, VectorNetwork, - author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, stamp_of, string, - textedit, + AUTHOR_INK, Affine, AutoLayout, BlendMode, BoardItem, BoardShape, Color, Connector, Constraints, CrossAlign, Document, Effect, EffectKind, End, + EngineError, HConstraint, HIGHLIGHTER, INK, LayoutMode, MARKER, MainAlign, Node, NodeId, NodeKind, Paint, Result, SHAPE, STAMP_SIZE, STICKY, + STICKY_GAP, STICKY_PAD, Session, Shadow, Side, Sizing, StrokeAlign, StrokeCap, StrokeJoin, TextAlignH, TextAutoResize, TextData, VConstraint, + Value, VectorNetwork, author, bad, boolean, color_param, get, id, id_of, json, line_shade, num, num_or, parent_of, req_num, require, shape_of, + stamp_of, string, textedit, }; /// A new layer at the top of `parent`, at `at` in it, made by `f`. @@ -144,10 +145,14 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result { let from = id(p, "id") .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) - .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?; + .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky or a shape first".into()))?; let n = doc.get(from)?; + if matches!(n.board, Some(BoardItem::Shape { .. })) { + // A shape gets the next one joined to it, as the "+" beside it makes (#1597). + return add_connected(s, p); + } if !matches!(n.board, Some(BoardItem::Sticky { .. })) { - return Err(bad(cmd, "that isn't a sticky")); + return Err(bad(cmd, "that isn't a sticky or a shape")); } let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky); let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?; @@ -172,6 +177,70 @@ pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result { Ok(json!({"id": id, "text": note})) } +/// The room between a board object and the next one `board.addConnected` makes, joined by a +/// connector: enough for the arrow to read. +pub const CONNECTED_GAP: f64 = 80.0; + +/// Where `board.addConnected` puts the next object on `side` of one at `at` sized `size`, `next` +/// big (its top-left), and the sides the connector joins (from, to). `None`: not a side. +pub fn connected_place(at: (f64, f64), size: (f64, f64), next: (f64, f64), side: &str) -> Option<((f64, f64), Side, Side)> { + let ((x, y), (w, h), (nw, nh)) = (at, size, next); + let (cx, cy) = (x + (w - nw) / 2.0, y + (h - nh) / 2.0); + Some(match side { + "right" => ((x + w + CONNECTED_GAP, cy), Side::Right, Side::Left), + "left" => ((x - CONNECTED_GAP - nw, cy), Side::Left, Side::Right), + "below" => ((cx, y + h + CONNECTED_GAP), Side::Bottom, Side::Top), + "above" => ((cx, y - CONNECTED_GAP - nh), Side::Top, Side::Bottom), + _ => return None, + }) +} + +/// `board.addConnected`: the next sticky or shape on a side of one, of the same kind, size and +/// colour, joined to it by a connector the way the connector tool makes one; one undo step. +pub(super) fn add_connected(s: &mut Session, p: &Value) -> Result { + let cmd = "board.addConnected"; + require(s, cmd)?; + let editing = s.ds()?.text_edit.as_ref().map(|t| t.id); + let doc = s.doc()?; + let from = id(p, "id") + .or_else(|| editing.and_then(|t| owner_of_text(doc, t))) + .or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i))) + .ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky or a shape first".into()))?; + let n = doc.get(from)?; + let shape = match &n.board { + Some(BoardItem::Sticky { .. }) => None, + Some(BoardItem::Shape { shape }) => Some(*shape), + _ => return Err(bad(cmd, "that isn't a sticky or a shape")), + }; + let side = string(p, "side").unwrap_or_else(|| "right".into()); + let parent = n.parent.ok_or_else(|| bad(cmd, "that object has no parent"))?; + let at = n.transform.translation(); + let colour_of = |n: &Node| n.fills.iter().find_map(Paint::solid_color); + let (next, color) = match shape { + None => ((STICKY, STICKY), colour_of(n)), + Some(_) => ((n.width, n.height), n.children.first().and_then(|c| doc.node(*c)).and_then(colour_of)), + }; + let color = color.unwrap_or(s.tool.board.sticky); + let (to_at, from_side, to_side) = connected_place((at.x, at.y), (n.width, n.height), next, &side) + .ok_or_else(|| bad(cmd, format!("`side` is right, below, left or above, not `{side}`")))?; + let (routing, author, page) = (s.tool.board.routing, author(s), s.page()?); + if editing.is_some() { + textedit::end(s)?; + } + let doc = s.doc_mut()?; + let (id, text) = match shape { + None => make_sticky(doc, parent, to_at, "", color, &author)?, + Some(shape) => make_shape(doc, parent, to_at, next, shape, "", color)?, + }; + let connector = Connector { from: End::Node { node: from, side: from_side }, to: End::Node { node: id, side: to_side }, routing }; + let line = make(doc, page, connector, CAPS)?; + s.set_selection(vec![id]); + if boolean(p, "edit") == Some(true) { + s.request_ui("board.editText", json!({"id": id})); + } + Ok(json!({"id": id, "text": text, "connector": line})) +} + /// A shape with text's layers: the frame, its outline and its text. fn make_shape( doc: &mut Document, diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index 6f0641db1..6eeb28263 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -201,7 +201,7 @@ pub static KEYS: &[Binding] = &[ k(Tools, "tool.highlighter", &["U"]), k(Tools, "tool.eraser", &["E"]), k(Tools, "tool.stamp", &["W"]), - k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one").both(), + k(Tools, "board.nextSticky", &["Shift+Cmd+Enter"]).label("Board: a new sticky beside the selected one, or the next shape joined to it").both(), // View // The documentation gives both keys for the pixel grid in different places. k(View, "view.pixelGrid", &["Shift+'", "Cmd+'"]), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 0f192557e..e1f4c785a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -201,6 +201,8 @@ mod batch_undo_snapshot_tests; #[cfg(test)] mod binding_lists_tests; #[cfg(test)] +mod board_connected_tests; +#[cfg(test)] mod board_connector_tests; #[cfg(test)] mod board_grid_tests; diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index 4a988de6a..464424632 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -39,6 +39,7 @@ status = "draft — needs native review" "Actual size (100%)" = "الحجم الفعلي (100%)" "Add a category" = "إضافة فئة" "Add a comment" = "إضافة تعليق" +"Add a connected shape" = "إضافة شكل متصل" "Add a description" = "إضافة وصف" "Add a frame (F) or a shape (R) to get started" = "أضف إطارًا (F) أو شكلًا (R) للبدء" "Add a layer or component to tile first" = "أضف طبقة أو مكوّنًا أولًا لتكراره بنمط متجاور" @@ -1907,6 +1908,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "تقريب إلى البكسلات" "arrange.spacing" = "التباعد" "arrange.tidy" = "ترتيب منظم" +"board.addConnected" = "إضافة شكل متصل" "board.connector" = "موصل" "board.ink" = "ضربة قلم التحديد" "board.label" = "تسمية الموصل" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 6e0e9fc73..d218b873a 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -34,6 +34,7 @@ status = "draft — needs native review" "Actual size (100%)" = "প্রকৃত আকার (১০০%)" "Add a category" = "একটি বিভাগ যোগ করুন" "Add a comment" = "একটি মন্তব্য যোগ করুন" +"Add a connected shape" = "সংযুক্ত আকার যোগ করুন" "Add a description" = "একটি বিবরণ যোগ করুন" "Add a frame (F) or a shape (R) to get started" = "শুরু করতে একটি ফ্রেম (F) বা শেপ (R) যোগ করুন" "Add a layer or component to tile first" = "প্রথমে টাইল করার জন্য একটি লেয়ার বা কম্পোনেন্ট যোগ করুন" @@ -1822,6 +1823,7 @@ status = "draft — needs native review" [commands] +"board.addConnected" = "সংযুক্ত আকার যোগ করুন" "board.connector" = "সংযোগকারী" "board.ink" = "মার্কারের টান" "board.label" = "সংযোগকারীর লেবেল" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index adfb45b7e..35a72091d 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -30,6 +30,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Skutečná velikost (100 %)" "Add a category" = "Přidat kategorii" "Add a comment" = "Přidat komentář" +"Add a connected shape" = "Přidat propojený tvar" "Add a description" = "Přidat popis" "Add a frame (F) or a shape (R) to get started" = "Začněte přidáním rámce (F) nebo tvaru (R)" "Add a layer or component to tile first" = "Nejprve přidejte vrstvu nebo komponentu k opakování" @@ -1809,6 +1810,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Zaokrouhlit na pixely" "arrange.spacing" = "Rozestupy" "arrange.tidy" = "Uspořádat" +"board.addConnected" = "Přidat propojený tvar" "board.connector" = "Spojnice" "board.ink" = "Tah fixem" "board.label" = "Popisek spojnice" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index b2491dd4b..97395646d 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -31,6 +31,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Originalgröße (100 %)" "Add a category" = "Kategorie hinzufügen" "Add a comment" = "Kommentar hinzufügen" +"Add a connected shape" = "Verbundene Form hinzufügen" "Add a description" = "Beschreibung hinzufügen" "Add a frame (F) or a shape (R) to get started" = "Fügen Sie zum Start einen Rahmen (F) oder eine Form (R) hinzu" "Add a layer or component to tile first" = "Fügen Sie zuerst eine Ebene oder Komponente zum Kacheln hinzu" @@ -1767,6 +1768,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Auf Pixel runden" "arrange.spacing" = "Abstand" "arrange.tidy" = "Aufräumen" +"board.addConnected" = "Verbundene Form hinzufügen" "board.connector" = "Verbinder" "board.ink" = "Markerstrich" "board.label" = "Verbinderbeschriftung" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 7529d93b8..69e26e7d7 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -33,6 +33,7 @@ name = "English" "Actual size (100%)" = "Actual size (100%)" "Add a category" = "Add a category" "Add a comment" = "Add a comment" +"Add a connected shape" = "Add a connected shape" "Add a description" = "Add a description" "Add a frame (F) or a shape (R) to get started" = "Add a frame (F) or a shape (R) to get started" "Add a layer or component to tile first" = "Add a layer or component to tile first" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 95743f10a..bfa6c6107 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -8,6 +8,7 @@ name = "Español" status = "draft — needs native review" [ui] +"Add a connected shape" = "Añadir una forma conectada" "Add a minute" = "Añadir un minuto" "Add column" = "Añadir columna" "Add measurement" = "Añadir medición" @@ -1895,6 +1896,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Redondear a píxeles" "arrange.spacing" = "Espaciado" "arrange.tidy" = "Ordenar" +"board.addConnected" = "Añadir forma conectada" "board.connector" = "Conector" "board.ink" = "Trazo de rotulador" "board.label" = "Etiqueta del conector" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 851583efc..357a0c545 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Todellinen koko (100 %)" "Add a category" = "Lisää luokka" "Add a comment" = "Lisää kommentti" +"Add a connected shape" = "Lisää yhdistetty muoto" "Add a description" = "Lisää kuvaus" "Add a frame (F) or a shape (R) to get started" = "Aloita lisäämällä kehys (F) tai muoto (R)" "Add a layer or component to tile first" = "Lisää ensin laatoitettava taso tai komponentti" @@ -1852,6 +1853,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Pyöristä pikseleihin" "arrange.spacing" = "Välistys" "arrange.tidy" = "Järjestä tasavälein" +"board.addConnected" = "Lisää yhdistetty muoto" "board.connector" = "Yhdistin" "board.ink" = "Tussiviiva" "board.label" = "Yhdistimen nimiö" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 894f4eeac..e7787d8da 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -34,6 +34,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Taille réelle (100 %)" "Add a category" = "Ajouter une catégorie" "Add a comment" = "Ajouter un commentaire" +"Add a connected shape" = "Ajouter une forme reliée" "Add a description" = "Ajouter une description" "Add a frame (F) or a shape (R) to get started" = "Ajoutez un cadre (F) ou une forme (R) pour commencer" "Add a layer or component to tile first" = "Ajoutez d’abord un calque ou un composant à répéter en mosaïque" @@ -1853,6 +1854,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Arrondir aux pixels" "arrange.spacing" = "Espacement" "arrange.tidy" = "Ranger" +"board.addConnected" = "Ajouter une forme reliée" "board.connector" = "Connecteur" "board.ink" = "Trait de marqueur" "board.label" = "Libellé du connecteur" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 56d09e3a2..00daf1893 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -8,6 +8,7 @@ name = "Bahasa Indonesia" status = "draft — needs native review" [ui] +"Add a connected shape" = "Tambahkan bentuk yang terhubung" "Relative to" = "Relatif terhadap" "Scroll wheel zoom" = "Zoom dengan roda gulir" "Trigger layer" = "Layer pemicu" @@ -1813,6 +1814,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Bulatkan ke piksel" "arrange.spacing" = "Spasi" "arrange.tidy" = "Rapikan" +"board.addConnected" = "Tambahkan bentuk terhubung" "board.connector" = "Konektor" "board.ink" = "Goresan spidol" "board.label" = "Label konektor" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index 84eccc637..c94d09826 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -33,6 +33,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Dimensione reale (100%)" "Add a category" = "Aggiungi una categoria" "Add a comment" = "Aggiungi un commento" +"Add a connected shape" = "Aggiungi una forma collegata" "Add a description" = "Aggiungi una descrizione" "Add a frame (F) or a shape (R) to get started" = "Aggiungi un frame (F) o una forma (R) per iniziare" "Add a layer or component to tile first" = "Aggiungi prima un livello o un componente da affiancare" @@ -1788,6 +1789,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Arrotonda ai pixel" "arrange.spacing" = "Spaziatura" "arrange.tidy" = "Riordina" +"board.addConnected" = "Aggiungi forma collegata" "bool.exclude" = "Escludi selezione" "bool.flatten" = "Appiattisci booleano" "bool.intersect" = "Interseca selezione" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index 6cc8ad2f7..fdb2774bc 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -36,6 +36,7 @@ status = "draft — needs native review" "Actual size (100%)" = "実際のサイズ(100%)" "Add a category" = "カテゴリを追加" "Add a comment" = "コメントを追加" +"Add a connected shape" = "接続されたシェイプを追加" "Add a description" = "説明を追加" "Add a frame (F) or a shape (R) to get started" = "フレーム(F)またはシェイプ(R)を追加して開始" "Add a layer or component to tile first" = "先に並べるレイヤーまたはコンポーネントを追加" @@ -1833,6 +1834,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "ピクセルに丸める" "arrange.spacing" = "間隔" "arrange.tidy" = "整列" +"board.addConnected" = "接続されたシェイプを追加" "board.connector" = "コネクタ" "board.ink" = "マーカーの線" "board.label" = "コネクタのラベル" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 4014462b1..94edaac5b 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -36,6 +36,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Actual size (100%)" = "რეალური ზომა (100%)" "Add a category" = "კატეგორიის დამატება" "Add a comment" = "კომენტარის დამატება" +"Add a connected shape" = "დაკავშირებული ფიგურის დამატება" "Add a description" = "აღწერის დამატება" "Add a frame (F) or a shape (R) to get started" = "დასაწყებად დაამატეთ ჩარჩო (F) ან ფიგურა (R)" "Add a layer or component to tile first" = "ჯერ დაამატეთ შრე ან კომპონენტი ფილებად დასაწყობად" @@ -1905,6 +1906,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "arrange.roundToPixels" = "პიქსელებამდე დამრგვალება" "arrange.spacing" = "ინტერვალი" "arrange.tidy" = "მოწესრიგება" +"board.addConnected" = "დაკავშირებული ფიგურის დამატება" "board.connector" = "შემაერთებელი" "board.ink" = "მარკერის შტრიხი" "board.label" = "შემაერთებლის წარწერა" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 681eb524b..1f11b0bf9 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -32,6 +32,7 @@ status = "draft — needs native review" "Actual size (100%)" = "실제 크기(100%)" "Add a category" = "카테고리 추가" "Add a comment" = "댓글 추가" +"Add a connected shape" = "연결된 도형 추가" "Add a description" = "설명 추가" "Add a frame (F) or a shape (R) to get started" = "프레임(F) 또는 도형(R)을 추가해 시작하세요" "Add a layer or component to tile first" = "먼저 바둑판식으로 배열할 레이어나 컴포넌트를 추가하세요" @@ -1873,6 +1874,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "픽셀에 맞춰 반올림" "arrange.spacing" = "간격" "arrange.tidy" = "정돈" +"board.addConnected" = "연결된 도형 추가" "board.connector" = "커넥터" "board.ink" = "마커 획" "board.label" = "커넥터 레이블" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 89a993346..e3bfefe6f 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -35,6 +35,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Werkelijke grootte (100%)" "Add a category" = "Categorie toevoegen" "Add a comment" = "Reactie toevoegen" +"Add a connected shape" = "Verbonden vorm toevoegen" "Add a description" = "Beschrijving toevoegen" "Add a frame (F) or a shape (R) to get started" = "Voeg een frame (F) of een vorm (R) toe om te beginnen" "Add a layer or component to tile first" = "Voeg eerst een laag of component toe om als tegel te gebruiken" @@ -1851,6 +1852,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Afronden op pixels" "arrange.spacing" = "Afstand" "arrange.tidy" = "Opruimen" +"board.addConnected" = "Verbonden vorm toevoegen" "board.connector" = "Verbinding" "board.ink" = "Stiftlijn" "board.label" = "Label van verbinding" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index 2081b1cd5..f66cb7db6 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -33,6 +33,7 @@ name = "Polski" "Actual size (100%)" = "Rozmiar rzeczywisty (100%)" "Add a category" = "Dodaj kategorię" "Add a comment" = "Dodaj komentarz" +"Add a connected shape" = "Dodaj połączony kształt" "Add a description" = "Dodaj opis" "Add a frame (F) or a shape (R) to get started" = "Dodaj ramkę (F) lub kształt (R), aby rozpocząć" "Add a layer or component to tile first" = "Najpierw dodaj warstwę lub komponent do powielenia" @@ -1864,6 +1865,7 @@ name = "Polski" "arrange.roundToPixels" = "Zaokrąglij do pikseli" "arrange.spacing" = "Odstępy" "arrange.tidy" = "Uporządkuj" +"board.addConnected" = "Dodaj połączony kształt" "board.connector" = "Łącznik" "board.ink" = "Pociągnięcie markera" "board.label" = "Etykieta łącznika" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 4033ecc8e..84f03f1f7 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -30,6 +30,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Tamanho real (100%)" "Add a category" = "Adicionar uma categoria" "Add a comment" = "Adicione um comentário" +"Add a connected shape" = "Adicionar uma forma conectada" "Add a description" = "Adicionar uma descrição" "Add a frame (F) or a shape (R) to get started" = "Adicione um quadro (F) ou uma forma (R) para começar" "Add a layer or component to tile first" = "Adicione primeiro uma camada ou um componente para o mosaico" @@ -1870,6 +1871,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Arredondar para pixels" "arrange.spacing" = "Espaçamento" "arrange.tidy" = "Organizar" +"board.addConnected" = "Adicionar forma conectada" "board.connector" = "Conector" "board.ink" = "Traço de marcador" "board.label" = "Rótulo do conector" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 01c2813a5..24683d56c 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -33,6 +33,7 @@ name = "Русский" "Actual size (100%)" = "Фактический размер (100%)" "Add a category" = "Добавить категорию" "Add a comment" = "Добавить комментарий" +"Add a connected shape" = "Добавить связанную фигуру" "Add a description" = "Добавить описание" "Add a frame (F) or a shape (R) to get started" = "Для начала добавьте Frame (F) или фигуру (R)" "Add a layer or component to tile first" = "Сначала добавьте слой или компонент для повторения" @@ -1864,6 +1865,7 @@ name = "Русский" "arrange.roundToPixels" = "Округлить до пикселей" "arrange.spacing" = "Интервал" "arrange.tidy" = "Упорядочить" +"board.addConnected" = "Добавить связанную фигуру" "board.connector" = "Соединитель" "board.ink" = "Штрих маркера" "board.label" = "Подпись соединителя" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index e18044f52..0fa4f906c 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -33,6 +33,7 @@ name = "Svenska" "Actual size (100%)" = "Verklig storlek (100 %)" "Add a category" = "Lägg till en kategori" "Add a comment" = "Lägg till en kommentar" +"Add a connected shape" = "Lägg till en ansluten form" "Add a description" = "Lägg till en beskrivning" "Add a frame (F) or a shape (R) to get started" = "Lägg till en frame (F) eller en form (R) för att komma igång" "Add a layer or component to tile first" = "Lägg först till ett lager eller en komponent att lägga sida vid sida" @@ -1935,6 +1936,7 @@ name = "Svenska" "arrange.roundToPixels" = "Avrunda till pixlar" "arrange.spacing" = "Avstånd" "arrange.tidy" = "Snygga till" +"board.addConnected" = "Lägg till ansluten form" "board.connector" = "Koppling" "board.ink" = "Märkpennsdrag" "board.label" = "Kopplingsetikett" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 4af404982..58a6422ec 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -38,6 +38,7 @@ status = "draft — needs native review" "Actual size (100%)" = "ขนาดจริง (100%)" "Add a category" = "เพิ่มหมวดหมู่" "Add a comment" = "เพิ่มความคิดเห็น" +"Add a connected shape" = "เพิ่มรูปทรงที่เชื่อมต่อ" "Add a description" = "เพิ่มคำอธิบาย" "Add a frame (F) or a shape (R) to get started" = "เพิ่มเฟรม (F) หรือรูปร่าง (R) เพื่อเริ่มต้น" "Add a layer or component to tile first" = "เพิ่มเลเยอร์หรือคอมโพเนนต์ที่จะเรียงต่อกันก่อน" @@ -1838,6 +1839,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "ปัดเป็นพิกเซล" "arrange.spacing" = "ระยะห่าง" "arrange.tidy" = "จัดระเบียบ" +"board.addConnected" = "เพิ่มรูปทรงที่เชื่อมต่อ" "board.connector" = "ตัวเชื่อม" "board.ink" = "เส้นปากกามาร์กเกอร์" "board.label" = "ป้ายตัวเชื่อม" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 6f84aa376..064e853a0 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -34,6 +34,7 @@ name = "Türkçe" "Actual size (100%)" = "Gerçek boyut (%100)" "Add a category" = "Kategori ekle" "Add a comment" = "Yorum ekle" +"Add a connected shape" = "Bağlı şekil ekle" "Add a description" = "Açıklama ekle" "Add a frame (F) or a shape (R) to get started" = "Başlamak için bir çerçeve (F) veya bir şekil (R) ekleyin" "Add a layer or component to tile first" = "Önce döşenecek bir katman veya bileşen ekleyin" @@ -1813,6 +1814,7 @@ name = "Türkçe" "arrange.roundToPixels" = "Piksellere yuvarla" "arrange.spacing" = "Aralık" "arrange.tidy" = "Toparla" +"board.addConnected" = "Bağlı şekil ekle" "board.connector" = "Bağlayıcı" "board.ink" = "Keçeli kalem çizgisi" "board.label" = "Bağlayıcı etiketi" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index e729d8cc0..2c12c4be7 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -34,6 +34,7 @@ status = "draft — needs native review" "Actual size (100%)" = "Kích thước thực (100%)" "Add a category" = "Thêm danh mục" "Add a comment" = "Thêm bình luận" +"Add a connected shape" = "Thêm hình được nối" "Add a description" = "Thêm mô tả" "Add a frame (F) or a shape (R) to get started" = "Thêm khung (F) hoặc hình dạng (R) để bắt đầu" "Add a layer or component to tile first" = "Hãy thêm một lớp hoặc thành phần để lặp ô trước" @@ -1789,6 +1790,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "Làm tròn theo pixel" "arrange.spacing" = "Khoảng cách" "arrange.tidy" = "Sắp xếp gọn" +"board.addConnected" = "Thêm hình được nối" "bool.exclude" = "Loại trừ vùng chọn" "bool.flatten" = "Làm phẳng nhóm boolean" "bool.intersect" = "Giao vùng chọn" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index ae545b2f7..3351632d4 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -33,6 +33,7 @@ status = "draft — needs native review" "Actual size (100%)" = "实际大小(100%)" "Add a category" = "添加类别" "Add a comment" = "添加评论" +"Add a connected shape" = "添加相连的形状" "Add a description" = "添加描述" "Add a frame (F) or a shape (R) to get started" = "添加画框(F)或形状(R)以开始" "Add a layer or component to tile first" = "请先添加要平铺的图层或组件" @@ -1766,6 +1767,7 @@ status = "draft — needs native review" "arrange.roundToPixels" = "取整到像素" "arrange.spacing" = "间距" "arrange.tidy" = "整理" +"board.addConnected" = "添加相连的形状" "board.connector" = "连接线" "board.ink" = "马克笔笔画" "board.label" = "连接线标签" diff --git a/crates/ui-egui/src/board_connect_tests.rs b/crates/ui-egui/src/board_connect_tests.rs new file mode 100644 index 000000000..46667a65a --- /dev/null +++ b/crates/ui-egui/src/board_connect_tests.rs @@ -0,0 +1,103 @@ +//! The "+" handles beside a board's selected sticky or shape (#1597), drawn headless: four, only +//! for one sticky or shape with the Move tool, clear of the bar above it; clicking one makes the +//! next shape on that side joined by a connector and starts typing in it; one undo takes both away. + +use egui::{Event, Id, LayerId, Modifiers, Order, PointerButton, Pos2, Rect, pos2}; +use figcraft_doc::{BoardItem, NodeId}; +use figcraft_engine::Session; +use figcraft_engine::tools::ToolId; +use serde_json::{Value, json}; + +use crate::board_connect::REACH; +use crate::layers_drag_tests::Ui; + +fn id(v: &Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn board_with_shape() -> (Ui, NodeId) { + let mut s = Session::new(); + s.execute("file.newBoard", &json!({})).unwrap(); + let a = id(&s.execute("board.shape", &json!({"shape": "roundedRectangle", "x": 0, "y": 0, "width": 160, "height": 100})).unwrap()); + let mut u = Ui::new(s); + u.frame(vec![]); + // The shape in the middle of the canvas at 100%. + let view = &mut u.app.session.ds_mut().unwrap().view; + (view.zoom, view.cx, view.cy) = (1.0, 80.0, 50.0); + u.frame(vec![]); + (u, a) +} + +/// Where the "+" for a side shows, if it does. +fn handle(u: &Ui, side: &str) -> Option { + let layer = LayerId::new(Order::Middle, Id::unique(("board_connect", side))); + u.ctx.memory(|m| m.areas().visible_last_frame(&layer)).then(|| u.ctx.memory(|m| m.area_rect(layer.id))).flatten() +} + +fn click(u: &mut Ui, p: Pos2) { + for pressed in [true, false] { + u.frame(vec![Event::PointerMoved(p), Event::PointerButton { pos: p, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }]); + } + u.frame(vec![]); +} + +fn screen_box(u: &Ui, n: NodeId) -> Rect { + let b = u.app.session.doc().unwrap().world_bounds(n).unwrap(); + Rect::from_two_pos(u.app.to_screen(b.x0, b.y0), u.app.to_screen(b.x1, b.y1)) +} + +#[test] +fn four_handles_show_for_one_shape_and_clear_of_the_bar() { + let (mut u, a) = board_with_shape(); + u.app.session.execute("select.set", &json!({"ids": []})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + assert!(handle(&u, "right").is_none(), "nothing selected"); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let r = screen_box(&u, a); + for (side, want) in [ + ("above", pos2(r.center().x, r.min.y - REACH)), + ("right", pos2(r.max.x + REACH, r.center().y)), + ("below", pos2(r.center().x, r.max.y + REACH)), + ("left", pos2(r.min.x - REACH, r.center().y)), + ] { + let h = handle(&u, side).unwrap_or_else(|| panic!("{side}: no handle")); + assert!((h.center() - want).length() < 1.0, "{side}: {h:?}"); + } + let bar = u.ctx.memory(|m| m.area_rect(Id::unique("board_bar"))).expect("the bar shows"); + assert!(!bar.intersects(handle(&u, "above").unwrap()), "the bar leaves the handle above free"); + // Another tool, or two layers selected: none. + u.app.session.set_tool(ToolId::Hand); + u.frame(vec![]); + u.frame(vec![]); + assert!(handle(&u, "right").is_none(), "another tool"); + u.app.session.set_tool(ToolId::Move); + let b = id(&u.app.session.execute("board.sticky", &json!({"x": 600, "y": 0})).unwrap()); + u.app.session.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + assert!(handle(&u, "right").is_none(), "two layers"); +} + +#[test] +fn clicking_a_handle_adds_the_next_shape_joined_and_typing() { + let (mut u, a) = board_with_shape(); + u.app.session.execute("select.set", &json!({"ids": [a]})).unwrap(); + u.frame(vec![]); + u.frame(vec![]); + let before = u.app.session.doc().unwrap().ids().count(); + let at = handle(&u, "below").expect("a handle below").center(); + click(&mut u, at); + let doc = u.app.session.doc().unwrap(); + let made: Vec = doc.ids().filter(|i| doc.node(*i).is_some_and(|n| n.board.is_some())).collect(); + let next = made.iter().copied().find(|i| *i != a && matches!(doc.node(*i).unwrap().board, Some(BoardItem::Shape { .. }))).expect("a new shape"); + let line = made.iter().copied().find(|i| matches!(doc.node(*i).unwrap().board, Some(BoardItem::Connector(_)))).expect("a connector"); + assert!(screen_box(&u, next).min.y > screen_box(&u, a).max.y, "below it"); + assert!(matches!(&doc.node(line).unwrap().board, Some(BoardItem::Connector(c)) if c.from.node() == Some(a) && c.to.node() == Some(next))); + assert!(u.app.session.ds().unwrap().text_edit.is_some(), "typing in the new shape"); + figcraft_engine::textedit::end(&mut u.app.session).unwrap(); + u.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(u.app.session.doc().unwrap().ids().count(), before, "undo takes the shape and its connector away"); +} diff --git a/crates/ui-egui/src/board_ui_tests.rs b/crates/ui-egui/src/board_ui_tests.rs index 7926dfd2d..c507f4111 100644 --- a/crates/ui-egui/src/board_ui_tests.rs +++ b/crates/ui-egui/src/board_ui_tests.rs @@ -215,6 +215,23 @@ fn the_next_sticky_from_the_keyboard_goes_on_typing() { assert_eq!(doc.node(owner).unwrap().transform.translation().x, 224.0); } +#[test] +fn the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing() { + let mut u = board(); + let a = id(&u.app.run("board.shape", json!({"shape": "diamond", "x": 0, "y": 0, "width": 200, "height": 120})).unwrap()); + // Shift+Cmd/Ctrl+Enter on a shape: the next shape, joined to it (#1597). + crate::menus::invoke(&mut u.app, "board.nextSticky"); + let doc = u.app.session.doc().unwrap(); + let te = u.app.session.ds().unwrap().text_edit.clone().expect("typing in the new shape"); + let b = figcraft_engine::board::owner_of_text(doc, te.id).unwrap(); + assert!(matches!(doc.node(b).unwrap().board, Some(BoardItem::Shape { shape: BoardShape::Diamond }))); + let joined = doc.nodes().any(|n| { + matches!(&n.board, Some(BoardItem::Connector(k)) if k.from == figcraft_doc::End::Node { node: a, side: figcraft_doc::Side::Right } + && k.to == figcraft_doc::End::Node { node: b, side: figcraft_doc::Side::Left }) + }); + assert!(joined, "a connector from the first shape's right side to the new one's left"); +} + #[test] fn a_board_page_has_a_dot_grid_under_its_layers_thinned_out_zoomed_out() { let mut u = board(); diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index ae39c7e0e..e63caaa3a 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -31,6 +31,7 @@ use render::{JobKey, request_render, shows}; pub use rulers::fmt; pub(crate) use rulers::{status_badge, status_label}; pub mod board_bar; +pub mod board_connect; pub mod board_facilitate; pub mod comments; pub mod cursors; @@ -299,6 +300,7 @@ pub fn show(app: &mut FigApp, ui: &mut Ui, worker: &mut Worker) { crate::board_facilitate::badges(app, &painter); crate::board_bar::stamp_author(app, &painter, ctx.input(|i| i.pointer.hover_pos()).filter(|p| rect.contains(*p))); crate::board_bar::show(app, &ctx); + crate::board_connect::show(app, &ctx); crate::board_facilitate::show(app, &ctx, rect); crate::widget_bar::show(app, &ctx); crate::selection_spacing::draw(app, &painter); diff --git a/crates/ui-egui/src/canvas/board_bar.rs b/crates/ui-egui/src/canvas/board_bar.rs index 8a33538c3..418731b51 100644 --- a/crates/ui-egui/src/canvas/board_bar.rs +++ b/crates/ui-egui/src/canvas/board_bar.rs @@ -172,7 +172,11 @@ pub fn show(app: &mut FigApp, ctx: &egui::Context) { } let Some(h) = has(app) else { return }; let Some(b) = app.session.ds().ok().and_then(|ds| ds.doc.bounds_of(&ds.selection)) else { return }; - let sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + let mut sel = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + // Clear of the "+" handles above and below a sticky or shape. + if crate::board_connect::shown(app) { + sel = sel.expand2(vec2(0.0, crate::board_connect::REACH)); + } let size = vec2(h.width(), 40.0); let at = place(sel, size, app.canvas.rect); let t = Tokens::get(ctx); diff --git a/crates/ui-egui/src/canvas/board_connect.rs b/crates/ui-egui/src/canvas/board_connect.rs new file mode 100644 index 000000000..87211055b --- /dev/null +++ b/crates/ui-egui/src/canvas/board_connect.rs @@ -0,0 +1,115 @@ +//! Boards: a "+" beside each side of the one selected sticky or shape (#1597). Hovering one shows +//! where the next one goes; clicking it makes the next of the same kind there, joined by a +//! connector, and starts typing in it (`board.addConnected`). + +use egui::{Area, Color32, CornerRadius, Id, LayerId, Order, Pos2, Rect, Sense, Shape, Stroke, pos2, vec2}; +use figcraft_doc::{BoardItem, NodeId}; +use figcraft_engine::ToolId; +use figcraft_engine::board::{STICKY, connected_place}; +use serde_json::json; + +use crate::FigApp; +use crate::i18n::tr; +use crate::theme::Tokens; + +/// How far each "+" sits outside the object, from its edge to its centre (screen points): clear +/// of the selection's handles and of the size label under it. +pub(crate) const REACH: f32 = 36.0; +const RADIUS: f32 = 9.0; +const SIDES: [&str; 4] = ["above", "right", "below", "left"]; + +/// The sticky or shape the handles are for: its id, its box on the page and the next one's size. +fn target(app: &FigApp) -> Option<(NodeId, figcraft_geom::Rect, (f64, f64))> { + let editable = app.session.active_read_only().is_none(); + if app.session.tool.busy() || !editable || app.session.tool.tool != ToolId::Move || !app.session.doc().is_ok_and(figcraft_engine::board::is_board) + { + return None; + } + let ds = app.session.ds().ok()?; + let ([id], None) = (ds.selection.as_slice(), &ds.text_edit) else { return None }; + let n = ds.doc.node(*id)?; + let next = match n.board { + Some(BoardItem::Sticky { .. }) => (STICKY, STICKY), + Some(BoardItem::Shape { .. }) => (n.width, n.height), + _ => return None, + }; + Some((*id, ds.doc.world_bounds(*id)?, next)) +} + +/// Handles show for the selection: the bar above it leaves them room. +pub(crate) fn shown(app: &FigApp) -> bool { + target(app).is_some() +} + +/// The centre of the "+" for `side` of the object's screen box `r`. +fn handle_at(r: Rect, side: &str) -> Pos2 { + match side { + "above" => pos2(r.center().x, r.min.y - REACH), + "right" => pos2(r.max.x + REACH, r.center().y), + "below" => pos2(r.center().x, r.max.y + REACH), + _ => pos2(r.min.x - REACH, r.center().y), + } +} + +pub fn show(app: &mut FigApp, ctx: &egui::Context) { + let Some((id, b, next)) = target(app) else { return }; + let r = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + // Too small on screen to tell the handles from the object. + if r.width() < 2.0 * RADIUS || r.height() < 2.0 * RADIUS { + return; + } + let t = Tokens::get(ctx); + let accent = crate::theme::canvas_accent(ctx); + // Not over the toolbar at the bottom of the canvas. + let toolbar = crate::toolbar::pill_rect(app.canvas.rect, crate::toolbar::bar_width(app)).expand(RADIUS); + let mut picked = None; + for side in SIDES { + let c = handle_at(r, side); + if !app.canvas.rect.shrink(RADIUS).contains(c) || toolbar.contains(c) { + continue; + } + let area = Area::new(Id::unique(("board_connect", side))).fixed_pos(c - vec2(RADIUS, RADIUS)).order(Order::Middle); + let clicked = area + .show(ctx, |ui| { + let (rect, resp) = ui.allocate_exact_size(vec2(2.0 * RADIUS, 2.0 * RADIUS), Sense::click()); + let hot = resp.hovered(); + let p = ui.painter(); + p.circle_filled(rect.center(), RADIUS, if hot { accent } else { t.panel }); + p.circle_stroke(rect.center(), RADIUS, Stroke::new(1.0, if hot { accent } else { t.border })); + let ink = Stroke::new(1.5, if hot { Color32::WHITE } else { t.text_secondary }); + let arm = RADIUS * 0.45; + p.line_segment([rect.center() - vec2(arm, 0.0), rect.center() + vec2(arm, 0.0)], ink); + p.line_segment([rect.center() - vec2(0.0, arm), rect.center() + vec2(0.0, arm)], ink); + if hot { + preview(app, ctx, b, next, side, accent); + } + resp.on_hover_text(tr("Add a connected shape")).clicked() + }) + .inner; + if clicked { + picked = Some(side); + } + } + if let Some(side) = picked { + app.run("board.addConnected", json!({"id": id, "side": side, "edit": true})); + } +} + +/// Where the next one goes for `side`, faintly: its box and the connector's line to it. +fn preview(app: &FigApp, ctx: &egui::Context, b: figcraft_geom::Rect, next: (f64, f64), side: &str, accent: Color32) { + let Some(((x, y), ..)) = connected_place((b.x0, b.y0), (b.width(), b.height()), next, side) else { return }; + let ghost = Rect::from_two_pos(app.to_screen(x, y), app.to_screen(x + next.0, y + next.1)); + let from = Rect::from_two_pos(app.to_screen(b.x0, b.y0), app.to_screen(b.x1, b.y1)); + let (a, z) = match side { + "above" => (from.center_top(), ghost.center_bottom()), + "right" => (from.right_center(), ghost.left_center()), + "below" => (from.center_bottom(), ghost.center_top()), + _ => (from.left_center(), ghost.right_center()), + }; + let p = ctx.layer_painter(LayerId::new(Order::Middle, Id::unique("board_connect_preview"))); + let faint = Stroke::new(1.5, accent.gamma_multiply(0.6)); + p.rect_filled(ghost, CornerRadius::same(8), accent.gamma_multiply(0.08)); + let corners = [ghost.left_top(), ghost.right_top(), ghost.right_bottom(), ghost.left_bottom(), ghost.left_top()]; + p.extend(Shape::dashed_line(&corners, faint, 6.0, 4.0)); + p.line_segment([a, z], faint); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3aa8baa10..751f01f12 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -36,6 +36,8 @@ pub mod ui_scale; pub mod widgets; pub mod windows; +#[cfg(test)] +mod board_connect_tests; #[cfg(test)] mod board_picker_tests; #[cfg(test)] @@ -257,6 +259,7 @@ mod windows_qa_tests; #[cfg(test)] mod windows_tests; pub use canvas::board_bar; +pub use canvas::board_connect; pub use canvas::board_facilitate; pub use canvas::comments; pub use canvas::cursors; diff --git a/docs/boards-parity.md b/docs/boards-parity.md index fd2ce41cc..b9024e94b 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -35,6 +35,7 @@ all end with the same geometry. | Shapes with text | done | rectangle, rounded rectangle, ellipse, diamond, triangle, speech bubble, chevron; text centred and wrapping inside the outline; shape type and text size from the contextual bar | | Shape auto-fit to its text, more shapes (cylinder, cross, star…), shape libraries | missing | | | Connectors | done | from a layer's side (the facing one, or one named) to another layer or a free point, straight, elbow or curved, arrowheads (the stroke's caps), a label in the middle of the route by length (double-click to add); while one is dragged out it shows as it will be made (route, line and arrowhead), the object under the pointer outlined with its four attach points (side middles), the one the end goes to ringed, and within 10 px of one the end keeps that side; re-routed when either end moves or resizes; a deleted end stays where it was | +| Create the next shape already connected | done | a selected sticky or shape shows a "+" beside each side; hovering one shows where the next goes, clicking makes one of the same kind, size and colour there, joined by a connector as the Connector tool makes one, and starts typing in it; one undo step (`board.addConnected {id?, side?, edit?}`, #1597). Shift+Cmd/Ctrl+Enter on a selected (or edited) shape adds the next shape on its right, joined the same way, and goes on typing; on a sticky it adds a sticky beside it without a connector, as before | | Curved connectors, elbows around objects, dragging an end to re-attach | done | `board.setStyle {style: curved}` (and the contextual bar): one curve leaving and arriving along the sides; an elbow whose short way would cross what it joins goes around their boxes, 24 units clear; a selected connector shows its two ends as handles, and dragging one onto an object attaches it there (the object is outlined while dragging), onto empty canvas leaves it free (`board.reattach {id, end, target, side?}`, one undo step) | | Connector waypoints placed by hand | missing | routes are computed from the ends; no bend points of your own | | Quick-create handles (drag out a connected copy) | missing | | @@ -63,6 +64,8 @@ all end with the same geometry. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | #1597: Shift+Cmd/Ctrl+Enter on a selected shape adds the next shape joined by a connector and starts typing in it; on a sticky it still adds a sticky beside it. Numbers unchanged. | +| 2026-10-30 | minor | A row for creating the next shape already connected: "+" handles on a selected sticky's or shape's sides make the next one joined by a connector, ready for typing (#1597). Numbers unchanged | | 2026-10-30 | minor | M12, #1342: a FigJam `.jam` file opens as a board, our whiteboard file kind, instead of a design file: the `.fig` reader marks a canvas with FigJam's `fig-jam.` header as `FileKind::Board`, so the file opens with the board toolbar, the dot grid and the board panels, and saves as a board. A `.fig` stays a design file, and `file.import` of a `.jam` adds its pages to the open file without changing its kind. Its stickies and shapes with text are still frames with their generated layers and its connectors vectors, not native board objects. Tests: `board_import_tests.rs` (the kind, `.jam` against `.fig`), `jam_open_tests.rs` (open as a board, import into a design file). | | 2026-10-29 | minor | M12: board grid settings (`board.grid`): dots, lines, graph paper or none, with spacing, size and contrast, in Preferences; the default grid looks as before. Numbers unchanged (beyond the incumbent's behaviour) | | 2026-10-29 | minor | M12, #1387 (connector tool): while a connector is dragged out with the connector tool, the canvas shows the connector itself as it will be made (its straight, elbow or curved route, line, weight and arrowhead, built by the same code as `board.connector`), not a thin line; the object under the pointer is outlined with its four attach points (the middles of its sides), the one the end goes to ringed; within 10 px of one the end keeps that side (`fromSide`/`toSide`, and `side` when re-attaching). Hovering with the tool shows the attach points too. Escape drops the preview; releasing makes the connector in one undo step. Tests: `board_connector_tests`. Numbers unchanged | diff --git a/docs/mcp.md b/docs/mcp.md index 44c68c5d9..24a6773d4 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -457,7 +457,13 @@ one undo step and returns the ids it made: defaults to your name (`pref.userName`, or the one a live session gave you; `false` for none). - `board.nextSticky {id?, side?: right|below|left|above, edit?}` → `{id, text}`: a sticky of the same colour beside the selected (or edited) one; with `edit: true` the app goes on typing in it - (`Shift+Cmd/Ctrl+Enter` in the app does both). + (`Shift+Cmd/Ctrl+Enter` in the app does both). On a shape it does what `board.addConnected` does: + the next shape, joined to it by a connector (`{id, text, connector}`). +- `board.addConnected {id?, side?: right|below|left|above, edit?}` → `{id, text, connector}`: the + next sticky or shape of the same kind, size and colour on that side of the selected (or edited) + one, 80 units away, joined to it by a connector the way the Connector tool makes one; one undo + step. With `edit: true` the app goes on typing in it (the "+" beside a selected sticky or shape + in the app does both). - `board.shape {shape, x, y, width?, height?, text?, color?, parent?}` → `{id, text}`: a shape with centred, wrapping text: `rectangle roundedRectangle ellipse diamond triangle speechBubble chevron`. - `board.connector {from, to, fromSide?, toSide?, style?: straight|elbow|curved, startCap?, endCap?, label?}` diff --git a/docs/roadmap.md b/docs/roadmap.md index 1aacb7325..dcfeb15f4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,8 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M12, #1597: Shift+Cmd/Ctrl+Enter on a board shape adds the next shape already connected (stickies unchanged). Tests: `board_connected_tests`, `board_ui_tests` | +| 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | | 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | | 2026-10-30 | minor | M8, #1085 (the rest): a panic in a `Session::grouped` closure outside any command (plugin, widget, data fill and CLI groups) skipped the group rollback, leaving documents half edited, the history limit unbounded, live documents held and the group depth raised. `checkpointed` now catches the unwind, runs the same rollback as a failed group and resumes the panic, so the outer guard still reports it. Journal rollover and the browser (wasm aborts) are unchanged. 3 regression tests (`group_panic_tests`). | | 2026-10-30 | minor | M0.9, part of #1396: a development example generates completions for Bash, Elvish, Fish, PowerShell and Zsh and a man page for the CLI and each subcommand, nested ones included, from figcraft-cli's clap definitions, without running commands. Generating these files doesn't install them; package installation and the other RFC-0012 exceptions remain outstanding. Regression tests in `apps/figcraft-cli/tests/it/generated_docs.rs` cover artifacts, command names and options, matching Bash dispatch states, repeat generation and filesystem errors. Numbers unchanged | From 8d5bad2afc526400a9ff021a88d046f60165359e Mon Sep 17 00:00:00 2001 From: Christopher Linnett Date: Fri, 30 Oct 2026 16:53:29 +0800 Subject: [PATCH 25/34] M3: CSS export never names a calculation it leaves out either (#1280) The CSS export left out a colour calculation that resolves to nothing but still wrote an alias to it as var(--name); where no block defines that property, whatever reads the alias is invalid. CSS (and Tailwind) and Penpot now share one rule, Variables::flattener: an alias that leads to a calculation some mode of its collection leaves out goes out as the value it shows in the mode, or is left out when it shows nothing. --- crates/doc/src/styles.rs | 58 +++++++++++++++++++++ crates/engine/src/cmd/variables_io.rs | 6 ++- crates/engine/src/variables_colour_tests.rs | 37 +++++++++++++ crates/format/src/penpot/export.rs | 58 +++------------------ 4 files changed, 105 insertions(+), 54 deletions(-) diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index e7d104b93..66326d850 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -271,6 +271,12 @@ pub struct Variable { pub const CODE_PLATFORMS: [&str; 3] = ["WEB", "iOS", "ANDROID"]; impl Variable { + /// Its value in `mode`, or its first value when it has none there: an export writes every + /// mode of the collection, given a value in each or not. + pub fn value_or_first(&self, mode: &str) -> Option<&VariableValue> { + self.values.get(mode).or_else(|| self.values.values().next()) + } + /// Whether pickers offer this variable for properties of `scope`. pub fn in_scope(&self, scope: &str) -> bool { self.scopes.as_ref().is_none_or(|list| list.iter().any(|s| s == scope || s == ALL_SCOPES || (s == "ALL_FILLS" && scope.ends_with("_FILL")))) @@ -401,6 +407,36 @@ impl Variables { } } + /// Values as an export without colour calculations writes them, references included: see + /// [`Flattener::value`]. + pub fn flattener(&self) -> Flattener<'_> { + let mut out = BTreeSet::new(); + for var in &self.variables { + let Some(coll) = self.collection_by_id(&var.collection) else { continue }; + if coll.modes.iter().any(|m| var.value_or_first(&m.id).is_some_and(|v| self.flattened(var, &m.id, v).is_none())) { + out.insert(var.id.as_str()); + } + } + // Then every alias that leads to one, back along alias chains. + let mut readers: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for var in &self.variables { + for value in var.values.values() { + if let VariableValue::Alias(target) = value { + readers.entry(target.as_str()).or_default().push(var.id.as_str()); + } + } + } + let mut stack: Vec<&str> = out.iter().copied().collect(); + while let Some(id) = stack.pop() { + for reader in readers.get(id).into_iter().flatten() { + if out.insert(reader) { + stack.push(reader); + } + } + } + Flattener { vars: self, unreferenceable: out } + } + /// The stored value of `id` in its collection's current mode (the mode chosen when it has a /// value for it, else the default mode, else any value). fn value_in(&self, id: &str, modes: &BTreeMap) -> Option<&VariableValue> { @@ -476,6 +512,28 @@ impl Variables { /// Linear per-channel interpolation (`t` clamped and NaN-guarded: a command validates, a crafted /// file must still resolve to a sane colour). +/// Values as an export that leaves out what resolves to nothing writes them ([`Variables::flattener`]). +pub struct Flattener<'v> { + vars: &'v Variables, + /// Colour calculations that resolve to nothing in some mode of their collection, and every + /// alias that leads to one. + unreferenceable: BTreeSet<&'v str>, +} + +impl Flattener<'_> { + /// `val`, a value of `var` in `mode`, as [`Variables::flattened`] writes it, and an alias that + /// leads to a calculation some mode leaves out also as the value it shows here: a reference to + /// it would read nothing whenever that mode applies. None: it shows nothing in this mode. + pub fn value(&self, var: &Variable, mode: &str, val: &VariableValue) -> Option { + match self.vars.flattened(var, mode, val)? { + VariableValue::Alias(target) if self.unreferenceable.contains(target.as_str()) => { + self.vars.resolve(&var.id, &BTreeMap::from([(var.collection.clone(), mode.to_string())])) + } + other => Some(other), + } + } +} + fn mix_colors(a: &Color, b: &Color, t: f32) -> Color { let t = if t.is_finite() { t.clamp(0.0, 1.0) } else { 0.0 }; // Weighted at both ends so t = 0 and t = 1 give each operand exactly: `x + (y - x) * t` can diff --git a/crates/engine/src/cmd/variables_io.rs b/crates/engine/src/cmd/variables_io.rs index 483d92657..dfdee7b80 100644 --- a/crates/engine/src/cmd/variables_io.rs +++ b/crates/engine/src/cmd/variables_io.rs @@ -70,6 +70,7 @@ fn css_safe(text: &str) -> String { /// aliases are `var(--name)` and colour calculations resolve to their colour in the mode. fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { let vars = &doc.variables; + let flattener = vars.flattener(); // Tailwind theme names, unique per file and the same ones page code reads. let theme = crate::tailwind::Theme::new(doc); let name = |v: &Variable| if tailwind { theme.name(v) } else { css_var_of(v) }; @@ -90,8 +91,9 @@ fn css(doc: &Document, colls: &[&Collection], tailwind: bool) -> String { out.push_str(&format!("/* {} · {} */\n{sel} {{\n", css_safe(&c.name), css_safe(&m.name))); for v in vars.variables.iter().filter(|v| v.collection == c.id) { // A calculation is written as the colour it resolves to in this mode (no color-mix() - // or relative-colour output). - let flat = v.values.get(&m.id).and_then(|x| vars.flattened(v, &m.id, x)); + // or relative-colour output), and so is an alias to one some mode leaves out: + // `var()` of a property no block defines would invalidate whatever reads it. + let flat = v.values.get(&m.id).and_then(|x| flattener.value(v, &m.id, x)); let value = match flat.as_ref() { Some(VariableValue::Color(col)) => col.to_css(), Some(VariableValue::Number(x)) if roles.role(v) == crate::token_roles::NumberRole::Unitless => trim_num(*x), diff --git a/crates/engine/src/variables_colour_tests.rs b/crates/engine/src/variables_colour_tests.rs index ffc1ff357..8f8c1d1ee 100644 --- a/crates/engine/src/variables_colour_tests.rs +++ b/crates/engine/src/variables_colour_tests.rs @@ -395,6 +395,43 @@ fn rest_and_css_exports_write_calculations_as_their_colours() { assert!(text.contains("rgba(255, 0, 0, 0.5") && text.contains("rgba(0, 0, 255, 0.5"), "both modes in CSS: {text}"); } +/// A hand-edited file can hold a calculation that resolves to nothing (one reading a variable +/// that isn't there). The CSS export leaves it out, so no property may name it: an alias to it +/// goes out as the colour it shows, or is left out when that is nothing. +#[test] +fn css_never_names_a_calculation_it_leaves_out() { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "T", "modes": ["Light", "Dark"]})); + let ink = json!({"collection": "T", "name": "ink", "type": "color", "values": {"Light": "#FF0000", "Dark": "#0000FF"}}); + ex(&mut s, "variable.create", ink); + for name in ["lost", "half"] { + ex(&mut s, "variable.create", json!({"collection": "T", "name": name, "type": "color", "value": "#000000"})); + } + for (name, to) in [("lost-too", "lost"), ("half-too", "half"), ("ink-too", "ink")] { + ex(&mut s, "variable.create", json!({"collection": "T", "name": name, "type": "color", "value": {"alias": to}})); + } + // As a hand-edited file could hold them: `lost` reads a variable that isn't there in both + // modes, `half` only in Dark. + let doc = s.doc_mut().unwrap(); + let ink = doc.variables.get("ink").unwrap().id.clone(); + let modes: Vec = doc.variables.collection("T").unwrap().modes.iter().map(|m| m.id.clone()).collect(); + let gone = figcraft_doc::VariableValue::ColorAlpha { variable: "gone".into(), opacity: 0.5 }; + let half = figcraft_doc::VariableValue::ColorAlpha { variable: ink, opacity: 0.5 }; + for v in &mut doc.variables.variables { + match v.name.as_str() { + "lost" => v.values = modes.iter().map(|m| (m.clone(), gone.clone())).collect(), + "half" => v.values = [(modes[0].clone(), half.clone()), (modes[1].clone(), gone.clone())].into_iter().collect(), + _ => {} + } + } + let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap().to_string(); + let (light, dark) = css.split_once("[data-").unwrap(); + assert!(!css.contains("var(--lost)") && !css.contains("var(--half)"), "no var() of a property a block leaves out: {css}"); + assert!(!css.contains("--lost-too"), "an alias to what shows nothing is left out: {css}"); + assert!(light.contains("--half-too: rgba(255, 0, 0, 0.5") && !dark.contains("--half-too"), "the colour it shows, where it shows one: {css}"); + assert!(css.contains("--ink-too: var(--ink);"), "an alias that always resolves stays a reference: {css}"); +} + /// A calculation is a variable's value, not a literal an expression may hold: nothing would check /// what it reads. Every way an expression arrives refuses one. #[test] diff --git a/crates/format/src/penpot/export.rs b/crates/format/src/penpot/export.rs index 4e1432128..f7449fe11 100644 --- a/crates/format/src/penpot/export.rs +++ b/crates/format/src/penpot/export.rs @@ -8,7 +8,7 @@ use figcraft_doc::proto::{Action, Direction, Easing, Interaction, OverlayPositio use figcraft_doc::{ BoolOp, CellAlign, ContentAlign, CrossAlign, Document, EffectKind, GridAlign, GridKind, GridTrack, HConstraint, LayoutMode, LetterSpacing, LineHeight, MainAlign, Node, NodeId, NodeKind, Sizing, StrokeAlign, StrokeCap, TextAlignH, TextAlignV, TextAutoResize, TextCase, TextDecoration, - TextStyle, VConstraint, Variable, VariableType, VariableValue, Variables, + TextStyle, VConstraint, VariableType, VariableValue, }; use serde_json::{Map, Value, json}; @@ -1720,7 +1720,7 @@ impl<'a> Exporter<'a> { if variables.variables.is_empty() { return None; } - let unreferenceable = unreferenceable(variables); + let flattener = variables.flattener(); let mut root = Map::new(); let mut themes = Vec::new(); let mut order = Vec::new(); @@ -1732,26 +1732,14 @@ impl<'a> Exporter<'a> { let set_name = if many { format!("{}/{}", collection.name, mode.name) } else { collection.name.clone() }; let mut tokens = Map::new(); for var in variables.variables.iter().filter(|v| v.collection == collection.id) { - let Some(value) = mode_value(var, &mode.id) else { continue }; + let Some(value) = var.value_or_first(&mode.id) else { continue }; // Penpot tokens have no shape for colour calculations: the colour they resolve - // to in this mode goes out as a plain colour token. - let Some(value) = variables.flattened(var, &mode.id, value) else { + // to in this mode goes out as a plain colour token, and so does an alias to one + // some set leaves out (its `{name}` would dangle whenever that set is active). + let Some(value) = flattener.value(var, &mode.id, value) else { self.warn(crate::CALCULATIONS_LEFT_OUT); continue; }; - // A reference to a token some set leaves out would dangle whenever that set is - // active: such an alias goes out as the colour it shows here instead. - let value = match value { - VariableValue::Alias(target) if unreferenceable.contains(target.as_str()) => { - let here = BTreeMap::from([(collection.id.clone(), mode.id.clone())]); - let Some(shown) = variables.resolve(&var.id, &here) else { - self.warn(crate::CALCULATIONS_LEFT_OUT); - continue; - }; - shown - } - other => other, - }; let (kind, value) = match &value { VariableValue::Alias(target) => { let Some(target_name) = self.token_name(target) else { continue }; @@ -1805,40 +1793,6 @@ impl<'a> Exporter<'a> { } } -/// The value a variable's token takes in `mode` (its first value when it has none for that mode). -fn mode_value<'v>(var: &'v Variable, mode: &str) -> Option<&'v VariableValue> { - var.values.get(mode).or_else(|| var.values.values().next()) -} - -/// Variables a token reference must not name: colour calculations some token set leaves out (they -/// resolve to nothing in a mode of their collection) and every alias that leads to one. -fn unreferenceable(variables: &Variables) -> BTreeSet<&str> { - let mut out = BTreeSet::new(); - for var in &variables.variables { - let Some(coll) = variables.collection_by_id(&var.collection) else { continue }; - if coll.modes.iter().any(|m| mode_value(var, &m.id).is_some_and(|v| variables.flattened(var, &m.id, v).is_none())) { - out.insert(var.id.as_str()); - } - } - let mut readers: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); - for var in &variables.variables { - for value in var.values.values() { - if let VariableValue::Alias(target) = value { - readers.entry(target.as_str()).or_default().push(var.id.as_str()); - } - } - } - let mut stack: Vec<&str> = out.iter().copied().collect(); - while let Some(id) = stack.pop() { - for reader in readers.get(id).into_iter().flatten() { - if out.insert(reader) { - stack.push(reader); - } - } - } - out -} - /// A layer's visibility, lock, opacity, blend, proportion lock and constraints as Penpot fields. fn appearance_fields(node: &Node, fields: &mut Map) { if !node.visible { From 99440fa9ff58b693107d0d558be2971e61a0c5f7 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 16:13:55 +0700 Subject: [PATCH 26/34] M8: a drag that only moves the selection shifts what was worked out at its start (#1571) (#1640) The engine notes when a drag has only moved the selected layers by one offset (Interaction::moved_by); the canvas keeps the outlines, box and slots from the drag's start document and shifts them, the selection spacing and the Design panel's values (but the position fields) come from that document, and the Export section's values are kept. Dragging 648 layers: UI pass 2.10 to 1.44 ms a frame. --- ROADMAP.md | 3 +- crates/engine/src/canvas/tools/drag.rs | 24 +++++ crates/engine/src/doc_state.rs | 4 + crates/engine/src/lib.rs | 2 + crates/engine/src/moved_by_tests.rs | 87 +++++++++++++++++++ crates/engine/src/session/undo.rs | 2 +- crates/ui-egui/src/canvas/outline.rs | 5 +- crates/ui-egui/src/canvas/overlays.rs | 32 ++++++- .../src/canvas/selection_cache_tests.rs | 61 +++++++++++++ .../ui-egui/src/canvas/selection_spacing.rs | 14 +-- crates/ui-egui/src/panels/export_panel.rs | 51 +++++++---- crates/ui-egui/src/panels/right.rs | 41 +++++---- crates/ui-egui/src/selection_cache.rs | 10 +++ docs/roadmap.md | 3 +- 14 files changed, 292 insertions(+), 47 deletions(-) create mode 100644 crates/engine/src/moved_by_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 614856bba..0da9af23d 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-10-30 · **Change:** minor (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; 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; 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); 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); 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-10-30 · **Change:** minor (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; 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; 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); 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); 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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M12, #1597 (the third point): Shift+Cmd/Ctrl+Enter (`board.nextSticky`) now works on a selected or edited shape too: it adds the next shape of the same kind, size and colour on its right (or the `side` given), joined to it by a connector and with typing going on in it, as the "+" beside it does (`board.addConnected`, #1631). On a sticky it adds a sticky beside it without a connector, as before. Numbers unchanged. Tests: `board_connected_tests::the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were`, `board_ui_tests::the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing`. | | 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | | 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index e0f56a10a..aad28466f 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -232,6 +232,8 @@ impl Session { let mut in_flow = false; // Placed in a row or column: (is it a column, its gap) for the insertion line. let mut slot_axis: Option<(bool, f64)> = None; + // Only the selected layers moved, each by `d` (see `Interaction::moved_by`). + let mut shifted = !dup; self.preview(|doc, sel| { let mut moving = ids.clone(); if dup { @@ -288,6 +290,7 @@ impl Session { } // A free move. Layers dragged out of an auto layout flow leave it (onto the page // while dragging; the drop puts them in the frame under the pointer). + shifted &= only_shifted(doc, &moving); for m in &moving { let in_al = doc.node(*m).and_then(|n| n.parent).and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()); if flow && in_al { @@ -298,6 +301,9 @@ impl Session { drop_target = (flow && target != page).then_some(target); Ok(()) })?; + if let Some(it) = self.ds_mut()?.interaction.as_mut() { + it.moved_by = (shifted && !in_flow).then_some((d.x, d.y)); + } let placed = if dup { self.selection() } else { ids }; // Snapping guides mean nothing while the layout places the layers; with Alt held, // live distances to the neighbours replace them. @@ -628,3 +634,21 @@ impl Session { Ok(()) } } + +/// Moving `moving` by the same offset moves nothing else and changes nothing about them but where +/// they are: none is inside another (it would move twice) or placed by its parent's auto layout +/// (the layout would put it back, or close the gap it leaves). +fn only_shifted(doc: &Document, moving: &[NodeId]) -> bool { + let set: std::collections::HashSet = moving.iter().copied().collect(); + moving.iter().all(|m| { + let Some(n) = doc.node(*m) else { return false }; + let in_flow = n.parent.and_then(|p| doc.node(p)).is_some_and(|p| p.layout.is_some()) && !n.child_layout.absolute; + let mut up = n.parent; + let mut nested = false; + while let Some(p) = up { + nested |= set.contains(&p); + up = doc.node(p).and_then(|x| x.parent); + } + !in_flow && !nested + }) +} diff --git a/crates/engine/src/doc_state.rs b/crates/engine/src/doc_state.rs index ba412ba69..3a1f72e50 100644 --- a/crates/engine/src/doc_state.rs +++ b/crates/engine/src/doc_state.rs @@ -26,6 +26,10 @@ pub struct Interaction { pub label: String, pub doc: Arc, pub selection: Vec, + /// The gesture so far only moved the selected layers by this much (page units) from `doc`: + /// their outlines, box and everything else that doesn't depend on where they are can follow + /// by shifting what was worked out from `doc` (#1571). `None` for any other change. + pub moved_by: Option<(f64, f64)>, } /// The text being edited on the canvas. diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index e1f4c785a..7c73f65bc 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -399,6 +399,8 @@ mod measure_tests; #[cfg(test)] mod menu_gaps_tests; #[cfg(test)] +mod moved_by_tests; +#[cfg(test)] mod nested_override_tests; #[cfg(test)] mod node_find_tests; diff --git a/crates/engine/src/moved_by_tests.rs b/crates/engine/src/moved_by_tests.rs new file mode 100644 index 000000000..5e8a9d451 --- /dev/null +++ b/crates/engine/src/moved_by_tests.rs @@ -0,0 +1,87 @@ +//! `Interaction::moved_by` (#1571): set while a drag has only moved the selected layers, each by the +//! same offset from the document the gesture started from, so the UI can shift what it worked out +//! from that document; `None` for anything else (a layer in an auto layout flow, a copy made with +//! Alt), and gone with the gesture. + +use figcraft_doc::NodeId; +use figcraft_geom::Point; +use serde_json::json; + +use crate::Session; +use crate::params::id_of; +use crate::tools::{PointerEvent, PointerKind}; + +fn rect(s: &mut Session, parent: Option, x: f64, y: f64) -> NodeId { + let mut p = json!({"x": x, "y": y, "width": 40, "height": 40}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id_of(&s.execute("shape.rectangle", &p).unwrap()["id"]).unwrap() +} + +fn ev(kind: PointerKind, x: f64, y: f64, alt: bool) -> PointerEvent { + PointerEvent { alt, ..PointerEvent::at(kind, x, y, 1.0) } +} + +/// Press at `from` and move to `to`, holding the button: what the interaction says of it. +fn moved_by(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: bool) -> Option<(f64, f64)> { + s.pointer(&ev(PointerKind::Down, from.0, from.1, alt)).unwrap(); + s.pointer(&ev(PointerKind::Move, (from.0 + to.0) / 2.0, (from.1 + to.1) / 2.0, alt)).unwrap(); + s.pointer(&ev(PointerKind::Move, to.0, to.1, alt)).unwrap(); + s.ds().unwrap().interaction.as_ref().and_then(|it| it.moved_by) +} + +fn release(s: &mut Session, at: (f64, f64)) { + s.pointer(&ev(PointerKind::Up, at.0, at.1, false)).unwrap(); +} + +fn world(s: &Session, id: NodeId) -> Point { + let t = s.doc().unwrap().world_transform(id).translation(); + Point::new(t.x, t.y) +} + +#[test] +fn a_plain_move_says_how_far_and_matches_the_document() { + let mut s = Session::new(); + let a = rect(&mut s, None, 0.0, 0.0); + let b = rect(&mut s, None, 100.0, 0.0); + s.execute("select.set", &json!({"ids": [a, b]})).unwrap(); + let (before_a, before_b) = (world(&s, a), world(&s, b)); + let d = moved_by(&mut s, (20.0, 20.0), (57.0, 81.0), false).expect("a plain move"); + assert_eq!(d, (37.0, 61.0)); + for (id, was) in [(a, before_a), (b, before_b)] { + assert_eq!(world(&s, id), Point::new(was.x + d.0, was.y + d.1), "each layer moved by it"); + } + release(&mut s, (57.0, 81.0)); + assert!(s.ds().unwrap().interaction.is_none(), "gone with the gesture"); +} + +#[test] +fn a_layer_selected_with_its_frame_moves_with_it_once() { + let mut s = Session::new(); + let g = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 200, "height": 200})).unwrap()["id"]).unwrap(); + let inner = rect(&mut s, Some(g), 20.0, 20.0); + s.execute("select.set", &json!({"ids": [g, inner]})).unwrap(); + let was = world(&s, inner); + let d = moved_by(&mut s, (25.0, 25.0), (80.0, 90.0), false).expect("the frame moves, and the layer in it with it"); + assert_eq!(world(&s, inner), Point::new(was.x + d.0, was.y + d.1)); +} + +#[test] +fn other_moves_say_nothing() { + // A layer placed by its frame's auto layout. + let mut s = Session::new(); + let f = id_of(&s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 300, "height": 300})).unwrap()["id"]).unwrap(); + let a = rect(&mut s, Some(f), 10.0, 10.0); + rect(&mut s, Some(f), 60.0, 10.0); + s.execute("layout.add", &json!({"ids": [f]})).unwrap(); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + let at = world(&s, a); + assert_eq!(moved_by(&mut s, (at.x + 5.0, at.y + 5.0), (at.x + 9.0, at.y + 200.0), false), None, "in an auto layout flow"); + release(&mut s, (at.x + 9.0, at.y + 200.0)); + // A copy dragged out with Alt. + let mut s = Session::new(); + let a = rect(&mut s, None, 0.0, 0.0); + s.execute("select.set", &json!({"ids": [a]})).unwrap(); + assert_eq!(moved_by(&mut s, (20.0, 20.0), (90.0, 20.0), true), None, "a copy"); +} diff --git a/crates/engine/src/session/undo.rs b/crates/engine/src/session/undo.rs index 4aa847603..fdf33b91f 100644 --- a/crates/engine/src/session/undo.rs +++ b/crates/engine/src/session/undo.rs @@ -57,7 +57,7 @@ impl Session { return Err(EngineError::Disabled(label.to_string(), PREVIEW_READ_ONLY.into())); } if ds.interaction.is_none() { - ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone() }); + ds.interaction = Some(Interaction { label: label.to_string(), doc: ds.doc.clone(), selection: ds.selection.clone(), moved_by: None }); } Ok(()) } diff --git a/crates/ui-egui/src/canvas/outline.rs b/crates/ui-egui/src/canvas/outline.rs index 154a4de01..37e7aebc2 100644 --- a/crates/ui-egui/src/canvas/outline.rs +++ b/crates/ui-egui/src/canvas/outline.rs @@ -73,7 +73,10 @@ pub(super) fn quad(painter: &egui::Painter, q: [Pos2; 4], s: Stroke) { /// content). pub(super) fn slot_badges(app: &FigApp, painter: &egui::Painter, doc: &Arc, sel: &[NodeId], drop: Option) { let key = [drop.map_or(0, |d| d.0.wrapping_add(1))]; - let slots = crate::selection_cache::cached(painter.ctx(), "slots", doc, sel, &key, || slots_shown(doc, sel, drop)); + // Which slots show doesn't change while the selection is only moved: the gesture's start keeps it. + let base = app.session.ds().ok().and_then(crate::selection_cache::moved).map(|(b, _)| b); + let from = base.as_ref().unwrap_or(doc); + let slots = crate::selection_cache::cached(painter.ctx(), "slots", from, sel, &key, || slots_shown(from, sel, drop)); for (id, name) in slots { let Some(q) = node_quad(app, doc, id) else { continue }; if !Rect::from_points(&q).intersects(app.canvas.rect) { diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index e34dc341b..ad293cb6a 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -268,6 +268,25 @@ pub(super) fn selection_geometry(app: &FigApp, painter: &egui::Painter, doc: &Ar }) } +/// [`selection_geometry`] as the canvas shows it now, and how far to shift it (on screen, on the +/// page): while a gesture has only moved the selection, the geometry where it started (kept for +/// the whole gesture) shifted by the move ([`crate::selection_cache::moved`]). +fn shown_geometry( + app: &FigApp, + painter: &egui::Painter, + ds: &figcraft_engine::DocState, + doc: &Arc, + sel: &[NodeId], +) -> (SelectionGeometry, egui::Vec2, figcraft_geom::Vec2) { + match crate::selection_cache::moved(ds) { + Some((base, (dx, dy))) => { + let on_screen = app.to_screen(dx, dy) - app.to_screen(0.0, 0.0); + (selection_geometry(app, painter, &base, sel), on_screen, figcraft_geom::Vec2::new(dx, dy)) + } + None => (selection_geometry(app, painter, doc, sel), egui::Vec2::ZERO, figcraft_geom::Vec2::ZERO), + } +} + /// The selection's outlines, transform box and handles; returns the size label's box. fn selection_overlay( app: &FigApp, @@ -278,9 +297,16 @@ fn selection_overlay( zoom: f64, ) -> Option { let mut size_label = None; - let geo = selection_geometry(app, painter, doc, sel); + let (geo, shift, page_shift) = shown_geometry(app, painter, ds, doc, sel); let col = geo.col; - painter.extend(geo.outlines.iter().cloned()); + if shift == egui::Vec2::ZERO { + painter.extend(geo.outlines.iter().cloned()); + } else { + painter.extend(geo.outlines.iter().cloned().map(|mut s| { + s.translate(shift); + s + })); + } // Constraint guides belong to the normal selection overlay: not while the Shape Builder // reshapes by regions, nor while measuring to another layer (Alt), whose lines take over. if app.session.tool.tool != ToolId::ShapeBuilder && app.session.tool.overlay.measure_box.is_none() { @@ -288,7 +314,7 @@ fn selection_overlay( } // The shape builder reshapes by regions: no transform box. if app.session.tool.tool != ToolId::ShapeBuilder - && let Some(b) = geo.bbox + && let Some(b) = geo.bbox.map(|b| b.map(|p| p + page_shift)) && let Some((w, h)) = geo.size { let is_line = sel.len() == 1 && matches!(sel.first().and_then(|i| doc.node(*i)).map(|n| &n.kind), Some(NodeKind::Line)); diff --git a/crates/ui-egui/src/canvas/selection_cache_tests.rs b/crates/ui-egui/src/canvas/selection_cache_tests.rs index ca20e5447..49427ce26 100644 --- a/crates/ui-egui/src/canvas/selection_cache_tests.rs +++ b/crates/ui-egui/src/canvas/selection_cache_tests.rs @@ -13,6 +13,7 @@ use serde_json::{Value, json}; use super::overlays::{SelectionGeometry, overlays, selection_geometry}; use crate::theme::{Tokens, set_canvas_page}; use crate::{FigApp, NoServices}; +use figcraft_engine::tools::{PointerEvent, PointerKind}; fn id(v: Value) -> NodeId { NodeId(v["id"].as_u64().unwrap()) @@ -176,3 +177,63 @@ fn the_position_fields_follow_edits_and_the_selection() { app.set_props(&[ids[1]], json!({"x": 90})); assert_eq!(x(&app), Some(90.0), "an edit"); } + +/// The visual box of every shape one frame of the canvas overlays draws, in drawing order. +fn boxes(app: &FigApp, ctx: &Context) -> Vec { + let mut out = ctx.run_ui(Default::default(), |ui| { + overlays(app, &ui.ctx().layer_painter(LayerId::background()), &Tokens::light()); + }); + out.textures_delta.clear(); + out.shapes.iter().map(|s| s.shape.visual_bounding_rect()).collect() +} + +/// A drag of the selection held at (`x`, `y`) on the page, from (10, 10); snapping is off (Cmd). +fn drag_to(app: &mut FigApp, x: f64, y: f64) { + let ev = |kind, x, y| PointerEvent { cmd: true, ..PointerEvent::at(kind, x, y, 1.0) }; + if app.session.ds().unwrap().interaction.is_none() { + app.session.pointer(&ev(PointerKind::Down, 10.0, 10.0)).unwrap(); + } + app.session.pointer(&ev(PointerKind::Move, x, y)).unwrap(); +} + +#[test] +fn a_move_shifts_the_geometry_kept_from_where_it_started() { + let (mut app, ctx, _) = setup(); + let start = app.session.ds().unwrap().doc.clone(); + drag_to(&mut app, 47.0, 71.0); + assert_eq!(app.session.ds().unwrap().interaction.as_ref().and_then(|i| i.moved_by), Some((37.0, 61.0))); + let moving = boxes(&app, &ctx); + let first = selection_geometry(&app, &Painter::new(ctx.clone(), LayerId::background(), Rect::EVERYTHING), &start, &app.session.selection()); + drag_to(&mut app, 57.0, 91.0); + let again = boxes(&app, &ctx); + let kept_since = selection_geometry(&app, &Painter::new(ctx.clone(), LayerId::background(), Rect::EVERYTHING), &start, &app.session.selection()); + assert!(kept(&first, &kept_since), "the geometry where the move started is kept for it"); + // What shows is what the moved document shows when nothing is kept, to a hundredth of a point. + let mut fresh = FigApp::new(Session::new(), Box::new(NoServices)); + fresh.canvas.rect = app.canvas.rect; + { + let (ds, now) = (fresh.session.ds_mut().unwrap(), app.session.ds().unwrap()); + (ds.doc, ds.selection, ds.view) = (now.doc.clone(), now.selection.clone(), now.view.clone()); + } + let ctx2 = Context::default(); + set_canvas_page(&ctx2, Color32::WHITE); + let want = boxes(&fresh, &ctx2); + assert_eq!(again.len(), want.len()); + for (got, want) in again.iter().zip(&want) { + assert!((got.min - want.min).length() < 0.01 && (got.max - want.max).length() < 0.01, "{got:?} != {want:?}"); + } + assert_ne!(moving, again, "the second move shows further on"); +} + +#[test] +fn the_gesture_start_stands_in_only_while_the_selection_is_only_moved() { + let (mut app, _, _) = setup(); + let start = app.session.ds().unwrap().doc.clone(); + assert!(crate::selection_cache::moved(app.session.ds().unwrap()).is_none(), "no gesture"); + drag_to(&mut app, 30.0, 10.0); + let (base, d) = crate::selection_cache::moved(app.session.ds().unwrap()).expect("a plain move"); + assert!(Arc::ptr_eq(&base, &start) && d == (20.0, 0.0)); + // The selection changing under the gesture: the start no longer stands in. + app.session.ds_mut().unwrap().selection.pop(); + assert!(crate::selection_cache::moved(app.session.ds().unwrap()).is_none()); +} diff --git a/crates/ui-egui/src/canvas/selection_spacing.rs b/crates/ui-egui/src/canvas/selection_spacing.rs index 8dc9db62b..04a4ecb92 100644 --- a/crates/ui-egui/src/canvas/selection_spacing.rs +++ b/crates/ui-egui/src/canvas/selection_spacing.rs @@ -28,13 +28,16 @@ struct LayoutCache { /// refreshing next frame after an edit and reusing an unchanged document snapshot. fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc { let Ok(ds) = app.session.ds() else { return Arc::new(SelectionSpacing::default()) }; + // The gaps between layers moved together stay as they were: while a gesture only moves the + // selection, they come from where it started. + let doc = crate::selection_cache::moved(ds).filter(|_| ds.selection == ids).map_or_else(|| ds.doc.clone(), |(b, _)| b); let key = Id::unique("selection_spacing_layout"); let frame = ctx.cumulative_frame_nr(); if let Some(mut cache) = ctx.data(|d| d.get_temp::(key)) && cache.document == ds.uid && cache.page == ds.page && cache.ids == ids - && (cache.frame == frame || Arc::ptr_eq(&cache.doc, &ds.doc)) + && (cache.frame == frame || Arc::ptr_eq(&cache.doc, &doc)) { if cache.frame != frame { cache.frame = frame; @@ -44,13 +47,10 @@ fn layout(app: &FigApp, ctx: &egui::Context, ids: &[NodeId]) -> Arc, + mixed: bool, + /// Every selected layer is a text layer. + text: bool, + /// The layers an export writes, the first one's preview, and the button's label. + layers: BTreeSet, + first: Option, + name: Option, +} + +fn exports(doc: &Document, sel: &[NodeId]) -> Exports { + let shared = common(doc, sel, |n| n.exports.clone()); + let jobs = export_jobs(doc, sel); + let layers: BTreeSet = jobs.iter().map(|j| j.id).collect(); + Exports { + mixed: shared.is_none() && sel.len() > 1, + list: shared.unwrap_or_default(), + text: sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))), + first: jobs.first().map(|j| j.id), + name: layers.first().and_then(|i| doc.node(*i)).map(|n| n.name.clone()), + layers, + } +} + +pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId]) { let t = Tokens::get(ui.ctx()); let ids = sel.to_vec(); - let shared = common(doc, sel, |n| n.exports.clone()); - let mixed = shared.is_none() && sel.len() > 1; - let list = shared.unwrap_or_default(); + let Exports { list, mixed, text, layers, first, name } = crate::selection_cache::cached(ui.ctx(), "exports", doc, sel, &[], || exports(doc, sel)); let tip = if mixed { tr("Replace mixed export settings") } else { tr("Add export setting") }; if widgets::section_header(ui, tr("Export"), &[(Icon::Plus, tip)], list.is_empty()).is_some() { if mixed { @@ -40,20 +68,13 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Document, sel: &[NodeId]) { if mixed { widgets::note(ui, tr("Click + to replace mixed content"), t.text_secondary); } - let text = sel.iter().all(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))); for (i, e) in list.iter().enumerate() { setting_row(app, ui, &ids, i, e, text); } - let jobs = export_jobs(doc, sel); - if jobs.is_empty() { - return; - } - if let Some(first) = jobs.first().map(|j| j.id) { - preview(app, ui, doc, first); - } - let layers: BTreeSet = jobs.iter().map(|j| j.id).collect(); - let label = match (layers.len(), layers.first().and_then(|i| doc.node(*i))) { - (1, Some(n)) => trf("Export {name}", &[("name", &n.name)]), + let Some(first) = first else { return }; + preview(app, ui, doc, first); + let label = match (layers.len(), name) { + (1, Some(name)) => trf("Export {name}", &[("name", &name)]), (k, _) => trn("Export {n} layers", k, &[]), }; if wide_button(ui, &label) { diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 152aae707..58a1a6e19 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -248,58 +248,61 @@ fn design(app: &mut FigApp, ui: &mut Ui) { let Ok(ds) = app.session.ds() else { return }; // The panel shows the document itself, not a live text preview over it. let doc = ds.text_preview.clone().unwrap_or_else(|| ds.doc.clone()); + // While a gesture only moves the selection, nothing the sections show changes but where it + // is: they show the document it started from, so what they keep stays kept (#1571). + let shown = crate::selection_cache::moved(ds).filter(|_| ds.text_preview.is_none()).map_or_else(|| doc.clone(), |(b, _)| b); let sel = ds.selection.clone(); let vector_edit = ds.vector_edit.clone(); if sel.is_empty() { page_section(app, ui, &doc); return; } - header(app, ui, &doc, &sel); + header(app, ui, &shown, &sel); widgets::separator(ui); // Editing a vector with points selected, the points' fields stand in for the layer's position // and dimensions. let points_selected = vector_edit.as_ref().is_some_and(|ve| !ve.vertices.is_empty() || !ve.segments.is_empty()); let vector_edit_none = vector_edit.is_none(); match vector_edit { - Some(ve) => crate::vector_panel::points(app, ui, &doc, &ve), + Some(ve) => crate::vector_panel::points(app, ui, &shown, &ve), None => { - crate::section_panel::section(app, ui, &doc, &sel); + crate::section_panel::section(app, ui, &shown, &sel); if app.session.tool.tool == figcraft_engine::ToolId::Scale { - crate::scale_panel::section(app, ui, &doc, &sel); + crate::scale_panel::section(app, ui, &shown, &sel); widgets::separator(ui); } - position(app, ui, &doc, &sel); + position(app, ui, &doc, &shown, &sel); } } widgets::separator(ui); if !points_selected { - crate::layout_panel::layout_section(app, ui, &doc, &sel); + crate::layout_panel::layout_section(app, ui, &shown, &sel); widgets::separator(ui); } - if vector_edit_none && arc_section(app, ui, &doc, &sel) || points_section(app, ui, &doc, &sel) { + if vector_edit_none && arc_section(app, ui, &shown, &sel) || points_section(app, ui, &shown, &sel) { widgets::separator(ui); } - appearance(app, ui, &doc, &sel); + appearance(app, ui, &shown, &sel); widgets::separator(ui); - if variable_modes(app, ui, &doc, &sel) { + if variable_modes(app, ui, &shown, &sel) { widgets::separator(ui); } if sel.iter().any(|i| matches!(doc.node(*i).map(|n| &n.kind), Some(NodeKind::Text(_)))) { - crate::typography::typography(app, ui, &doc, &sel); + crate::typography::typography(app, ui, &shown, &sel); widgets::separator(ui); } - paints(app, ui, &doc, &sel, true); + paints(app, ui, &shown, &sel, true); widgets::separator(ui); - paints(app, ui, &doc, &sel, false); + paints(app, ui, &shown, &sel, false); widgets::separator(ui); - effects(app, ui, &doc, &sel); + effects(app, ui, &shown, &sel); widgets::separator(ui); - crate::selection_colors::section(app, ui, &doc, &sel); + crate::selection_colors::section(app, ui, &shown, &sel); if sel.iter().all(|i| doc.node(*i).is_some_and(|n| n.kind.is_frame_like())) { - grids(app, ui, &doc, &sel); + grids(app, ui, &shown, &sel); widgets::separator(ui); } - crate::export_panel::section(app, ui, &doc, &sel); + crate::export_panel::section(app, ui, &shown, &sel); } /// Draw the page section: background and colour profile, local variables/styles, and the @@ -519,10 +522,12 @@ pub(crate) fn in_instance(ctx: &egui::Context, doc: &Arc, sel: &[NodeI }) } -fn position(app: &mut FigApp, ui: &mut Ui, doc: &Arc, sel: &[NodeId]) { +/// The Position section: its values from `doc`, what doesn't change with where the layers are from +/// `shown` (the panel's document, see `design`). +fn position(app: &mut FigApp, ui: &mut Ui, doc: &Arc, shown: &Arc, sel: &[NodeId]) { widgets::section_header(ui, tr("Position"), &[], false); // Layers inside an instance take their place and constraints from the main component. - let nested = in_instance(ui.ctx(), doc, sel).nested; + let nested = in_instance(ui.ctx(), shown, sel).nested; ui.add_enabled_ui(!nested, |ui| position_fields(app, ui, doc, sel)) .response .on_disabled_hover_text(tr("Layers in an instance take their position from the main component")); diff --git a/crates/ui-egui/src/selection_cache.rs b/crates/ui-egui/src/selection_cache.rs index 63ede6abb..e799c48b0 100644 --- a/crates/ui-egui/src/selection_cache.rs +++ b/crates/ui-egui/src/selection_cache.rs @@ -42,6 +42,16 @@ pub(crate) fn cached( value } +/// While a gesture has only moved the selection (`Interaction::moved_by`): the document it +/// started from and how far it moved them (page units). What depends only on the selected layers' +/// shapes, not where they are, is worked out from that document, so it is kept for the whole +/// gesture instead of made again on every move (#1571). +pub(crate) fn moved(ds: &figcraft_engine::DocState) -> Option<(Arc, (f64, f64))> { + let it = ds.interaction.as_ref()?; + let d = it.moved_by?; + (it.selection == ds.selection).then(|| (it.doc.clone(), d)) +} + /// A screen point as key material. pub(crate) fn bits(p: egui::Pos2) -> [u64; 2] { [u64::from(p.x.to_bits()), u64::from(p.y.to_bits())] diff --git a/docs/roadmap.md b/docs/roadmap.md index dcfeb15f4..4ecdc4299 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M12, #1597: Shift+Cmd/Ctrl+Enter on a board shape adds the next shape already connected (stickies unchanged). Tests: `board_connected_tests`, `board_ui_tests` | | 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | | 2026-10-30 | minor | M4, #1292: a `.fig` import said a family was not installed and drew it in a fallback font although the font picker listed it. Fonts name a family two ways: a typographic name ("Economia", style "Medium") and a legacy name per weight ("Economia Medium", style "Regular"); fonts without typographic names are installed under the legacy ones only, while a file may use either. The font database now resolves a family + style either way (`FontDb::resolve`, used by every lookup: import, paste, REST import, rendering, the missing-fonts check and the font report): a family named with weight words finds the family without them, a style the family lacks finds the family named with it, and the Style list offers those weights. Family names also match across case, spaces, hyphens and underscores, and a weight number style ("600") goes by that weight. `.fig` and REST imports wait for the installed fonts to be indexed before their text is laid out. Widths aren't taken for weights (a missing "Roboto Condensed" stays missing). Tests: `fontdb_names_tests.rs` (synthetic names), `fig_font_names_tests.rs` (a `.fig` round trip). | From 84e5278209ebd60354557286a60489205e5b1c7f Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 16:31:25 +0700 Subject: [PATCH 27/34] M6: figcraft-cli connect publish publishes code connect mappings to a design file (#1643) * M6: figcraft-cli connect publish publishes code connect mappings to a design file figcraft-cli connect publish [--mappings figcraft.connect.json] [--dry-run] links each entry's component or set by name through codeConnect.import, warns about and skips links that don't fit, saves the file in place through the engine's file.save, and exits 1 when nothing links. Gap 16. * M6: the generated man pages include connect and connect publish --- ROADMAP.md | 3 +- apps/figcraft-cli/src/args.rs | 40 +++++++++++- apps/figcraft-cli/src/files.rs | 64 ++++++++++++++++++++ apps/figcraft-cli/src/main.rs | 5 +- apps/figcraft-cli/tests/it/generated_docs.rs | 5 +- docs/cli.md | 20 +++++- docs/dev-mode-parity.md | 3 +- docs/gaps.md | 14 +++-- docs/roadmap.md | 3 +- 9 files changed, 144 insertions(+), 13 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0da9af23d..171b43e34 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-10-30 · **Change:** minor (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; 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; 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); 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); 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-10-30 · **Change:** minor (`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; 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; 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); 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); 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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish [--mappings FILE] [--dry-run]` publishes a code repository's code connect mappings (`figcraft.connect.json`, as Export code connections writes it) to a design file: each link connects the components or sets of that name to their code for its language; links that don't fit are warnings and are skipped; the file is saved in place through the engine's `file.save` (whole or not at all, version history kept); a dry run saves nothing; nothing linked is exit code 1. Numbers unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, `args` tests. | | 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M12, #1597 (the third point): Shift+Cmd/Ctrl+Enter (`board.nextSticky`) now works on a selected or edited shape too: it adds the next shape of the same kind, size and colour on its right (or the `side` given), joined to it by a connector and with typing going on in it, as the "+" beside it does (`board.addConnected`, #1631). On a sticky it adds a sticky beside it without a connector, as before. Numbers unchanged. Tests: `board_connected_tests::the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were`, `board_ui_tests::the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing`. | | 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | diff --git a/apps/figcraft-cli/src/args.rs b/apps/figcraft-cli/src/args.rs index 216fae5f7..063326c3d 100644 --- a/apps/figcraft-cli/src/args.rs +++ b/apps/figcraft-cli/src/args.rs @@ -41,6 +41,11 @@ pub enum Command { #[command(subcommand)] command: PluginCommand, }, + /// Publish a code repository's component mappings (code connect) to a design file + Connect { + #[command(subcommand)] + command: ConnectCommand, + }, Perf(PerfArgs), } @@ -228,6 +233,31 @@ pub struct PublishArgs { pub server: String, } +#[derive(Subcommand)] +pub enum ConnectCommand { + Publish(ConnectPublishArgs), +} + +/// Link a design file's components to their code as a figcraft.connect.json says, and save it +/// +/// The mappings are what Dev Mode's Export code connections writes, kept in the code repository: +/// each links a component (or component set) of that name to its code component for a language. +/// Entries that don't fit (an unknown component, a bad template) are reported and skipped. +#[derive(Args)] +pub struct ConnectPublishArgs { + /// The design file (.figcraft), saved in place + #[arg(value_name = "FILE")] + pub file: String, + /// The mappings + #[arg(long, value_name = "FILE", default_value = "figcraft.connect.json")] + pub mappings: String, + /// Report what would be linked without saving the file + #[arg(long)] + pub dry_run: bool, + #[command(flatten)] + pub roots: RootArgs, +} + /// Measure the hot paths on synthetic documents and print Markdown tables #[derive(Args)] pub struct PerfArgs { @@ -326,7 +356,7 @@ mod tests { use clap::CommandFactory; use clap::error::ErrorKind; - use super::{Cli, Command, PluginCommand, parse}; + use super::{Cli, Command, ConnectCommand, PluginCommand, parse}; fn try_parse(args: &[&str]) -> Result { parse(std::iter::once("figcraft-cli").chain(args.iter().copied()).map(Into::into).collect()) @@ -434,6 +464,14 @@ mod tests { panic!("plugin") }; assert_eq!((a.folder.as_str(), a.server.as_str()), ("dist/counter", "ws://h:47900/")); + let Command::Connect { command: ConnectCommand::Publish(a) } = ok(&["connect", "publish", "ui.figcraft"]) else { panic!("connect") }; + assert_eq!((a.file.as_str(), a.mappings.as_str(), a.dry_run), ("ui.figcraft", "figcraft.connect.json", false)); + let Command::Connect { command: ConnectCommand::Publish(a) } = + ok(&["connect", "publish", "ui.figcraft", "--mappings", "code/map.json", "--dry-run"]) + else { + panic!("connect") + }; + assert_eq!((a.mappings.as_str(), a.dry_run), ("code/map.json", true)); } #[test] diff --git a/apps/figcraft-cli/src/files.rs b/apps/figcraft-cli/src/files.rs index 8614e2cdd..29f8cd5b6 100644 --- a/apps/figcraft-cli/src/files.rs +++ b/apps/figcraft-cli/src/files.rs @@ -459,6 +459,34 @@ pub fn run_script(script: &str, input: Option<&str>, out: Option<&str>) -> CliRe Ok(ExitCode::SUCCESS) } +/// Link the components of `file` (a `.figcraft`) to their code as `mappings` (a +/// `figcraft.connect.json`) says, and save it in place unless `dry_run`. Prints what was linked; +/// entries that were skipped are warnings on stderr, and linking nothing fails (exit code 1). +pub fn connect_publish(file: &str, mappings: &str, dry_run: bool) -> CliResult { + if ext(file) != "figcraft" { + return Err(CliError::Usage(format!("{file}: connect publish saves code links in a .figcraft file"))); + } + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": file})).map_err(|e| CliError::Failed(format!("can't open {file}: {e}")))?; + let r = s.execute("codeConnect.import", &json!({"path": mappings})).map_err(|e| CliError::Failed(format!("{mappings}: {e}")))?; + for w in r["warnings"].as_array().into_iter().flatten().filter_map(Value::as_str) { + eprintln!("warning: {w}"); + } + let linked = r["linked"].as_u64().unwrap_or(0); + if linked == 0 { + eprintln!("error: no component of {file} matches a link in {mappings}: nothing published"); + return Ok(ExitCode::from(1)); + } + if dry_run { + println!("{linked} component link(s) from {mappings} would be published to {file} (dry run: not saved)"); + return Ok(ExitCode::SUCCESS); + } + // The engine's save: written whole or not at all, with the file's version history. + s.execute("file.save", &json!({})).map_err(|e| CliError::Failed(format!("can't save {file}: {e}")))?; + println!("published {linked} component link(s) from {mappings} to {file}"); + Ok(ExitCode::SUCCESS) +} + #[cfg(test)] mod tests { use super::*; @@ -557,6 +585,42 @@ mod tests { std::fs::remove_dir_all(dir).unwrap(); } + /// `connect publish` links the file's components as the mappings say and saves the file in + /// place; a dry run saves nothing, and mappings that link nothing fail. + #[test] + fn connect_publish_links_components_and_saves_the_file() { + let dir = temp_dir("connect"); + let file = dir.join("ui.figcraft"); + let file = file.to_str().unwrap(); + let mut s = Session::new(); + let frame = s.execute("frame.create", &json!({"x": 0, "y": 0, "width": 100, "height": 40, "name": "Card"})).unwrap(); + s.execute("component.create", &json!({"ids": [frame["id"]]})).unwrap(); + s.execute("file.saveAs", &json!({"path": file})).unwrap(); + let mappings = dir.join("figcraft.connect.json"); + let link = + |component: &str| json!({"component": component, "language": "react", "importPath": "@acme/ui", "name": "Card", "template": ""}); + std::fs::write(&mappings, json!({"version": 1, "links": [link("Card"), link("Nope")]}).to_string()).unwrap(); + let mappings = mappings.to_str().unwrap(); + let links = || { + let mut s = Session::empty(); + s.execute("file.open", &json!({"path": file})).unwrap(); + let doc = s.doc().unwrap(); + doc.nodes().filter(|n| n.name == "Card").map(|n| n.code_links.len()).sum::() + }; + + assert_eq!(connect_publish(file, mappings, true).unwrap(), ExitCode::SUCCESS); + assert_eq!(links(), 0, "a dry run saves nothing"); + assert_eq!(connect_publish(file, mappings, false).unwrap(), ExitCode::SUCCESS, "an unknown component is only a warning"); + assert_eq!(links(), 1, "Card is linked to its code in the saved file"); + + let none = dir.join("none.json"); + std::fs::write(&none, json!({"version": 1, "links": [link("Nope")]}).to_string()).unwrap(); + assert_eq!(connect_publish(file, none.to_str().unwrap(), false).unwrap(), ExitCode::from(1)); + assert!(matches!(connect_publish(dir.join("ui.svg").to_str().unwrap(), mappings, false), Err(CliError::Usage(_)))); + assert!(matches!(connect_publish(file, dir.join("missing.json").to_str().unwrap(), false), Err(CliError::Failed(_)))); + std::fs::remove_dir_all(dir).unwrap(); + } + /// A script resolves `{"$step": n}` like the same commands through `command.batch`. Each step /// used to run on its own, so the reference reached `parent` unresolved, `node.create` read it /// as "no parent" and the layer landed on the page — with every step reporting success. diff --git a/apps/figcraft-cli/src/main.rs b/apps/figcraft-cli/src/main.rs index 17cecb898..ef846bce2 100644 --- a/apps/figcraft-cli/src/main.rs +++ b/apps/figcraft-cli/src/main.rs @@ -1,6 +1,6 @@ //! `figcraft-cli`: FigCraft without a window. Inspect, export, convert and render documents, run //! command scripts, list commands, serve MCP on stdio, publish plugins to a server's plugin -//! directory and measure performance. +//! directory, publish code connect mappings to a design file and measure performance. #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod args; @@ -10,7 +10,7 @@ mod perf; use std::io::Write; use std::process::ExitCode; -use args::{Cli, Command, PluginCommand, RootArgs}; +use args::{Cli, Command, ConnectCommand, PluginCommand, RootArgs}; use serde_json::{Value, json}; /// The automation roots a file subcommand was given, if any. Omitting both leaves the run @@ -93,6 +93,7 @@ fn run(cli: Cli) -> CliResult { // `--attach` conflicts with the roots: the app runs the commands, only its own roots can confine them. Command::Mcp(a) => mcp(a.attach.as_deref(), roots_of(&a.roots)?), Command::Plugin { command: PluginCommand::Publish(a) } => publish_plugin(&a.folder, &a.server), + Command::Connect { command: ConnectCommand::Publish(a) } => confined(&a.roots, || files::connect_publish(&a.file, &a.mappings, a.dry_run)), Command::Perf(a) => { let sizes: Vec = a.layers.into_iter().map(|n| n.clamp(10, 2_000_000)).collect(); let instances: Vec = a.instances.into_iter().filter(|n| *n > 0).map(|n| n.min(100_000)).collect(); diff --git a/apps/figcraft-cli/tests/it/generated_docs.rs b/apps/figcraft-cli/tests/it/generated_docs.rs index 061be5b38..0cbb4022d 100644 --- a/apps/figcraft-cli/tests/it/generated_docs.rs +++ b/apps/figcraft-cli/tests/it/generated_docs.rs @@ -99,6 +99,8 @@ fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::R pages, [ "figcraft-cli-commands.1", + "figcraft-cli-connect-publish.1", + "figcraft-cli-connect.1", "figcraft-cli-convert.1", "figcraft-cli-export.1", "figcraft-cli-info.1", @@ -122,6 +124,7 @@ fn man_pages_cover_every_subcommand_without_synthetic_help_pages() -> std::io::R ("figcraft-cli-export.1", "--format"), ("figcraft-cli-export.1", "--automation-read-root"), ("figcraft-cli-plugin-publish.1", "--server"), + ("figcraft-cli-connect-publish.1", "--dry-run"), ] { let text = fs::read_to_string(dir.join(page))?.replace("\\-", "-"); assert!(text.contains(option), "{page} does not contain {option}"); @@ -142,7 +145,7 @@ fn generation_creates_the_output_directory_and_can_run_again() -> std::io::Resul fs::write(&page, "old page")?; generator::generate(&output)?; assert_eq!(fs::read(page)?, first); - assert_eq!(fs::read_dir(output)?.count(), 16); + assert_eq!(fs::read_dir(output)?.count(), 18); Ok(()) } diff --git a/docs/cli.md b/docs/cli.md index c437d190d..6c2f8aec4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,7 +1,7 @@ # figcraft-cli FigCraft without a window: inspect, export, convert and render documents, run command scripts, list -commands, serve MCP and measure performance. Build it with +commands, serve MCP, publish plugins and code connect mappings, and measure performance. Build it with `cargo build --release -p figcraft-cli` (binary `target/release/figcraft-cli`). The CLI follows [RFC-0012](https://github.com/storytold/craft-contrib/blob/main/rfcs/0012-cli-conventions.md), @@ -295,6 +295,24 @@ size and SHA-256. The server's key comes from the address or, better, from the lists); it is never printed. The bundle is checked before it is sent and again by the server. See [plugins.md](plugins.md#plugin-directories). +### `connect publish [--mappings FILE] [--dry-run]` + +Publishes a code repository's component mappings to a design file: each link in the mappings file +(default `figcraft.connect.json` in the current folder, the file Dev Mode's **Export code +connections** and `codeConnect.export` write) connects every component or component set of that +name to its code component for a language, replacing its link for that language, and the file is +saved in place, written whole or not at all, with its version history. Links that don't fit (no +component of that name, a bad template or language) are printed as warnings and skipped; if nothing +is linked, nothing is saved and the exit code is 1. `--dry-run` reports what would be linked +without saving. Run it from the repository, for example in CI after the mappings change: + +```sh +figcraft-cli connect publish ../design/ui.figcraft --mappings figcraft.connect.json +``` + +It takes the automation roots like the other file subcommands. See +[dev-mode-parity.md](dev-mode-parity.md) for code connect itself. + ### `perf [--layers N[,N...]] [--instances N[,N...]]` Builds synthetic documents of each size (default `1000,10000` layers: screens with an auto layout diff --git a/docs/dev-mode-parity.md b/docs/dev-mode-parity.md index 80d351d26..8ece694a8 100644 --- a/docs/dev-mode-parity.md +++ b/docs/dev-mode-parity.md @@ -18,7 +18,7 @@ repository, third-party integrations. Remaining: 1–2 h. | Ready for dev list, statuses, "Changed" after edits | done | the list filters Ready for dev, Changed, Completed or any, and badges changed ones (`inspect.readyForDev {status: changed}`, `changed` per item); only sections and frames at the top of a page or section take a status (others are refused) | | Focus view (one frame, previous / next) | done | | | Compare changes (before/after side by side or overlaid, property diffs) | done | against the snapshot taken when a frame was first changed after it was marked ready, or any saved version (`dev.compare {version}`; the view's Compare with menu); a frame that isn't ready for dev compares with versions | -| Code connect (map components to repository code) | done | per language (React, Leptos, Vue, SwiftUI, Compose, ArkUI, Flutter): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. No CLI publishing mappings from the code repository | +| Code connect (map components to repository code) | done | per language (React, Leptos, Vue, SwiftUI, Compose, ArkUI, Flutter): import path, name and a template with `{property}` and `{property ? "a" : "b"}` placeholders; instances (also inside generated layer trees) show the snippet with their values; edited in the Inspect panel with a live preview; `figcraft.connect.json` export/import; generated layer trees bring the import lines of the connected components they call (also in definitions, slots and swapped-in instances) once each at the top, per language. `figcraft-cli connect publish` publishes a repository's mappings to a design file (`--dry-run` to check them first) | | Plugins in Dev Mode (Jira, GitHub, etc.) | partial | FigCraft's own WASM plugins that only read run from the Inspect tab, with their panels (callbacks can only read); no third-party integrations | | Measurements (redlines on hover, Alt in the design) | done | distances between the selection and the hovered layer, its parent or a guide, auto layout padding and gaps (Shift+Alt); measurements kept on the canvas (`devMode.addMeasurement` between two layers, along a side or to the layer around it; stored on the layer, saved, synced, undoable) drawn in Dev Mode as labelled redlines that follow the layers, listed, placed and removed in the Inspect tab; a measurement whose other layer was deleted isn't drawn until an undo brings it back | | VS Code extension | out of scope | FigCraft's MCP server and control channel already serve design data, code and connected snippets to agents and editors | @@ -28,6 +28,7 @@ repository, third-party integrations. Remaining: 1–2 h. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish [--mappings figcraft.connect.json] [--dry-run]` publishes a code repository's mappings to a design file (as the incumbent's Code Connect CLI publishes a repository's mappings): it links each entry's component or set by name, warns about and skips links that don't fit, saves the file in place through the engine's save (or not, with `--dry-run`) and fails when nothing links. Dev Mode breadth (~98%) and ready (~78%) unchanged at this rounding; hours unchanged | | 2026-10-26 | minor | Class names from layer names transliterate non-ASCII scripts (#1235): `class_name` runs names through `deunicode` (new dependency: pure Rust tables, zero deps, BSD-3-Clause), so Korean, Cyrillic, Latin-diacritic and CJK names become readable ASCII classes in HTML/CSS/React/Leptos output, component roots and `data-*` variant attributes (`rogeuin-hwamyeon`, `knopka`, `uber`, `an-niu`; before, every one of them came out `layer-`); symbols-only names take the node's kind (`.text`, `.frame`) instead of `layer-`, and the 40-character cut lands on a word boundary. ASCII-only names are unchanged. `html_codegen_tests` updated (its diacritics expectation improves from `layer-1-n-code-name` to `layer-1-unicode-name`) and extended. Percentages and hours unchanged | | 2026-10-24 | minor | Dev Mode refuses design edits from files dropped on the canvas (a dropped document still opens as a file of its own, and the drop overlay shows the Dev Mode note for other files), from the Pages list's add, rename, reorder, duplicate and delete, and from the Assets panel's place, drag, Alt-drop swap, style, Hide when publishing, Update, and adding, removing and swapping libraries. Percentages and hours unchanged | | 2026-10-24 | minor | Measurements kept on the canvas (gap 16): `devMode.addMeasurement {from?, to?, side?}` places one between two layers, along one side of a layer or (neither) to the layer around it, stored on the layer it starts from (saved in `.figcraft`, synced, undoable; old files open without them); Dev Mode draws them as labelled redlines that follow the layers, the Inspect tab lists them (those of the selected layer, or every one on the page), places and removes them; `devMode.removeMeasurement` and `devMode.measurements` remove and list them; a copy keeps those to layers copied with it. The layers panel is read-only in Dev Mode: rows select and expand, but don't rename, reorder, hide or lock. Breadth 97% → 98%, ready 76% → 78%, 2–4 h → 1–2 h | diff --git a/docs/gaps.md b/docs/gaps.md index 3d8207822..dc96ee9dd 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -29,7 +29,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 13 | Vector tail: none left (boolean hit testing on the result landed 2026-10-11) | Feature | — | [target-app-parity](target-app-parity.md) | | 14 | Variables: extended collections checked on real libraries (the tail landed 2026-10-12) | Feature | 0–1 | [components-parity](components-parity.md) | | 15 | Prototyping: media triggers and actions; controllers on macOS, Linux and FreeBSD (controller buttons on key/gamepad triggers, widgets in prototypes landed) | Feature | 2–5 | [prototyping-parity](prototyping-parity.md) | -| 16 | Dev Mode: publishing mappings from the code repository; the editor extension is left to the MCP server (measurements kept on the canvas and a read-only layers panel landed 2026-10-24) | Feature | 1–2 | [dev-mode-parity](dev-mode-parity.md) | +| 16 | Dev Mode: renaming the file in Dev Mode to check; the editor extension is left to the MCP server (publishing mappings from the code repository landed 2026-10-30) | Feature | 1–2 | [dev-mode-parity](dev-mode-parity.md) | | 17 | Branching: text runs merged whole, branches only their author sees in a live session (branches in live sessions, export settings and guides item by item, and history entries for creation and updates landed 2026-10-24) | Feature | 1–2 | [collaboration-parity](collaboration-parity.md) | | 18 | Display P3 on screen: blocked upstream (egui-wgpu picks an 8-bit sRGB surface); gamut warning and soft-proof sRGB landed, as did several windows | Hardware | 4–8 | [hardware-parity](hardware-parity.md) | | 19 | AI features | AI | 30–60 | [target-app-parity](target-app-parity.md) | @@ -447,10 +447,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe - **Landed (2026-10-24):** files dropped on the canvas, the Pages list and the Assets panel no longer edit the design in Dev Mode (a dropped document still opens as a file of its own, and the Pages list only opens pages). -- **Missing:** publishing mappings from the code repository with a CLI. An editor extension is out - of scope: the MCP server already serves design data, code and the connected snippets to agents - and editors. The file name field in the left panel still renames the file in Dev Mode - (`file.rename`); whether Figma allows that in Dev Mode needs checking before it changes. +- **Landed (2026-10-30):** `figcraft-cli connect publish [--mappings + figcraft.connect.json] [--dry-run]` publishes a code repository's mappings to a design file, so + code owners keep them with the code and publish them from CI ([cli.md](cli.md)). +- **Missing:** the file name field in the left panel still renames the file in Dev Mode + (`file.rename`); whether Figma allows that in Dev Mode needs checking before it changes. An + editor extension is out of scope: the MCP server already serves design data, code and the + connected snippets to agents and editors. - **Estimate:** 1–2 h. ### 17. Branching and merging @@ -562,6 +565,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish` publishes a repository's `figcraft.connect.json` to a design file (links by component name, warnings for links that don't fit, `--dry-run`, exit 1 when nothing links). The CLI part leaves the missing list; the Dev Mode file-rename check remains; estimate unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, the CLI argument tests | | 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. | | 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | | 2026-10-29 | minor | Gap 7: the browser draws a zoom's full render a band at a time (eight at most), so a deep zoom no longer holds the page for the whole render; pans and edits meanwhile are patched in once it is drawn. Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4ecdc4299..c74c64d97 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (compact layer trees with fields and outline (M0.9, part of #996); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish` publishes a repository's code connect mappings to a `.figcraft` file (`--dry-run`). Tests: `files::tests`, `args` tests | | 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M12, #1597: Shift+Cmd/Ctrl+Enter on a board shape adds the next shape already connected (stickies unchanged). Tests: `board_connected_tests`, `board_ui_tests` | | 2026-10-30 | minor | M12, #1597: boards create the next shape already connected. A selected sticky or shape shows a "+" beside each of its four sides (clear of the contextual bar, the size label and the toolbar); hovering one shows where the next goes and the connector to it; clicking makes a sticky, or a shape of the same kind, size and colour, 80 units away on that side, joined to it by a connector the way the Connector tool makes one, and starts typing in it. One undo step takes both away. `board.addConnected {id?, side?, edit?}` does it from commands, MCP and plugins. Shift+Cmd/Ctrl+Enter still adds a sticky without a connector. Tests: `board_connected_tests.rs` (engine), `board_connect_tests.rs` (the handles, headless). | From 3d7ca30d51e1124858ff58e92a573bc769624384 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 16:45:33 +0700 Subject: [PATCH 28/34] M6: plugin panels take a slider control (gap 6) (#1637) --- ROADMAP.md | 3 +- crates/plugin/src/manifest.rs | 11 +++-- crates/plugin/src/v2_tests.rs | 9 +++- crates/ui-egui/src/a11y.rs | 13 ++++++ crates/ui-egui/src/dialogs/plugins_ui.rs | 29 ++++++++++++ crates/ui-egui/src/panels/typography/axes.rs | 42 ++--------------- crates/ui-egui/src/plugin_panel_tests.rs | 31 ++++++++++++- crates/ui-egui/src/widgets.rs | 48 ++++++++++++++++++++ docs/gaps.md | 8 +++- docs/plugins.md | 8 ++-- docs/roadmap.md | 3 +- 11 files changed, 153 insertions(+), 52 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 171b43e34..c117f9fb1 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-10-30 · **Change:** minor (`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; 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; 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); 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); 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-10-30 · **Change:** minor (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; 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; 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); 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); 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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 6: plugin panels (and plugin command forms) take a `slider` control: a number between a required `min` and `max`, drawn as a slider with the value field beside it, named for screen readers as a slider with its range. Dragging shows the value live and calls the plugin back once, when let go (one undo step); typing in the field or double-clicking the slider (back to the panel's value) do the same. The slider is the one the Type settings' variable axes used, moved into the shared widgets, which also gives those axis sliders a screen-reader name. Numbers unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go`. | | 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish [--mappings FILE] [--dry-run]` publishes a code repository's code connect mappings (`figcraft.connect.json`, as Export code connections writes it) to a design file: each link connects the components or sets of that name to their code for its language; links that don't fit are warnings and are skipped; the file is saved in place through the engine's `file.save` (whole or not at all, version history kept); a dry run saves nothing; nothing linked is exit code 1. Numbers unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, `args` tests. | | 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M12, #1597 (the third point): Shift+Cmd/Ctrl+Enter (`board.nextSticky`) now works on a selected or edited shape too: it adds the next shape of the same kind, size and colour on its right (or the `side` given), joined to it by a connector and with typing going on in it, as the "+" beside it does (`board.addConnected`, #1631). On a sticky it adds a sticky beside it without a connector, as before. Numbers unchanged. Tests: `board_connected_tests::the_next_sticky_shortcut_joins_the_next_shape_and_leaves_stickies_as_they_were`, `board_ui_tests::the_next_shape_from_the_keyboard_comes_connected_and_goes_on_typing`. | diff --git a/crates/plugin/src/manifest.rs b/crates/plugin/src/manifest.rs index 92cfac7b5..6b13239d0 100644 --- a/crates/plugin/src/manifest.rs +++ b/crates/plugin/src/manifest.rs @@ -143,7 +143,7 @@ pub struct Param { /// The choices of a `select` or `list`. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub options: Vec, - /// Bounds of a `number`. + /// Bounds of a `number` (or a `slider`, which needs both). #[serde(default, skip_serializing_if = "Option::is_none")] pub min: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -163,6 +163,8 @@ pub enum ParamKind { Color, /// One of `options`, shown as a list rather than a menu. List, + /// A number between `min` and `max` (both required), shown as a slider. + Slider, /// Panels only: a button (it has no value). Button, /// Panels only: text (`label`, up to 1000 characters). @@ -187,7 +189,7 @@ impl Param { } match self.kind { ParamKind::Text => Value::String(String::new()), - ParamKind::Number => serde_json::json!(self.clamp(0.0)), + ParamKind::Number | ParamKind::Slider => serde_json::json!(self.clamp(0.0)), ParamKind::Boolean => Value::Bool(false), ParamKind::Select | ParamKind::List => self.options.first().map_or(Value::Null, |o| Value::String(o.clone())), ParamKind::Color => Value::String("#000000".into()), @@ -205,7 +207,7 @@ impl Param { Value::Number(n) => Ok(Value::String(n.to_string())), _ => Err(format!("`{name}` must be text")), }, - ParamKind::Number => { + ParamKind::Number | ParamKind::Slider => { let n = match v { Value::Number(n) => n.as_f64(), Value::String(s) => s.trim().parse::().ok(), @@ -253,6 +255,9 @@ impl Param { { return Err(format!("parameter `{}` has bad bounds", self.name)); } + if self.kind == ParamKind::Slider && (self.min.is_none() || self.max.is_none()) { + return Err(format!("slider `{}` needs a `min` and a `max`", self.name)); + } self.default = self.default.as_ref().and_then(|d| self.coerce(d).ok()); Ok(()) } diff --git a/crates/plugin/src/v2_tests.rs b/crates/plugin/src/v2_tests.rs index 74a634d1a..ce76628da 100644 --- a/crates/plugin/src/v2_tests.rs +++ b/crates/plugin/src/v2_tests.rs @@ -122,6 +122,7 @@ fn panels_are_validated_and_keep_entered_values() { {"type": "color", "name": "accent", "value": "#f0a"}, {"type": "number", "name": "n", "min": 1, "max": 4, "value": 9}, {"type": "text", "name": "t"}, + {"type": "slider", "name": "o", "min": 0, "max": 100, "value": -5}, {"type": "button", "name": "go", "label": "Go"} ]}"##, ) @@ -129,12 +130,16 @@ fn panels_are_validated_and_keep_entered_values() { assert_eq!(p.control("accent").unwrap().default, Some(json!("#FF00AA"))); assert_eq!(p.control("n").unwrap().default, Some(json!(4.0)), "values are clamped"); assert_eq!(p.control("go").unwrap().kind, ParamKind::Button); + let o = p.control("o").unwrap(); + assert_eq!((o.kind, o.default.clone()), (ParamKind::Slider, Some(json!(0.0))), "a slider is clamped like a number"); + assert_eq!(o.coerce(&json!("250")), Ok(json!(100.0))); + assert!(o.coerce(&json!(true)).is_err()); let mut old = Map::new(); old.insert("t".into(), json!("typed")); old.insert("palette".into(), json!("Warm")); assert_eq!( json!(p.values(&old)), - json!({"palette": "Cool", "accent": "#FF00AA", "n": 4.0, "t": "typed"}), + json!({"palette": "Cool", "accent": "#FF00AA", "n": 4.0, "t": "typed", "o": 0.0}), "the spec's values win, then the entered ones" ); let deep = format!("{{\"controls\": {}{}}}", "[".repeat(5000), "]".repeat(5000)); @@ -148,6 +153,8 @@ fn panels_are_validated_and_keep_entered_values() { r#"{"controls": [{"type": "list", "name": "x"}]}"#, r#"{"controls": [{"type": "button", "name": "a"}, {"type": "text", "name": "a"}]}"#, r#"{"controls": [{"type": "number", "name": "a", "min": 3, "max": 1}]}"#, + r#"{"controls": [{"type": "slider", "name": "a", "min": 3}]}"#, + r#"{"controls": [{"type": "slider", "name": "a", "max": 3}]}"#, r#"{"title": "line\nbreak"}"#, &deep, &many, diff --git a/crates/ui-egui/src/a11y.rs b/crates/ui-egui/src/a11y.rs index 3c5fafb66..fae97a41d 100644 --- a/crates/ui-egui/src/a11y.rs +++ b/crates/ui-egui/src/a11y.rs @@ -910,6 +910,19 @@ pub fn number(resp: &Response, label: &str, value: Option, suffix: &str, em }); } +/// A slider over `min`..=`max` at `value` (None = mixed). +pub fn slider(resp: &Response, label: &str, value: Option, min: f64, max: f64) { + node(resp, label, |n| { + n.set_role(Role::Slider); + n.set_min_numeric_value(min); + n.set_max_numeric_value(max); + if let Some(v) = value { + n.set_numeric_value(v); + n.set_value(crate::widgets::fmt_num(v)); + } + }); +} + /// A text field showing `value`. pub fn text(resp: &Response, label: &str, value: &str) { node(resp, label, |n| { diff --git a/crates/ui-egui/src/dialogs/plugins_ui.rs b/crates/ui-egui/src/dialogs/plugins_ui.rs index 57491ceab..067dc574f 100644 --- a/crates/ui-egui/src/dialogs/plugins_ui.rs +++ b/crates/ui-egui/src/dialogs/plugins_ui.rs @@ -56,6 +56,8 @@ pub struct PluginsWindow { /// Width of the plugin windows' fields. const FIELD_W: f32 = 220.0; +/// The value field beside a slider. +const SLIDER_VALUE_W: f32 = 56.0; /// How far apart plugin panels open, side by side (a panel and a gap). const PANEL_STEP: f32 = 372.0; @@ -651,6 +653,33 @@ fn field(ui: &mut Ui, id: Id, p: &Param, current: &Value) -> Option { FieldEdit::Set(v) | FieldEdit::Scrub(v, true) => p.coerce(&json!(v)).ok(), FieldEdit::Scrub(_, false) => None, }, + // A slider and its value: dragging shows the value live and calls the plugin back once, + // when let go (each call is an undo step). + ParamKind::Slider => { + let (min, max) = (p.min.unwrap_or(0.0), p.max.unwrap_or(1.0)); + let live = id.with("live"); + let shown = ui.data(|d| d.get_temp::(live)).or(current.as_f64()); + let (slid, typed) = ui + .horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + let slid = widgets::slider(ui, id.with("slider"), min..=max, None, shown, FIELD_W - SLIDER_VALUE_W - 6.0, name); + (slid, widgets::number_field(ui, id, Lead::Named(name), shown, "", SLIDER_VALUE_W, (max - min) / 100.0)) + }) + .inner; + let done = match (slid, typed) { + (Some((Some(v), false)), _) => { + ui.data_mut(|d| d.insert_temp(live, v)); + None + } + (Some((v, true)), _) => Some(v.map_or_else(|| p.default_value(), |v| json!(v))), + (_, Some(FieldEdit::Set(v) | FieldEdit::Scrub(v, true))) => Some(json!(v)), + _ => None, + }; + if done.is_some() { + ui.data_mut(|d| d.remove::(live)); + } + done.and_then(|v| p.coerce(&v).ok()) + } ParamKind::Boolean => { let on = current.as_bool().unwrap_or(false); widgets::checkbox(ui, on, name).then_some(Value::Bool(!on)) diff --git a/crates/ui-egui/src/panels/typography/axes.rs b/crates/ui-egui/src/panels/typography/axes.rs index 953d6fa11..c9e5bebe9 100644 --- a/crates/ui-egui/src/panels/typography/axes.rs +++ b/crates/ui-egui/src/panels/typography/axes.rs @@ -1,7 +1,7 @@ use super::fields::menu_item_hover; use super::{ - Color32, CornerRadius, FIELD_H, FieldEdit, FigApp, Id, Lead, MIXED, NodeId, Rect, Sense, Stroke, TextStyle, Tokens, Ui, Value, commit_text, json, - pos2, regular, text_style_params, tr, trf, trim_num, uniform, vec2, widgets, + FIELD_H, FieldEdit, FigApp, Id, Lead, MIXED, NodeId, Sense, TextStyle, Tokens, Ui, Value, commit_text, json, pos2, regular, text_style_params, + tr, trf, trim_num, uniform, vec2, widgets, }; /// Type settings ▸ Variable: the font's named instances, then a slider and a field per visible @@ -55,7 +55,7 @@ pub(super) fn variable_axes(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], style ); ui.interact(lr, Id::unique(("ty_axis_label", &axis.tag)), Sense::hover()).on_hover_text(tip); ui.painter().galley(pos2(lr.min.x, lr.center().y - name.size().y / 2.0), name, t.text); - match axis_slider(ui, Id::unique(("ty_axis_slider", &axis.tag)), axis, shown, slider_w) { + match widgets::slider(ui, Id::unique(("ty_axis_slider", &axis.tag)), axis.min..=axis.max, Some(axis.default), shown, slider_w, &axis.name) { Some((Some(v), done)) => set = Some((json!(round_axis(axis, v)), done)), Some((None, _)) => set = Some((Value::Null, true)), None => {} @@ -152,39 +152,3 @@ fn named_instances( pub(super) fn round_axis(axis: &figcraft_text::Axis, v: f64) -> f64 { if axis.max - axis.min > 20.0 { v.round() } else { (v * 10.0).round() / 10.0 } } - -/// A horizontal slider over an axis's range with a tick at its default. `value` None = mixed. -/// Returns the new value (None = double-click: back to the style's value) and whether the -/// gesture ended. -fn axis_slider(ui: &mut Ui, id: Id, axis: &figcraft_text::Axis, value: Option, width: f32) -> Option<(Option, bool)> { - let t = Tokens::get(ui.ctx()); - let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); - let resp = ui.interact(rect, id, Sense::click_and_drag()); - let track = Rect::from_center_size(rect.center(), vec2(width - 12.0, 4.0)); - let span = (axis.max - axis.min).max(1e-9); - let x_of = |v: f64| track.min.x + ((v - axis.min) / span).clamp(0.0, 1.0) as f32 * track.width(); - let p = ui.painter(); - p.rect_filled(track, CornerRadius::same(2), t.border); - let dx = x_of(axis.default); - p.line_segment([pos2(dx, track.min.y - 3.0), pos2(dx, track.max.y + 3.0)], Stroke::new(1.0, t.border_strong)); - if let Some(v) = value { - let x = x_of(v); - p.rect_filled(Rect::from_min_max(track.min, pos2(x, track.max.y)), CornerRadius::same(2), t.accent); - p.circle_filled(pos2(x, track.center().y), 6.0, Color32::WHITE); - let ring = if resp.hovered() || resp.dragged() { crate::theme::ACCENT } else { t.border_strong }; - p.circle_stroke(pos2(x, track.center().y), 6.0, Stroke::new(1.0, ring)); - } - if resp.double_clicked() { - return Some((None, true)); - } - if (resp.dragged() || resp.clicked() || resp.drag_started()) - && let Some(pos) = resp.interact_pointer_pos() - { - let f = f64::from(((pos.x - track.min.x) / track.width().max(1.0)).clamp(0.0, 1.0)); - return Some((Some(axis.min + f * span), resp.drag_stopped() || resp.clicked())); - } - if resp.drag_stopped() { - return value.map(|v| (Some(v), true)); - } - None -} diff --git a/crates/ui-egui/src/plugin_panel_tests.rs b/crates/ui-egui/src/plugin_panel_tests.rs index 53b1ba56a..e857ede9d 100644 --- a/crates/ui-egui/src/plugin_panel_tests.rs +++ b/crates/ui-egui/src/plugin_panel_tests.rs @@ -22,6 +22,7 @@ pub(crate) fn panel_module() -> Vec { {"type": "color", "name": "accent", "label": "Accent", "value": "#FF5A5F"}, {"type": "select", "name": "shape", "label": "Shape", "options": ["Square", "Circle"]}, {"type": "text", "name": "title", "label": "Title"}, + {"type": "slider", "name": "opacity", "label": "Opacity", "min": 0, "max": 100, "value": 0}, {"type": "button", "name": "go", "label": "Go"} ]}) .to_string(); @@ -67,7 +68,7 @@ fn the_panel_names_its_controls_and_its_button_calls_the_plugin_back() { u.frame(vec![]); let tree = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; let named = |label: &str| tree.iter().map(|(_, n)| n).find(|n| n.label() == Some(label)).cloned(); - for label in ["Size", "Labels", "Warm", "Cool", "Accent", "Shape", "Title", "Go"] { + for label in ["Size", "Labels", "Warm", "Cool", "Accent", "Shape", "Title", "Opacity", "Go"] { assert!(named(label).is_some(), "no control named {label:?}"); } let go = named("Go").and_then(|n| n.bounds()).unwrap(); @@ -122,3 +123,31 @@ fn a_colour_field_opens_the_colour_picker_and_two_panels_show_side_by_side() { let accents = nodes(&mut u).iter().filter(|(_, n)| n.label() == Some("Accent")).count(); assert_eq!((accents, u.app.session.plugins.panels.len()), (2, 2), "both panels are drawn"); } + +/// A slider shows the value it is dragged to as it moves and calls the plugin back once, with +/// that value, when let go. +#[test] +fn a_slider_calls_the_plugin_back_once_when_let_go() { + let mut u = Ui::new(session()); + u.ctx.enable_accesskit(); + u.frame(vec![]); + let slider = |u: &mut Ui| { + let nodes = u.frame_output(vec![]).platform_output.accesskit_update.unwrap().nodes; + nodes.into_iter().map(|(_, n)| n).find(|n| n.role() == egui::accesskit::Role::Slider && n.label() == Some("Opacity")).unwrap() + }; + let s = slider(&mut u); + assert_eq!((s.min_numeric_value(), s.max_numeric_value(), s.numeric_value()), (Some(0.0), Some(100.0), Some(0.0))); + let b = s.bounds().unwrap(); + let y = ((b.y0 + b.y1) / 2.0) as f32; + let (from, to) = (Pos2::new(b.x0 as f32 + 6.0, y), Pos2::new(((b.x0 + b.x1) / 2.0) as f32, y)); + let before = layers(&u); + u.drag_to(from, to); + let live = slider(&mut u).numeric_value().unwrap(); + assert!((live - 50.0).abs() < 2.0, "the knob follows the drag: {live}"); + assert_eq!(layers(&u), before, "no call while dragging"); + u.button(to, false); + assert_eq!(layers(&u), before + 1, "one call when let go"); + assert_eq!(u.app.session.ds().unwrap().history.undo.len(), 1); + let value = u.app.session.plugins.panel("org.test.panel").unwrap().values["opacity"].as_f64().unwrap(); + assert!((value - live).abs() < 1e-9, "the plugin got the dragged value: {value}"); +} diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 83b33f0e4..b6b4b7e88 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -1021,6 +1021,54 @@ pub fn swatch(ui: &mut Ui, paint: &figcraft_color::Paint, size: f32) -> Response swatch_named(ui, paint, size, tr("Color")) } +/// A horizontal slider over `range` named `name` for screen readers, with a tick at `mark`. +/// `value` None = mixed (no knob). Returns the new value (None = double-click: back to the +/// default) and whether the gesture ended. +pub fn slider( + ui: &mut Ui, + id: Id, + range: std::ops::RangeInclusive, + mark: Option, + value: Option, + width: f32, + name: &str, +) -> Option<(Option, bool)> { + let t = Tokens::get(ui.ctx()); + let (min, max) = (*range.start(), *range.end()); + let (rect, _) = ui.allocate_exact_size(vec2(width, FIELD_H), Sense::hover()); + let resp = ui.interact(rect, id, Sense::click_and_drag()); + a11y::slider(&resp, name, value, min, max); + let track = Rect::from_center_size(rect.center(), vec2(width - 12.0, 4.0)); + let span = (max - min).max(1e-9); + let x_of = |v: f64| track.min.x + ((v - min) / span).clamp(0.0, 1.0) as f32 * track.width(); + let p = ui.painter(); + p.rect_filled(track, CornerRadius::same(2), t.border); + if let Some(m) = mark { + let dx = x_of(m); + p.line_segment([pos2(dx, track.min.y - 3.0), pos2(dx, track.max.y + 3.0)], Stroke::new(1.0, t.border_strong)); + } + if let Some(v) = value { + let x = x_of(v); + p.rect_filled(Rect::from_min_max(track.min, pos2(x, track.max.y)), CornerRadius::same(2), t.accent); + p.circle_filled(pos2(x, track.center().y), 6.0, Color32::WHITE); + let ring = if resp.hovered() || resp.dragged() { crate::theme::ACCENT } else { t.border_strong }; + p.circle_stroke(pos2(x, track.center().y), 6.0, Stroke::new(1.0, ring)); + } + if resp.double_clicked() { + return Some((None, true)); + } + if (resp.dragged() || resp.clicked() || resp.drag_started()) + && let Some(pos) = resp.interact_pointer_pos() + { + let f = f64::from(((pos.x - track.min.x) / track.width().max(1.0)).clamp(0.0, 1.0)); + return Some((Some(min + f * span), resp.drag_stopped() || resp.clicked())); + } + if resp.drag_stopped() { + return value.map(|v| (Some(v), true)); + } + None +} + /// [`swatch`] with the screen reader's name for the field it belongs to, so a panel's swatches /// are told apart from every other colour control (and from each other). pub fn swatch_named(ui: &mut Ui, paint: &figcraft_color::Paint, size: f32, label: &str) -> Response { diff --git a/docs/gaps.md b/docs/gaps.md index dc96ee9dd..f063d47d4 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -255,10 +255,13 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe (the value, cleaned and capped, goes into the widget's state and the plugin redraws; one undo step), and a widget can carry a property menu of actions, toggles and colour choices, shown in a small bar while it's selected and driven with `widget.menu`; the Counter example uses both. + Sliders (2026-10-30): a panel control or command parameter of type `slider` (a number between + its `min` and `max`) is drawn as a slider with its value beside it; the plugin is called back + once, when the drag ends. - **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings and install counts, background update checks, the directory in the browser build; widgets have no hover or timers, can't open a panel and draw only frames, text, shapes and images; - no images or sliders in panels. Figma's own plugins are JavaScript against Figma's plugin API and + no images in panels. Figma's own plugins are JavaScript against Figma's plugin API and don't run. - **Impact:** teams that depend on specific Figma plugins (icons, content, accessibility checks) can't bring them. FigCraft's CLI, control channel and MCP server cover agent automation better @@ -565,6 +568,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Gap 6: plugin panels and command forms take a `slider` control (a number between a required `min` and `max`), drawn as a slider with its value field beside it; the plugin is called back once when a drag ends, one undo step. "Sliders" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go` | | 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish` publishes a repository's `figcraft.connect.json` to a design file (links by component name, warnings for links that don't fit, `--dry-run`, exit 1 when nothing links). The CLI part leaves the missing list; the Dev Mode file-rename check remains; estimate unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, the CLI argument tests | | 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. | | 2026-10-29 | minor | Colour pickers dismiss on presses outside their card, including the sidebar, and open 8 pt beside the sidebar aligned to their control (lifted to fit the screen); switching targets resets a dragged position and commits pending edits. Eyedropper sampling stays open. UI regression tests cover added fills/strokes and page colours; headline estimates unchanged. | diff --git a/docs/plugins.md b/docs/plugins.md index 2c10377e6..b0ff0e743 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -72,7 +72,7 @@ A message the plugin shows (`notify`) appears as a toast; so does a text result. **Plugin panels.** A plugin written for API 2 can show a panel while it runs: a floating window, first placed beside the properties panel (drag its title bar to move it), with the plugin's notes, -fields (text, number, checkbox, menu, list, colour) and buttons, drawn with FigCraft's own widgets +fields (text, number, slider, checkbox, menu, list, colour) and buttons, drawn with FigCraft's own widgets and named for screen readers. A colour field's swatch opens FigCraft's colour picker (the plugin hears of the colour when a drag in the picker ends); its hex can also be typed. Pressing a button or changing a field calls the plugin back with every field's value; what that callback does is one @@ -246,7 +246,7 @@ plugin directory requests (`plugin.directory.*` says so). Import plugins with `p | `permissions.access` | `read` (the default): commands that leave the document as it is; such plugins also run from Dev Mode's Inspect tab. `write`: edits too. | | `permissions.commands` | Optional allowlist: exact command ids or prefixes ending in `*` (`node.*`). Empty: any command the access allows. | | `commands` | The menu commands, each `{id, label, params?}`. Without any, the plugin has one, `run`, unless it's a widget (a widget needs no command). | -| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean`, `select` (one of `options`, a menu), `list` (one of `options`, shown as a list) or `color` (hex, `#RRGGBB` or `#RRGGBBAA`). A default that doesn't fit is dropped. | +| `params[]` | `{name, label?, type, default?, options?, min?, max?}`; `type` is `text`, `number` (clamped to `min`/`max`), `boolean`, `select` (one of `options`, a menu), `list` (one of `options`, shown as a list), `slider` (a number between `min` and `max`, both required, shown as a slider with its value beside it) or `color` (hex, `#RRGGBB` or `#RRGGBBAA`). A default that doesn't fit is dropped. | ## What a plugin can't do @@ -371,7 +371,7 @@ plugin is removed. ``` `title` defaults to the plugin's name. Controls are the manifest's parameter types (`text`, -`number`, `boolean`, `select`, `list`, `color`) plus `button` and `note` (text in `label`, up to +`number`, `boolean`, `select`, `list`, `slider`, `color`) plus `button` and `note` (text in `label`, up to 1,000 characters, no `name` needed), in order, each `name` unique. A field shows its `value`; a field without one keeps what the user entered before the panel was updated, so a plugin can update a note without resetting the other fields. Buttons next to each other share a row; the first button @@ -517,7 +517,7 @@ import what you need from `figcraft`. - Widgets: clicks, field edits and menu items are their only events (no hover or timers), a field is plain text (no placeholder, length or single-line setting), they can't open a panel, and their layers are frames, text, rectangles, ellipses and images only (no vectors, components or variables). -- Panels: no images, sliders or free layout inside a panel. +- Panels: no images or free layout inside a panel. - Data in the document: plugins keep data on layers (pages included) with `node.set {props: {pluginData: {key: value}}}` (a `null` value removes a key) and read it back with `node.get`, which reports `pluginData: {key: value}` (values as strings) when a layer has any; there is no separate diff --git a/docs/roadmap.md b/docs/roadmap.md index c74c64d97..9857ba3fd 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (compact layer trees with fields and outline (M0.9, part of #996); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (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; compact layer trees with fields and outline (M0.9, part of #996); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 6: a `slider` control for plugin panels and command forms (a number between required `min` and `max`; one callback when a drag ends). Tests: `v2_tests`, `plugin_panel_tests` | | 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish` publishes a repository's code connect mappings to a `.figcraft` file (`--dry-run`). Tests: `files::tests`, `args` tests | | 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | | 2026-10-30 | minor | M12, #1597: Shift+Cmd/Ctrl+Enter on a board shape adds the next shape already connected (stickies unchanged). Tests: `board_connected_tests`, `board_ui_tests` | From 9099f1d5ae4b0e7927a1e7582bda1880546ff31a Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 17:11:22 +0700 Subject: [PATCH 29/34] M6: .fig elbowed and curved connectors without a saved path are routed, not drawn straight (#1649) The board connector router moves, unchanged, from the engine into figcraft-doc (route_points, RouteEnd) so the .fig importer can use it: an elbowed or curved connector saved without its path runs in right angles or one curve from the side of each layer it joins (the file's magnet, else the edge its point is on, else the side facing the other end). Straight connectors are unchanged. Gap 1. --- ROADMAP.md | 3 +- crates/doc/src/board_route.rs | 144 ++++++++++++++++++++++ crates/doc/src/lib.rs | 2 + crates/engine/src/boards/board.rs | 2 +- crates/engine/src/boards/board/derived.rs | 134 ++------------------ crates/format/src/fig/map/connector.rs | 85 ++++++++++--- crates/format/src/fig/tail_tests.rs | 43 ++++++- crates/ui-egui/locales/ar.toml | 1 - crates/ui-egui/locales/bn.toml | 1 - crates/ui-egui/locales/cs.toml | 1 - crates/ui-egui/locales/de.toml | 1 - crates/ui-egui/locales/en.toml | 2 +- crates/ui-egui/locales/es.toml | 1 - crates/ui-egui/locales/fi.toml | 1 - crates/ui-egui/locales/fr.toml | 1 - crates/ui-egui/locales/id.toml | 1 - crates/ui-egui/locales/it.toml | 1 - crates/ui-egui/locales/ja.toml | 1 - crates/ui-egui/locales/ka.toml | 1 - crates/ui-egui/locales/ko.toml | 1 - crates/ui-egui/locales/nl.toml | 1 - crates/ui-egui/locales/pl.toml | 1 - crates/ui-egui/locales/pt-BR.toml | 1 - crates/ui-egui/locales/ru.toml | 1 - crates/ui-egui/locales/sv.toml | 1 - crates/ui-egui/locales/th.toml | 1 - crates/ui-egui/locales/tr.toml | 1 - crates/ui-egui/locales/vi.toml | 1 - crates/ui-egui/locales/zh-Hans.toml | 1 - docs/file-format-parity.md | 11 +- docs/gaps.md | 5 +- docs/roadmap.md | 3 +- 32 files changed, 279 insertions(+), 176 deletions(-) create mode 100644 crates/doc/src/board_route.rs diff --git a/ROADMAP.md b/ROADMAP.md index c117f9fb1..01094ff72 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-10-30 · **Change:** minor (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; 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; 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); 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); 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-10-30 · **Change:** minor (`.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; 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; 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); 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); 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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 1: `.fig` connectors saved without their path that are elbowed or curved were drawn as straight lines. They now run as FigCraft's board connectors do: in right angles (around the layers they join when the short way would cross them) or along one curve, leaving each layer from the side the file's `magnet` names, else the edge its point lies on, else the side facing the other end. The router (`route_points`) moved from the engine's boards into `figcraft-doc`, unchanged, so the importer and the board connectors share it. Straight connectors are unchanged. Checked by rendering the synthetic fixture with figcraft-cli; not on real files. Numbers unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style`, the elbowed connector in `other_layer_kinds_import_as_the_closest_layers`, the engine's board connector tests. | | 2026-10-30 | minor | M6, gap 6: plugin panels (and plugin command forms) take a `slider` control: a number between a required `min` and `max`, drawn as a slider with the value field beside it, named for screen readers as a slider with its range. Dragging shows the value live and calls the plugin back once, when let go (one undo step); typing in the field or double-clicking the slider (back to the panel's value) do the same. The slider is the one the Type settings' variable axes used, moved into the shared widgets, which also gives those axis sliders a screen-reader name. Numbers unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go`. | | 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish [--mappings FILE] [--dry-run]` publishes a code repository's code connect mappings (`figcraft.connect.json`, as Export code connections writes it) to a design file: each link connects the components or sets of that name to their code for its language; links that don't fit are warnings and are skipped; the file is saved in place through the engine's `file.save` (whole or not at all, version history kept); a dry run saves nothing; nothing linked is exit code 1. Numbers unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, `args` tests. | | 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | diff --git a/crates/doc/src/board_route.rs b/crates/doc/src/board_route.rs new file mode 100644 index 000000000..1220d2f6d --- /dev/null +++ b/crates/doc/src/board_route.rs @@ -0,0 +1,144 @@ +//! Where a connector runs between its ends: straight, in right angles or along one curve. Pure +//! geometry, shared by the engine (board connectors that follow their layers) and the importers +//! (connectors saved without their path). + +use figcraft_geom::{Point, Rect, Vec2}; + +use crate::{Routing, Side}; + +/// One end of a route: a free point, or an object's box and the side the route leaves it by +/// ([`Side::Auto`]: the side facing the other end). +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum RouteEnd { + Point(Point), + Box(Rect, Side), +} + +impl RouteEnd { + fn centre(self) -> Point { + match self { + RouteEnd::Point(p) => p, + RouteEnd::Box(b, _) => b.center(), + } + } +} + +/// How far an elbow connector going around the objects it joins keeps from them. +const ROOM: f64 = 24.0; + +/// The points a connector runs through from `from` to `to`: from the middle of the side of each +/// box facing the other end (or the side it names), straight, in right angles, or (curved) its two +/// ends with the curve's two control points between them. `None` when a point isn't finite. +pub fn route_points(from: RouteEnd, to: RouteEnd, routing: Routing) -> Option> { + let (p0, s0) = attach(from, to.centre()); + let (p1, s1) = attach(to, from.centre()); + let boxes: Vec = [from, to].into_iter().filter_map(|e| if let RouteEnd::Box(b, _) = e { Some(b) } else { None }).collect(); + let pts = match routing { + Routing::Straight => tidy(vec![p0, p1]), + Routing::Elbow => elbow(p0, s0, p1, s1, &boxes), + Routing::Curved => curve(p0, s0, p1, s1), + }; + pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) +} + +/// `pts` without repeats or turns that don't turn (three in a line across or down). +fn tidy(pts: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(pts.len()); + for p in pts { + if out.last() == Some(&p) { + continue; + } + if let [.., a, b] = out.as_slice() + && ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y)) + { + out.pop(); + } + out.push(p); + } + out +} + +/// Where end `e` attaches (its point, or the middle of its box's side facing `other`) and that +/// side (`None`: a free point). +fn attach(e: RouteEnd, other: Point) -> (Point, Option) { + match e { + RouteEnd::Point(p) => (p, None), + RouteEnd::Box(b, side) => { + let side = match side { + Side::Auto => { + let d = other - b.center(); + // Compare against the box's proportions: a wide box takes the sides more. + if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { + if d.x >= 0.0 { Side::Right } else { Side::Left } + } else if d.y >= 0.0 { + Side::Bottom + } else { + Side::Top + } + } + s => s, + }; + (side.point(b), Some(side)) + } + } +} + +/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no +/// way): the ends and the two control points between them. +fn curve(p0: Point, s0: Option, p1: Point, s1: Option) -> Vec { + let reach = (p1 - p0).hypot() / 2.0; + let pull = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach); + vec![p0, pull(p0, s0), pull(p1, s1), p1] +} + +/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a +/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`, +/// else the cheapest of a few ways around them (by length, and turns). +fn elbow(p0: Point, s0: Option, p1: Point, s1: Option, boxes: &[Rect]) -> Vec { + let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal))); + let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct }; + if !crosses(&direct, boxes) { + return direct; + } + // Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects. + let out = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM); + let (a, b) = (out(p0, s0), out(p1, s1)); + let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]]; + for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] { + ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]); + } + for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] { + ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]); + } + let cost = |r: &Vec| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::() + ROOM * r.len() as f64; + ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct) +} + +/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge, +/// or out of one, is outside). +fn crosses(pts: &[Point], boxes: &[Rect]) -> bool { + const EDGE: f64 = 0.5; + pts.windows(2).any(|s| { + let (a, b) = (s[0], s[1]); + boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE) + }) +} + +/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end +/// follows the other): halfway across between two sides facing the same way, one turn otherwise. +fn short_elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { + let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); + let h1 = h1.unwrap_or(h0); + match (h0, h1) { + (true, true) => { + let mx = (p0.x + p1.x) / 2.0; + vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1] + } + (false, false) => { + let my = (p0.y + p1.y) / 2.0; + vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1] + } + (true, false) => vec![p0, Point::new(p1.x, p0.y), p1], + (false, true) => vec![p0, Point::new(p0.x, p1.y), p1], + } +} diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 9c5817605..ffa552860 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -9,6 +9,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod board; +mod board_route; pub mod colors; pub mod comments; pub mod facilitation; @@ -26,6 +27,7 @@ pub mod vector_faces; pub mod vector_regions; pub use board::*; +pub use board_route::{RouteEnd, route_points}; pub use colors::*; pub use comments::*; pub use facilitation::*; diff --git a/crates/engine/src/boards/board.rs b/crates/engine/src/boards/board.rs index b1ebe81b9..176e2e9d0 100644 --- a/crates/engine/src/boards/board.rs +++ b/crates/engine/src/boards/board.rs @@ -13,7 +13,7 @@ use figcraft_doc::{ TextAlignH, TextAutoResize, TextData, VConstraint, VectorNetwork, board_color, }; use figcraft_geom::kurbo::PathEl; -use figcraft_geom::{Affine, BezPath, Point, Rect, Shape, Vec2}; +use figcraft_geom::{Affine, BezPath, Point, Rect, Shape}; use serde_json::{Value, json}; use crate::cmd::CommandSpec; diff --git a/crates/engine/src/boards/board/derived.rs b/crates/engine/src/boards/board/derived.rs index 9899632f5..d29b390a2 100644 --- a/crates/engine/src/boards/board/derived.rs +++ b/crates/engine/src/boards/board/derived.rs @@ -1,8 +1,9 @@ +use figcraft_doc::RouteEnd; + use super::edit::set_font_size; use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of}; use super::{ - Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, Side, Vec2, VectorNetwork, - is_board, + Affine, BTreeSet, BezPath, BoardItem, Connector, Document, End, NodeId, NodeKind, PathEl, Point, Rect, Routing, Shape, VectorNetwork, is_board, }; // --------------------------------------------------------------------------------------------- @@ -214,129 +215,12 @@ pub(super) fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing } } -/// How far an elbow connector going around the objects it joins keeps from them. -const ROOM: f64 = 24.0; - -/// The page points connector `c` runs through: from the middle of the side of each attached layer -/// facing the other end (or the side it names), straight, in right angles, or (curved) its two -/// ends with the curve's two control points between them. +/// The page points connector `c` runs through (see [`figcraft_doc::route_points`]): from the +/// middle of the side of each attached layer facing the other end (or the side it names). pub fn route(doc: &Document, c: &Connector) -> Option> { - let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n)); - let centre = |e: &End, b: Option| match e { - End::Point { x, y } => Some(Point::new(*x, *y)), - End::Node { .. } => b.map(|b| b.center()), + let end = |e: &End| match e { + End::Point { x, y } => Some(RouteEnd::Point(Point::new(*x, *y))), + End::Node { node, side } => doc.world_bounds(*node).map(|b| RouteEnd::Box(b, *side)), }; - let (fb, tb) = (bounds(&c.from), bounds(&c.to)); - let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?); - let (p0, s0) = attach(&c.from, fb, tc)?; - let (p1, s1) = attach(&c.to, tb, fc)?; - let pts = match c.routing { - Routing::Straight => tidy(vec![p0, p1]), - Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::>()), - Routing::Curved => curve(p0, s0, p1, s1), - }; - pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts) -} - -/// `pts` without repeats or turns that don't turn (three in a line across or down). -fn tidy(pts: Vec) -> Vec { - let mut out: Vec = Vec::with_capacity(pts.len()); - for p in pts { - if out.last() == Some(&p) { - continue; - } - if let [.., a, b] = out.as_slice() - && ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y)) - { - out.pop(); - } - out.push(p); - } - out -} - -/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that -/// side (`None`: a free point). -fn attach(e: &End, b: Option, other: Point) -> Option<(Point, Option)> { - match e { - End::Point { x, y } => Some((Point::new(*x, *y), None)), - End::Node { side, .. } => { - let b = b?; - let side = match side { - Side::Auto => { - let d = other - b.center(); - // Compare against the box's proportions: a wide box takes the sides more. - if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) { - if d.x >= 0.0 { Side::Right } else { Side::Left } - } else if d.y >= 0.0 { - Side::Bottom - } else { - Side::Top - } - } - s => *s, - }; - Some((side.point(b), Some(side))) - } - } -} - -/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no -/// way): the ends and the two control points between them. -fn curve(p0: Point, s0: Option, p1: Point, s1: Option) -> Vec { - let reach = (p1 - p0).hypot() / 2.0; - let pull = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach); - vec![p0, pull(p0, s0), pull(p1, s1), p1] -} - -/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a -/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`, -/// else the cheapest of a few ways around them (by length, and turns). -fn elbow(p0: Point, s0: Option, p1: Point, s1: Option, boxes: &[Rect]) -> Vec { - let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal))); - let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct }; - if !crosses(&direct, boxes) { - return direct; - } - // Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects. - let out = |p: Point, s: Option| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM); - let (a, b) = (out(p0, s0), out(p1, s1)); - let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]]; - for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] { - ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]); - } - for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] { - ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]); - } - let cost = |r: &Vec| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::() + ROOM * r.len() as f64; - ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct) -} - -/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge, -/// or out of one, is outside). -fn crosses(pts: &[Point], boxes: &[Rect]) -> bool { - const EDGE: f64 = 0.5; - pts.windows(2).any(|s| { - let (a, b) = (s[0], s[1]); - boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE) - }) -} - -/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end -/// follows the other): halfway across between two sides facing the same way, one turn otherwise. -fn short_elbow(p0: Point, h0: Option, p1: Point, h1: Option) -> Vec { - let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs()); - let h1 = h1.unwrap_or(h0); - match (h0, h1) { - (true, true) => { - let mx = (p0.x + p1.x) / 2.0; - vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1] - } - (false, false) => { - let my = (p0.y + p1.y) / 2.0; - vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1] - } - (true, false) => vec![p0, Point::new(p1.x, p0.y), p1], - (false, true) => vec![p0, Point::new(p0.x, p1.y), p1], - } + figcraft_doc::route_points(end(&c.from)?, end(&c.to)?, c.routing) } diff --git a/crates/format/src/fig/map/connector.rs b/crates/format/src/fig/map/connector.rs index 232825024..34148133d 100644 --- a/crates/format/src/fig/map/connector.rs +++ b/crates/format/src/fig/map/connector.rs @@ -1,9 +1,12 @@ use super::layer::{cap_name, own_text, svg_path, valid_size}; use super::{Affine, Kv, Mapper, NO_STYLE, Obj, Point, Value, View, generated_record, guid, json, matrix, network, num, rows}; +use figcraft_doc::geom::kurbo::{BezPath, PathEl, Rect, Shape}; +use figcraft_doc::{RouteEnd, Routing, Side, route_points}; impl<'a> Mapper<'a> { - /// A connector's end caps, and when it has no saved outline or network a straight line - /// between its ends: free points, or a point (the centre unless given) of a sibling layer. + /// A connector's end caps, and when it has no saved outline or network its line between its + /// ends (free points, or a sibling layer): straight to the point given on the layer (its + /// centre unless given), or elbowed or curved from a side of it, as a board connector runs. pub(super) fn connector(&mut self, o: &mut Obj, v: &View<'a>, space: Affine) { for (from, to) in [("connectorStartCap", "strokeCapStart"), ("connectorEndCap", "strokeCapEnd")] { if let Some(c) = v.str(from) { @@ -16,10 +19,15 @@ impl<'a> Mapper<'a> { if ["vectorNetwork", "vectorNetworkRef", "fillGeometry", "strokeGeometry"].iter().any(|k| o.contains_key(*k)) { return; } + let routing = match v.str("connectorLineStyle") { + Some("ELBOWED") => Routing::Elbow, + Some("CURVED") => Routing::Curved, + _ => Routing::Straight, + }; let parent = v.get("parentIndex").and_then(|p| p.get("guid")).and_then(guid); - let end = |k: &str| -> Option { + let end = |k: &str| -> Option { let e = v.get(k)?; - let p = match e.get("endpointNodeID").and_then(guid).filter(|g| g != NO_STYLE) { + let end = match e.get("endpointNodeID").and_then(guid).filter(|g| g != NO_STYLE) { Some(id) => { let n = *self.nodes.get(&id)?; // Only siblings share the connector's coordinates. @@ -27,27 +35,52 @@ impl<'a> Mapper<'a> { return None; } let (w, h) = n.get("size").map_or((0.0, 0.0), |s| (num(s, "x").unwrap_or(0.0), num(s, "y").unwrap_or(0.0))); - let (rx, ry) = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?))).unwrap_or((0.5, 0.5)); - n.get("transform").and_then(matrix).unwrap_or_default() * Point::new(w * rx, h * ry) + let at = e.get("relativePosition").and_then(|r| Some((num(r, "x")?, num(r, "y")?))); + let t = n.get("transform").and_then(matrix).unwrap_or_default(); + match routing { + Routing::Straight => { + let (rx, ry) = at.unwrap_or((0.5, 0.5)); + RouteEnd::Point(t * Point::new(w * rx, h * ry)) + } + _ => RouteEnd::Box(t.transform_rect_bbox(Rect::new(0.0, 0.0, w, h)), side(e, at)), + } } - None => e.get("position").and_then(|p| Some(Point::new(num(p, "x")?, num(p, "y")?)))?, + None => RouteEnd::Point(e.get("position").and_then(|p| Some(Point::new(num(p, "x")?, num(p, "y")?)))?), }; - (p.x.is_finite() && p.y.is_finite()).then_some(p) + let finite = |p: Point| p.x.is_finite() && p.y.is_finite(); + let ok = match end { + RouteEnd::Point(p) => finite(p), + RouteEnd::Box(b, _) => finite(b.origin()) && finite(Point::new(b.x1, b.y1)), + }; + ok.then_some(end) }; - let (Some(a), Some(b)) = (end("connectorStart"), end("connectorEnd")) else { + let Some(pts) = end("connectorStart").zip(end("connectorEnd")).and_then(|(a, b)| route_points(a, b, routing)) else { self.warn("connectors whose ends can't be placed are empty"); return; }; - if v.str("connectorLineStyle").is_some_and(|s| s != "STRAIGHT") { - self.warn("elbowed and curved connectors without a saved path import as straight lines"); + if routing != Routing::Straight { + self.warn("elbowed and curved connectors without a saved path follow FigCraft's own routing"); } - let (x0, y0) = (a.x.min(b.x), a.y.min(b.y)); + // A curved route is one curve: its ends and the two control points between them. + let (path, vertices) = match (routing, pts.as_slice()) { + (Routing::Curved, [p0, c0, c1, p1]) => { + let mut path = BezPath::new(); + path.move_to(*p0); + path.curve_to(*c0, *c1, *p1); + (path, vec![*p0, *p1]) + } + _ => (pts.iter().enumerate().map(|(i, p)| if i == 0 { PathEl::MoveTo(*p) } else { PathEl::LineTo(*p) }).collect(), pts.clone()), + }; + let b = path.bounding_box(); + let (x0, y0) = (b.x0, b.y0); o.insert("relativeTransform".into(), rows(space * Affine::translate((x0, y0)))); - o.insert("size".into(), json!({"x": (a.x - b.x).abs(), "y": (a.y - b.y).abs()})); - o.insert( - "vectorNetwork".into(), - json!({"vertices": [{"x": a.x - x0, "y": a.y - y0}, {"x": b.x - x0, "y": b.y - y0}], "segments": [{"a": 0, "b": 1}]}), - ); + o.insert("size".into(), json!({"x": b.width(), "y": b.height()})); + let segments: Vec = match (routing, pts.as_slice()) { + (Routing::Curved, [p0, c0, c1, p1]) => vec![json!({"a": 0, "b": 1, "ta": [c0.x - p0.x, c0.y - p0.y], "tb": [c1.x - p1.x, c1.y - p1.y]})], + _ => (1..vertices.len()).map(|i| json!({"a": i - 1, "b": i})).collect(), + }; + let vertices: Vec = vertices.iter().map(|p| json!({"x": p.x - x0, "y": p.y - y0})).collect(); + o.insert("vectorNetwork".into(), json!({"vertices": vertices, "segments": segments})); } pub(super) fn connector_snapshot_supported(&self, v: &View<'a>) -> bool { @@ -260,3 +293,21 @@ pub(super) fn connector_has_saved_label(v: &View<'_>) -> bool { .and_then(Kv::str) .is_some_and(|s| !s.trim().is_empty()) } + +/// The side of its layer a connector end leaves from: the `magnet` the file names, else the edge +/// its point on the layer (`relativePosition`, 0–1) lies on, else the side facing the other end. +fn side(e: &Kv<'_>, at: Option<(f64, f64)>) -> Side { + match e.get("magnet").and_then(Kv::str) { + Some("TOP") => Side::Top, + Some("RIGHT") => Side::Right, + Some("BOTTOM") => Side::Bottom, + Some("LEFT") => Side::Left, + _ => match at { + Some((x, _)) if x <= 0.0 => Side::Left, + Some((x, _)) if x >= 1.0 => Side::Right, + Some((_, y)) if y <= 0.0 => Side::Top, + Some((_, y)) if y >= 1.0 => Side::Bottom, + _ => Side::Auto, + }, + } +} diff --git a/crates/format/src/fig/tail_tests.rs b/crates/format/src/fig/tail_tests.rs index 0e6d32bc2..4311df432 100644 --- a/crates/format/src/fig/tail_tests.rs +++ b/crates/format/src/fig/tail_tests.rs @@ -613,12 +613,13 @@ fn other_layer_kinds_import_as_the_closest_layers() { let kids: Vec<&figcraft_doc::Node> = bubble.children.iter().map(|c| doc.node(*c).unwrap()).collect(); assert!(matches!(kids[0].kind, NodeKind::Vector { .. }) && kids[0].fills.len() == 1); assert_eq!(text_of(bubble.children[1]), "Hi"); - // A connector from a free point to the centre of a sibling, with its end cap. + // An elbowed connector from a free point to a sibling: in right angles, into the side of the + // box facing the point, with its end cap. let link = named(&doc, "Link"); let world = doc.world_transform(link.id); let ends: Vec<(f64, f64)> = network_of(&doc, "Link").vertices.iter().map(|v| world * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect(); - assert_eq!(ends, [(0.0, 300.0), (450.0, 50.0)]); + assert_eq!(ends, [(0.0, 300.0), (200.0, 300.0), (200.0, 50.0), (400.0, 50.0)]); assert_eq!(link.stroke.cap_end, StrokeCap::TriangleArrow); // A table: a frame. assert!(matches!(named(&doc, "Grid").kind, NodeKind::Frame)); @@ -627,6 +628,44 @@ fn other_layer_kinds_import_as_the_closest_layers() { } } +/// Connectors saved without their path: straight to the point on the layer, elbowed and curved +/// from the side the end's point lies on, as a board connector would run. +#[test] +fn connectors_without_a_saved_path_follow_their_line_style() { + let mut message = tail_message(); + let Kv::Array(nodes) = field_mut(&mut message, "nodeChanges").unwrap() else { panic!() }; + let box_at = |id, name: &str, x| node(id, Some((1, "*")), "RECTANGLE", name, vec![("size", xy(100.0, 100.0)), ("transform", at(x, 600.0))]); + let joined = |id, name: &str, style: &'static str| { + let side = |node, x| o(vec![("endpointNodeID", g(node)), ("relativePosition", xy(x, 0.5))]); + node( + id, + Some((1, "+")), + "CONNECTOR", + name, + vec![ + ("strokePaints", arr(vec![solid(0.0, 0.0, 0.0)])), + ("connectorStart", side(60, 1.0)), + ("connectorEnd", side(61, 0.0)), + ("connectorLineStyle", e(style)), + ], + ) + }; + nodes.extend([box_at(60, "A", 0.0), box_at(61, "B", 300.0), joined(62, "Curve", "CURVED"), joined(63, "Line", "STRAIGHT")]); + let (doc, warnings) = import(&canvas(&schema(), &message), "Routes").unwrap(); + let world = |name: &str| doc.world_transform(named(&doc, name).id); + // Curved: one curve from A's right side to B's left, leaving and arriving level. + let curve = network_of(&doc, "Curve"); + let ends: Vec<_> = curve.vertices.iter().map(|v| world("Curve") * figcraft_doc::geom::Point::new(v.x, v.y)).collect(); + assert_eq!(ends.iter().map(|p| (p.x, p.y)).collect::>(), [(100.0, 650.0), (300.0, 650.0)]); + assert_eq!(curve.segments.len(), 1); + assert_eq!((curve.segments[0].ta, curve.segments[0].tb), ((100.0, 0.0), (-100.0, 0.0)), "the curve leaves and arrives along the sides"); + // Straight: unchanged, to the points the file gives on the layers. + let line: Vec<_> = + network_of(&doc, "Line").vertices.iter().map(|v| world("Line") * figcraft_doc::geom::Point::new(v.x, v.y)).map(|p| (p.x, p.y)).collect(); + assert_eq!(line, [(100.0, 650.0), (300.0, 650.0)]); + assert!(warnings.iter().any(|w| w.contains("FigCraft's own routing")), "{warnings:?}"); +} + #[test] fn variable_prototype_actions_import() { let (doc, warnings) = import(&tail_canvas(), "Tail").unwrap(); diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index 464424632..d0413f0d1 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -1701,7 +1701,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "تستخدم إدخالات أحجام مسارات الشبكة المكررة مسارات متساوية" "e.g. count + 1" = "مثل count + 1" "e.g. count >= 3 and not done" = "مثل count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "تُستورد الموصلات الزاوية والمنحنية التي ليس لها مسار محفوظ كخطوط مستقيمة" "entries without a node type were skipped" = "تم تخطي الإدخالات التي ليس لها نوع عقدة" "figcraft:// links from a browser, chat or email open this app and join the session." = "تفتح روابط figcraft:// من المتصفح أو الدردشة أو البريد هذا التطبيق وتنضم إلى الجلسة." "from {server}" = "من {server}" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index d218b873a..cbbaa054d 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1645,7 +1645,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "ডুপ্লিকেট গ্রিড ট্র্যাক সাইজিং এন্ট্রিগুলো সমান ট্র্যাক ব্যবহার করে" "e.g. count + 1" = "যেমন count + 1" "e.g. count >= 3 and not done" = "যেমন count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "সংরক্ষিত পাথ ছাড়া এলবো এবং কার্ভড কানেক্টর সরল রেখা হিসেবে ইম্পোর্ট হয়" "entries without a node type were skipped" = "নোটের ধরন ছাড়া এন্ট্রিগুলো বাদ দেওয়া হয়েছে" "figcraft:// links from a browser, chat or email open this app and join the session." = "ব্রাউজার, চ্যাট বা ইমেল থেকে figcraft:// লিংকগুলো এই অ্যাপ খুলে সেশনে যুক্ত হয়।" "from {server}" = "{server} থেকে" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 35a72091d..713efa59b 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1610,7 +1610,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "duplicitní položky velikosti stop mřížky používají stejně velké stopy" "e.g. count + 1" = "např. count + 1" "e.g. count >= 3 and not done" = "např. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "lomené a zakřivené spojnice bez uložené cesty se importují jako přímky" "entries without a node type were skipped" = "položky bez typu uzlu byly přeskočeny" "figcraft:// links from a browser, chat or email open this app and join the session." = "Odkazy figcraft:// z prohlížeče, chatu nebo e-mailu otevřou tuto aplikaci a připojí k relaci." "from {server}" = "ze serveru {server}" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index 97395646d..dea591ac6 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1571,7 +1571,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "doppelte Größeneinträge für Rasterspuren verwenden gleich große Spuren" "e.g. count + 1" = "z. B. count + 1" "e.g. count >= 3 and not done" = "z. B. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "gewinkelte und gebogene Verbinder ohne gespeicherten Pfad werden als gerade Linien importiert" "entries without a node type were skipped" = "Einträge ohne Knotentyp wurden übersprungen" "figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-Links aus Browser, Chat oder E-Mail öffnen diese App und treten der Sitzung bei." "from {server}" = "von {server}" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index ebf1a48da..3955b7861 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1719,7 +1719,7 @@ name = "English" "duplicate grid track sizing entries use equal tracks" = "duplicate grid track sizing entries use equal tracks" "e.g. count + 1" = "e.g. count + 1" "e.g. count >= 3 and not done" = "e.g. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "elbowed and curved connectors without a saved path import as straight lines" +"elbowed and curved connectors without a saved path follow FigCraft's own routing" = "elbowed and curved connectors without a saved path follow FigCraft's own routing" "entries without a node type were skipped" = "entries without a node type were skipped" "figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft:// links from a browser, chat or email open this app and join the session." "from {server}" = "from {server}" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index bfa6c6107..33ad0a338 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1689,7 +1689,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "las entradas duplicadas de tamaño de pista de cuadrícula usan pistas iguales" "e.g. count + 1" = "p. ej. count + 1" "e.g. count >= 3 and not done" = "p. ej. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "los conectores angulares y curvos sin trazado guardado se importan como líneas rectas" "entries without a node type were skipped" = "se omitieron las entradas sin tipo de nodo" "figcraft:// links from a browser, chat or email open this app and join the session." = "Los enlaces figcraft:// de un navegador, chat o correo abren esta app y se unen a la sesión." "from {server}" = "de {server}" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 357a0c545..0601e948f 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1650,7 +1650,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "päällekkäiset ruudukon kokomääritykset käyttävät tasakokoisia osia" "e.g. count + 1" = "esim. count + 1" "e.g. count >= 3 and not done" = "esim. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "mutkaiset ja kaarevat yhdistimet, joilla ei ole tallennettua polkua, tuodaan suorina viivoina" "entries without a node type were skipped" = "kohteet, joilta puuttuu solmutyyppi, ohitettiin" "figcraft:// links from a browser, chat or email open this app and join the session." = "selaimen, keskustelun tai sähköpostin figcraft://-linkit avaavat tämän sovelluksen ja liittyvät istuntoon." "from {server}" = "palvelimelta {server}" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index e7787d8da..f91c48697 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1653,7 +1653,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "les entrées de dimensionnement de piste de grille en double utilisent des pistes égales" "e.g. count + 1" = "p. ex. count + 1" "e.g. count >= 3 and not done" = "p. ex. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "les connecteurs coudés et courbes sans tracé enregistré sont importés comme des lignes droites" "entries without a node type were skipped" = "les entrées sans type de nœud ont été ignorées" "figcraft:// links from a browser, chat or email open this app and join the session." = "Les liens figcraft:// d’un navigateur, d’une messagerie ou d’un e-mail ouvrent cette application et rejoignent la session." "from {server}" = "depuis {server}" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index 00daf1893..b587ea7cd 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1614,7 +1614,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "entri ukuran track grid yang duplikat memakai track yang sama besar" "e.g. count + 1" = "mis. count + 1" "e.g. count >= 3 and not done" = "mis. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "konektor siku dan lengkung tanpa jalur tersimpan diimpor sebagai garis lurus" "entries without a node type were skipped" = "entri tanpa tipe node dilewati" "figcraft:// links from a browser, chat or email open this app and join the session." = "Link figcraft:// dari browser, chat, atau email membuka aplikasi ini dan bergabung ke sesi." "from {server}" = "dari {server}" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index c94d09826..bb4ce648e 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -1588,7 +1588,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "le voci duplicate di dimensionamento delle tracce della griglia usano tracce uguali" "e.g. count + 1" = "ad es. count + 1" "e.g. count >= 3 and not done" = "ad es. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "i connettori ad angolo e curvi senza un tracciato salvato vengono importati come linee rette" "entries without a node type were skipped" = "le voci senza tipo di nodo sono state saltate" "figcraft:// links from a browser, chat or email open this app and join the session." = "i link figcraft:// da un browser, una chat o un'email aprono questa app e partecipano alla sessione." "from {server}" = "da {server}" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index fdb2774bc..ad11beb9e 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1634,7 +1634,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "重複したグリッドトラックのサイズ指定は均等なトラックを使用" "e.g. count + 1" = "例:count + 1" "e.g. count >= 3 and not done" = "例:count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "保存されたパスのない折れ線・曲線コネクタは直線としてインポート" "entries without a node type were skipped" = "ノードタイプのない項目をスキップ" "figcraft:// links from a browser, chat or email open this app and join the session." = "ブラウザー、チャット、メールからの figcraft:// リンクでこのアプリが開き、セッションに参加する。" "from {server}" = "{server} から" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 94edaac5b..18637cbc7 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -1671,7 +1671,6 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "duplicate grid track sizing entries use equal tracks" = "ბადის ტრეკის ზომის დუბლირებული ჩანაწერებისას ტრეკები თანაბარია" "e.g. count + 1" = "მაგ. count + 1" "e.g. count >= 3 and not done" = "მაგ. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "შენახული ტრაექტორიის გარეშე კუთხოვანი და მრუდი შემაერთებლები სწორ ხაზებად იმპორტირდება" "entries without a node type were skipped" = "კვანძის ტიპის გარეშე ჩანაწერები გამოტოვებულია" "figcraft:// links from a browser, chat or email open this app and join the session." = "ბრაუზერიდან, ჩატიდან ან ელფოსტიდან figcraft:// ბმულები ხსნის ამ აპს და უერთდება სესიას." "from {server}" = "წყარო: {server}" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 1f11b0bf9..dd5a8b7a3 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1562,7 +1562,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "중복된 그리드 트랙 크기 항목은 균등한 트랙을 사용함" "e.g. count + 1" = "예: count + 1" "e.g. count >= 3 and not done" = "예: count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "저장된 경로가 없는 꺾인 커넥터와 곡선 커넥터는 직선으로 가져옴" "entries without a node type were skipped" = "노드 유형이 없는 항목을 건너뜀" "figcraft:// links from a browser, chat or email open this app and join the session." = "브라우저, 채팅, 이메일의 figcraft:// 링크로 이 앱을 열고 세션에 참여함." "from {server}" = "{server}에서" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index e3bfefe6f..0561c950e 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1650,7 +1650,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "Dubbele vermeldingen voor trackgroottes gebruiken gelijke tracks" "e.g. count + 1" = "bijv. count + 1" "e.g. count >= 3 and not done" = "bijv. aantal >= 3 en niet klaar" -"elbowed and curved connectors without a saved path import as straight lines" = "haakse en gebogen connectors zonder opgeslagen pad worden geïmporteerd als rechte lijnen" "entries without a node type were skipped" = "items zonder node-type zijn overgeslagen" "figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-links uit een browser, chat of e-mail openen deze app en laten je deelnemen aan de sessie." "from {server}" = "van {server}" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index f66cb7db6..e95707fc0 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1660,7 +1660,6 @@ name = "Polski" "duplicate grid track sizing entries use equal tracks" = "zduplikowane wpisy rozmiaru siatki używają równych pasów" "e.g. count + 1" = "np. count + 1" "e.g. count >= 3 and not done" = "np. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "złamane i zakrzywione łączniki bez zapisanej ścieżki są importowane jako linie proste" "entries without a node type were skipped" = "wpisy bez typu węzła zostały pominięte" "figcraft:// links from a browser, chat or email open this app and join the session." = "linki figcraft:// z przeglądarki, czatu lub poczty e-mail otwierają tę aplikację i dołączają do sesji." "from {server}" = "z serwera {server}" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 84f03f1f7..00e030ea9 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1560,7 +1560,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "entradas duplicadas de dimensionamento de faixas da grade usam faixas iguais" "e.g. count + 1" = "por ex. count + 1" "e.g. count >= 3 and not done" = "por ex. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "conectores angulares e curvos sem um caminho salvo são importados como linhas retas" "entries without a node type were skipped" = "entradas sem tipo de nó foram ignoradas" "figcraft:// links from a browser, chat or email open this app and join the session." = "Links figcraft:// de um navegador, chat ou e-mail abrem este app e entram na sessão." "from {server}" = "de {server}" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 24683d56c..e6f3e0224 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1660,7 +1660,6 @@ name = "Русский" "duplicate grid track sizing entries use equal tracks" = "для повторяющихся записей размеров треков сетки используются равные треки" "e.g. count + 1" = "например, count + 1" "e.g. count >= 3 and not done" = "например, count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "ломаные и изогнутые соединительные линии без сохранённого пути импортируются как прямые" "entries without a node type were skipped" = "записи без типа узла пропущены" "figcraft:// links from a browser, chat or email open this app and join the session." = "Ссылки figcraft:// из браузера, чата или почты открывают это приложение и подключают к сессии." "from {server}" = "с {server}" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 0fa4f906c..5d3afad2e 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1717,7 +1717,6 @@ name = "Svenska" "duplicate grid track sizing entries use equal tracks" = "dubbla storleksposter för rutnätsspår använder lika stora spår" "e.g. count + 1" = "t.ex. count + 1" "e.g. count >= 3 and not done" = "t.ex. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "vinklade och böjda kopplingslinjer utan sparad bana importeras som raka linjer" "entries without a node type were skipped" = "poster utan nodtyp hoppades över" "figcraft:// links from a browser, chat or email open this app and join the session." = "figcraft://-länkar från en webbläsare, chatt eller e-post öppnar den här appen och ansluter till sessionen." "from {server}" = "från {server}" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 58a6422ec..2225fe527 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -1639,7 +1639,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "รายการขนาดแทร็กกริดที่ซ้ำกันใช้แทร็กขนาดเท่ากัน" "e.g. count + 1" = "เช่น count + 1" "e.g. count >= 3 and not done" = "เช่น count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "ตัวเชื่อมแบบหักมุมและโค้งที่ไม่มีเส้นทางที่บันทึกไว้นำเข้าเป็นเส้นตรง" "entries without a node type were skipped" = "ข้ามรายการที่ไม่มีชนิดโหนด" "figcraft:// links from a browser, chat or email open this app and join the session." = "ลิงก์ figcraft:// จากเบราว์เซอร์ แชต หรืออีเมลจะเปิดแอปนี้และเข้าร่วมเซสชัน" "from {server}" = "จาก {server}" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 064e853a0..b4176d833 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1614,7 +1614,6 @@ name = "Türkçe" "duplicate grid track sizing entries use equal tracks" = "yinelenen ızgara izi boyutlandırma girdileri eşit izler kullanır" "e.g. count + 1" = "ör. count + 1" "e.g. count >= 3 and not done" = "ör. count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "kaydedilmiş yolu olmayan dirsekli ve eğri bağlayıcılar düz çizgi olarak içe aktarılır" "entries without a node type were skipped" = "düğüm türü olmayan girdiler atlandı" "figcraft:// links from a browser, chat or email open this app and join the session." = "Bir tarayıcıdan, sohbetten veya e-postadan gelen figcraft:// bağlantıları bu uygulamayı açar ve oturuma katılır." "from {server}" = "kaynak: {server}" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 2c12c4be7..392f6a059 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -1589,7 +1589,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "các mục kích thước dải lưới trùng lặp sẽ dùng kích thước dải bằng nhau" "e.g. count + 1" = "ví dụ: count + 1" "e.g. count >= 3 and not done" = "ví dụ: count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "các đường nối gấp khúc và cong không có đường dẫn đã lưu được nhập thành đường thẳng" "entries without a node type were skipped" = "đã bỏ qua các mục không có loại nút" "figcraft:// links from a browser, chat or email open this app and join the session." = "Liên kết figcraft:// từ trình duyệt, ứng dụng trò chuyện hoặc email sẽ mở ứng dụng này và tham gia phiên." "from {server}" = "từ {server}" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 3351632d4..6807cbb03 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1570,7 +1570,6 @@ status = "draft — needs native review" "duplicate grid track sizing entries use equal tracks" = "重复的网格轨道尺寸条目使用等分轨道" "e.g. count + 1" = "例如 count + 1" "e.g. count >= 3 and not done" = "例如 count >= 3 and not done" -"elbowed and curved connectors without a saved path import as straight lines" = "没有已保存路径的折线和曲线连接线导入为直线" "entries without a node type were skipped" = "已跳过没有节点类型的条目" "figcraft:// links from a browser, chat or email open this app and join the session." = "来自浏览器、聊天或邮件的 figcraft:// 链接会打开此应用并加入会话。" "from {server}" = "来自 {server}" diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 071890726..b3babfbe5 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-10-30 · **Change:** minor (`.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); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.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-10-30 · **Change:** minor (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); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.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 @@ -17,10 +17,10 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.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), 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). 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; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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 | -| `.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), 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, 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; the file's bytes stay out of the command journal; an import report lists what was left out or changed once the file opens. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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 | -| `.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), 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). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), 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. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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 | -| `.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), 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, 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 (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), 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. Not read: elbowed or curved connectors without a saved path (drawn straight) and labels of connectors without a saved snapshot, 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 | +| `.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), 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). 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; 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). Not read: labels of connectors without a saved snapshot, 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 | +| `.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), 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, 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; 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). Not read: labels of connectors without a saved snapshot, 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 | +| `.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), 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). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), 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). Not read: labels of connectors without a saved snapshot, 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 | +| `.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), 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, 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 (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), 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). Not read: labels of connectors without a saved snapshot, 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, 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 | — | @@ -189,6 +189,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | `.fig` import: elbowed and curved connectors saved without their path were drawn straight; they now run as FigCraft's board connectors do, in right angles or one curve, from the side of each layer they join (the file's `magnet`, else the edge its point is on, else the side facing the other end). The router moved from the engine into `figcraft-doc` (`route_points`) so both use it. Straight connectors are unchanged. Not checked on real files. Tests: `fig::tests::tail` (`other_layer_kinds_import_as_the_closest_layers`, `connectors_without_a_saved_path_follow_their_line_style`) | | 2026-10-30 | minor | M3, #1280: `.fig` export writes a colour calculation (a variable's `{alpha}` or `{mix}` value) as the colour it shows in each mode of its collection, other collections in their default modes; one that resolves to nothing (an operand missing, or more than 32 variables to read) is left out with a warning. Tests: `crates/format/src/fig/assets_tests.rs`. Numbers unchanged. | | 2026-10-30 | minor | Fonts: the missing-font row adds that a family is found under the other names its fonts give it (legacy per-weight names and typographic ones) and with other case and spacing, so a `.fig`, REST or pasted file no longer reports an installed family missing (#1292). Numbers unchanged | | 2026-10-30 | minor | M8, #574: `.fig` import budgets four decoded values per compressed canvas byte instead of one; the 20M floor and the 128M ceiling (32M on 32-bit targets) are unchanged. Dense legitimate files went past one value per compressed byte (further since every scalar field counts) and were refused whole as "not a valid FigCraft file: .fig file: message: too many values". Generated 21 MB and 15 MB canvases (220,044 and 108,012 layers) and a 49.7 MB one budgeted at the ceiling (530,106 layers) now open whole without warnings; a 29 KB canvas that unpacks to 30 million values is still refused. Files that now open instead of being refused take the memory they need: the 49.7 MB canvas's test peaks at about 9 GB. Tests: `crates/format/src/fig/value_budget_tests.rs` (the heavy ones with `--ignored`) and `large_files_get_a_larger_value_budget` in `crates/format/src/fig/tests.rs`. Numbers unchanged. | diff --git a/docs/gaps.md b/docs/gaps.md index f063d47d4..733a6f99b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -100,7 +100,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe so the differences come from the fonts' advances. Evidence: `crates/text/src/saved_breaks_tests.rs`, `crates/format/src/fig/text_wrap_tests.rs`; not checked on real files. -- **Missing:** elbowed or curved connectors without a saved path (drawn straight), labels of +- **Missing:** labels of connectors without a saved snapshot, table cells beyond their own layers, live region style/variable links, variables bound to fields the editor can't bind and video fills aren't read; text lays out again with @@ -568,6 +568,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Gap 1: elbowed and curved connectors without a saved path no longer import as straight lines; they are routed as board connectors are (the shared `figcraft_doc::route_points`). Not checked on real files; estimate unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style` | | 2026-10-30 | minor | Gap 6: plugin panels and command forms take a `slider` control (a number between a required `min` and `max`), drawn as a slider with its value field beside it; the plugin is called back once when a drag ends, one undo step. "Sliders" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go` | | 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish` publishes a repository's `figcraft.connect.json` to a design file (links by component name, warnings for links that don't fit, `--dry-run`, exit 1 when nothing links). The CLI part leaves the missing list; the Dev Mode file-rename check remains; estimate unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, the CLI argument tests | | 2026-10-29 | minor | Colour picker review follow-up: opening the first picker preserves a text undo interaction; placement follows the rendered sidebar; opening controls track their widget ids while moving; linked selection-colour names toggle closed; gradient variable-picker presses keep the parent open. Regression tests cover each case; estimates unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 9857ba3fd..44092430f 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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; compact layer trees with fields and outline (M0.9, part of #996); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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; compact layer trees with fields and outline (M0.9, part of #996); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 1: `.fig` elbowed and curved connectors without a saved path are routed like board connectors (shared `figcraft_doc::route_points`) instead of drawn straight. Tests: `fig::tests::tail` | | 2026-10-30 | minor | M6, gap 6: a `slider` control for plugin panels and command forms (a number between required `min` and `max`; one callback when a drag ends). Tests: `v2_tests`, `plugin_panel_tests` | | 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish` publishes a repository's code connect mappings to a `.figcraft` file (`--dry-run`). Tests: `files::tests`, `args` tests | | 2026-10-30 | minor | M8, #1571 (part 3): the first frame after each move of a drag rebuilt the selection's outlines and box, its slots, the selection spacing and the Design panel's values from the new document. Now the engine notes when a drag has only moved the selected layers, each by the same offset from where it started (`Interaction::moved_by`: not for a layer in an auto layout flow, nor an Alt copy), and the canvas and panel work those out once from the document the drag started from and shift what depends on position (the outlines on screen, the box); the Design panel shows that document's values, all but the position fields, which follow the move. The Export section's values are kept too. Nothing looks different. Dragging 648 of about 1,900 synthetic layers (release build, two runs each): the UI pass 2.10 to 1.44 ms a frame, CPU 135% to 117% of a core; the Design panel 0.9 to 0.5 ms, the overlays 0.5 to 0.3 ms. Not yet: the engine's preview of each move (copying and normalising the document) and the canvas texture's whole upload. Tests: `moved_by_tests.rs`, `canvas/selection_cache_tests.rs`. | From f0abe51a84d090735e61f785b2b4cda15907cbdb Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 17:33:04 +0700 Subject: [PATCH 30/34] M6: plugin widgets have hover styles (gap 6) (#1634) --- ROADMAP.md | 3 +- crates/engine/src/canvas/textpreview.rs | 23 +++- crates/engine/src/canvas/tools.rs | 2 + crates/engine/src/canvas/tools/pointer.rs | 21 ++++ crates/engine/src/extensions/widgets.rs | 73 +++++++++++- crates/engine/src/lib.rs | 2 + crates/engine/src/widget_hover_tests.rs | 129 ++++++++++++++++++++++ crates/plugin/src/lib.rs | 2 +- crates/plugin/src/widget.rs | 47 ++++++++ crates/plugin/src/widget_tests.rs | 23 ++++ docs/gaps.md | 12 +- docs/plugins.md | 6 + docs/roadmap.md | 3 +- docs/target-app-parity.md | 3 +- plugins/examples/counter/src/lib.rs | 4 +- 15 files changed, 342 insertions(+), 11 deletions(-) create mode 100644 crates/engine/src/widget_hover_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 01094ff72..a4cb0dddd 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-10-30 · **Change:** minor (`.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; 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; 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); 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); 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-10-30 · **Change:** minor (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; 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; 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); 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); 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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 6 (first slice): plugin widgets have hover styles, as the reference tool's widgets do. Any layer of a widget description, the widget itself too, may have `hover: {fill?, stroke?, opacity?, color? (text)}`, checked like the rest; on the canvas, while the pointer is over such a layer, it shows that style as a live preview (the text-preview mechanism: no undo step, never saved or sent to a live session), and moving away, leaving the canvas or any command (a click on the part acts on the document itself) puts it back; another live preview showing isn't replaced. `widget.hover {ids}` does the same for scripts and agents. No new plugin API: older FigCraft draws the layer as it is. The Counter example's buttons darken under the pointer. Timers and panels from widgets are still missing. Numbers unchanged. Tests: `widget_hover_tests`, `widget_tests::hover_styles_are_checked_and_normalised`. | | 2026-10-30 | minor | M6, gap 1: `.fig` connectors saved without their path that are elbowed or curved were drawn as straight lines. They now run as FigCraft's board connectors do: in right angles (around the layers they join when the short way would cross them) or along one curve, leaving each layer from the side the file's `magnet` names, else the edge its point lies on, else the side facing the other end. The router (`route_points`) moved from the engine's boards into `figcraft-doc`, unchanged, so the importer and the board connectors share it. Straight connectors are unchanged. Checked by rendering the synthetic fixture with figcraft-cli; not on real files. Numbers unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style`, the elbowed connector in `other_layer_kinds_import_as_the_closest_layers`, the engine's board connector tests. | | 2026-10-30 | minor | M6, gap 6: plugin panels (and plugin command forms) take a `slider` control: a number between a required `min` and `max`, drawn as a slider with the value field beside it, named for screen readers as a slider with its range. Dragging shows the value live and calls the plugin back once, when let go (one undo step); typing in the field or double-clicking the slider (back to the panel's value) do the same. The slider is the one the Type settings' variable axes used, moved into the shared widgets, which also gives those axis sliders a screen-reader name. Numbers unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go`. | | 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish [--mappings FILE] [--dry-run]` publishes a code repository's code connect mappings (`figcraft.connect.json`, as Export code connections writes it) to a design file: each link connects the components or sets of that name to their code for its language; links that don't fit are warnings and are skipped; the file is saved in place through the engine's `file.save` (whole or not at all, version history kept); a dry run saves nothing; nothing linked is exit code 1. Numbers unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, `args` tests. | diff --git a/crates/engine/src/canvas/textpreview.rs b/crates/engine/src/canvas/textpreview.rs index 268b6238b..07d3b0214 100644 --- a/crates/engine/src/canvas/textpreview.rs +++ b/crates/engine/src/canvas/textpreview.rs @@ -12,7 +12,7 @@ use serde_json::Value; use crate::{EngineError, Result, Session, bad, find_command, guard, normalize}; /// The commands that show or end a preview (they don't end the one showing). -pub const COMMANDS: [&str; 2] = ["text.preview", "text.endPreview"]; +pub const COMMANDS: [&str; 3] = ["text.preview", "text.endPreview", "widget.hover"]; /// What can be previewed: text styles, font styles and axis values, layers' properties and the /// blend of a paint on a layer, a selection or a style. @@ -70,6 +70,27 @@ pub fn show(s: &mut Session, command: &str, params: &Value) -> Result<()> { } } +/// Show what `change` does to the document as a live preview (in place of an earlier one), like +/// [`show`] does with a command's: no undo step, and the next command puts the document back first. +pub(crate) fn show_change(s: &mut Session, cmd: &str, change: impl FnOnce(&mut figcraft_doc::Document) -> Result<()>) -> Result<()> { + end(s); + let ds = s.ds_mut()?; + if ds.preview.is_some() { + return Err(EngineError::Disabled(cmd.into(), crate::PREVIEW_READ_ONLY.into())); + } + let base = ds.doc.clone(); + let mut doc = (*base).clone(); + guard::run(cmd, || { + change(&mut doc)?; + normalize(&mut doc, &base); + Ok(()) + })?; + ds.doc = Arc::new(doc); + ds.text_preview = Some(base); + ds.revision += 1; + Ok(()) +} + /// End the live preview, if any: the document is back as it was. Returns whether one was showing. pub fn end(s: &mut Session) -> bool { let Ok(ds) = s.ds_mut() else { return false }; diff --git a/crates/engine/src/canvas/tools.rs b/crates/engine/src/canvas/tools.rs index 862ff2eb8..f99ebe7b6 100644 --- a/crates/engine/src/canvas/tools.rs +++ b/crates/engine/src/canvas/tools.rs @@ -578,6 +578,8 @@ pub struct ToolState { pub pick_only: bool, /// The colour the eyedropper sampled for a colour picker, until the picker takes it. pub picked: Option, + /// The widget layers whose hover styles show while the pointer is over them (a live preview). + pub(crate) widget_hover: Vec, } impl ToolState { diff --git a/crates/engine/src/canvas/tools/pointer.rs b/crates/engine/src/canvas/tools/pointer.rs index e470a179d..e0ebac2cf 100644 --- a/crates/engine/src/canvas/tools/pointer.rs +++ b/crates/engine/src/canvas/tools/pointer.rs @@ -13,6 +13,24 @@ impl Session { self.tool.overlay.measure_box = None; } self.vector_pointer_left(); + self.follow_widget_hover(Vec::new()); + } + + /// Show the hover styles of the widget layers `ids` the pointer is over (none: end them), unless + /// another live preview (a hovered font, say) is showing. + fn follow_widget_hover(&mut self, ids: Vec) { + let ours = !self.tool.widget_hover.is_empty() && crate::textpreview::active(self); + if ids.is_empty() { + if ours { + crate::textpreview::end(self); + } + self.tool.widget_hover.clear(); + return; + } + if ours && self.tool.widget_hover == ids || !ours && crate::textpreview::active(self) { + return; + } + self.tool.widget_hover = if self.widget_hover(&ids).unwrap_or(false) { ids } else { Vec::new() }; } /// What a marquee starting at page point `p` selects inside: the top-level frame it starts in @@ -194,6 +212,7 @@ impl Session { cursor = Cursor::Text; } let mut hover = None; + let mut widget_hover = Vec::new(); let mut guides = Vec::new(); let mut measure_box = None; let arc_hover = if self.arc_handles_shown() { crate::arc_handles::hit(&doc, &sel, ev.p(), ev.zoom).map(|(_, h)| h) } else { None }; @@ -214,6 +233,7 @@ impl Session { if leaf.and_then(|l| crate::widgets::target_at(&doc, l, ev.p())).is_some() { cursor = Cursor::Pointer; } + widget_hover = leaf.map(|l| crate::widgets::hovered_at(&doc, l, ev.p())).unwrap_or_default(); hover = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| leaf.map(|leaf| select_target(&doc, leaf, &sel, ev.cmd))); // Alt over a selected layer: a drag from here duplicates it. if ev.alt && !self.tool.dev_mode && tool == ToolId::Move && hover.is_some_and(|h| sel.contains(&h) && !locked_in(&doc, h)) { @@ -260,6 +280,7 @@ impl Session { if tool == ToolId::Connector { self.connector_hover(ev); } + self.follow_widget_hover(widget_hover); let ds = self.ds_mut()?; if ds.hover != hover { ds.hover = hover; diff --git a/crates/engine/src/extensions/widgets.rs b/crates/engine/src/extensions/widgets.rs index 5d8243633..e1944ca53 100644 --- a/crates/engine/src/extensions/widgets.rs +++ b/crates/engine/src/extensions/widgets.rs @@ -16,7 +16,7 @@ use figcraft_color::{ImageScaleMode, Paint}; use figcraft_doc::{Document, Node, NodeId, NodeKind, TextData}; use figcraft_geom::{Affine, Point}; use figcraft_plugin::widget::{MAX_IMAGE_BYTES, MAX_STATE_BYTES, check_menu, check_state, clean_field}; -use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetKind, WidgetNode, WidgetRender}; +use figcraft_plugin::{Instance, Manifest, RunOutput, WidgetHover, WidgetKind, WidgetNode, WidgetRender}; pub use figcraft_plugin::{MenuItem, MenuKind}; use serde_json::{Map, Value, json}; @@ -36,6 +36,8 @@ const MENU: &str = "figcraft.widget.menu"; const TARGET: &str = "figcraft.widget.target"; /// On a text layer inside a widget: the name of the editable field it is. const FIELD: &str = "figcraft.widget.field"; +/// On a widget or a layer inside one: how it looks while the pointer is over it (JSON). +const HOVER: &str = "figcraft.widget.hover"; /// Images in a widget are at most this many pixels on a side. const MAX_IMAGE_SIDE: u32 = 4096; @@ -79,6 +81,54 @@ pub fn field_of(doc: &Document, id: NodeId) -> Option<(NodeId, String)> { Some((widget_of(doc, id)?, field)) } +/// The layers of a widget under page point `p` that change while the pointer is over them (the +/// widget itself among them), given the layer the pointer hits, outermost first. +pub fn hovered_at(doc: &Document, hit: NodeId, p: Point) -> Vec { + let Some(w) = widget_of(doc, hit) else { return Vec::new() }; + doc.subtree(w) + .into_iter() + .filter(|i| doc.node(*i).is_some_and(|n| n.visible && n.plugin_data.contains_key(HOVER))) + .filter(|i| doc.world_bounds(*i).is_some_and(|b| b.contains(p))) + .collect() +} + +/// What layer `n` sets while the pointer is over it, as `node.set` properties (none when it has no +/// usable hover style: the layer data may come from a file). +fn hover_props(n: &Node) -> Option> { + let text = n.plugin_data.get(HOVER).filter(|s| s.len() <= MAX_STATE_BYTES)?; + let mut h: WidgetHover = serde_json::from_str(text).ok()?; + h.check(if matches!(n.kind, NodeKind::Text(_)) { WidgetKind::Text } else { WidgetKind::Rectangle }).ok()?; + let mut p = Map::new(); + if let Some(c) = h.fill.or(h.color) { + p.insert("fills".into(), json!([c])); + } + if let Some(c) = h.stroke { + p.insert("strokes".into(), json!([c])); + } + if let Some(o) = h.opacity { + p.insert("opacity".into(), json!(o)); + } + Some(p) +} + +impl Session { + /// Show the hover styles of widget layers `ids` as a live preview (none: end the one showing): + /// nothing is undone, saved or sent to a live session, and the next command puts the document + /// back first. Layers without a hover style are left as they are. + pub fn widget_hover(&mut self, ids: &[NodeId]) -> Result { + let cmd = "widget.hover"; + crate::textpreview::end(self); + let doc = self.doc()?; + let changes: Vec<(NodeId, Map)> = + ids.iter().filter(|i| widget_of(doc, **i).is_some()).filter_map(|i| Some((*i, hover_props(doc.node(*i)?)?))).collect(); + if changes.is_empty() { + return Ok(false); + } + crate::textpreview::show_change(self, cmd, |doc| changes.iter().try_for_each(|(id, p)| crate::cmd::props::apply(cmd, doc, *id, p)))?; + Ok(true) + } +} + /// Widget `w`'s property menu (empty when it has none or it's unusable: it may come from a file). pub fn menu_of(doc: &Document, w: NodeId) -> Vec { doc.node(w) @@ -172,6 +222,7 @@ fn build(s: &mut Session, plugin: &str, w: NodeId, r: &WidgetRender) -> Result<( Ok(menu) if !r.menu.is_empty() => n.plugin_data.insert(MENU.into(), menu), _ => n.plugin_data.remove(MENU), }; + set_hover(n, r.root.hover.as_ref()); for c in &r.root.children { add(doc, w, c, cmd)?; } @@ -199,6 +250,7 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result< if !d.field.is_empty() { n.plugin_data.insert(FIELD.into(), d.field.clone()); } + set_hover(&mut n, d.hover.as_ref()); if d.kind == WidgetKind::Image { use base64::Engine as _; let bytes = base64::engine::general_purpose::STANDARD.decode(d.data.trim()).map_err(|e| bad(cmd, format!("an image's `data`: {e}")))?; @@ -218,6 +270,14 @@ fn add(doc: &mut Document, parent: NodeId, d: &WidgetNode, cmd: &str) -> Result< d.children.iter().try_for_each(|c| add(doc, id, c, cmd)) } +/// Keep layer `n`'s hover style (validated already) with it, or drop the one it had. +fn set_hover(n: &mut Node, hover: Option<&WidgetHover>) { + match hover.and_then(|h| serde_json::to_string(h).ok()) { + Some(h) => n.plugin_data.insert(HOVER.into(), h), + None => n.plugin_data.remove(HOVER), + }; +} + /// `node.set` properties for described layer `d` (validated already); `root`: the widget itself, /// which keeps its place and size unless the description gives one. fn props(d: &WidgetNode, root: bool) -> Map { @@ -462,6 +522,17 @@ pub fn specs() -> Vec { false, menu ), + spec!( + "widget.hover", + "Hover widget", + "{ids? [layers of widgets]} shows their hover styles, as the canvas does while the pointer is over a widget: a live preview without an undo step, which the next command (or no ids) ends → {hovering}", + false, + |s, p| { + let ids: Vec = + p.get("ids").and_then(Value::as_array).map(|a| a.iter().filter_map(crate::params::id_of).collect()).unwrap_or_default(); + Ok(json!({"hovering": s.widget_hover(&ids)?})) + } + ), spec!("widget.list", "List widgets", "{} → {widgets: [{id, plugin, name, page, state, targets, fields, menu}]}", false, list), ] } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 4c7b4c610..d5913291c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -597,6 +597,8 @@ mod view_behaviour_tests; #[cfg(test)] mod vue_codegen_tests; #[cfg(test)] +mod widget_hover_tests; +#[cfg(test)] mod widget_input_tests; #[cfg(test)] mod widgets_tests; diff --git a/crates/engine/src/widget_hover_tests.rs b/crates/engine/src/widget_hover_tests.rs new file mode 100644 index 000000000..b6eb10271 --- /dev/null +++ b/crates/engine/src/widget_hover_tests.rs @@ -0,0 +1,129 @@ +//! Widgets' hover styles: while the pointer is over a widget layer that has one, the canvas shows +//! it as a live preview (no undo step, nothing saved or synced); moving away, leaving the canvas or +//! any command puts the layer back, and a click on a hovered part acts on the document itself. + +use serde_json::{Value, json}; + +use crate::components::tests::{ex, idv, node}; +use crate::plugins_tests::{install, undo_len}; +use crate::tools::{PointerEvent, PointerKind}; +use crate::widgets_tests::{W, widget_manifest}; +use crate::{Session, textpreview}; +use figcraft_doc::NodeId; +use figcraft_geom::Point; + +const BLUE: &str = "0D99FF"; +const DARK: &str = "0B7FD6"; + +/// A widget whose button darkens and whose label turns white while hovered; the widget fades a +/// little. A click on the button counts. +fn button() -> Value { + json!({"root": {"type": "frame", "name": "Hover", "layout": "horizontal", "padding": 10, "fill": "#FFFFFF", "hover": {"opacity": 0.9}, "children": [ + {"type": "rectangle", "name": "Button", "target": "go", "width": 40, "height": 20, "fill": format!("#{BLUE}"), "hover": {"fill": format!("#{DARK}"), "stroke": "#000000"}}, + {"type": "text", "name": "Label", "text": "Go", "color": "#111111", "hover": {"color": "#FFFFFF"}}]}}) +} + +fn session() -> (Session, NodeId) { + let mut s = Session::new(); + let mut w = W::default(); + let body = w.render(&button()); + install(&mut s, widget_manifest("org.test.hover", "read"), &w.build(&body)); + let id = idv(&ex(&mut s, "widget.insert", json!({"plugin": "org.test.hover", "x": 50, "y": 60}))); + ex(&mut s, "select.set", json!({"ids": []})); + (s, id) +} + +fn fill(s: &Session, id: NodeId) -> String { + node(s, id).fills.first().and_then(|p| p.solid_color()).map(|c| c.to_hex()).unwrap_or_default() +} + +fn hover(s: &mut Session, p: Point) { + s.pointer(&PointerEvent::at(PointerKind::Hover, p.x, p.y, 1.0)).unwrap(); +} + +fn hover_over(s: &mut Session, id: NodeId) { + let p = centre(s, id); + hover(s, p); +} + +fn centre(s: &Session, id: NodeId) -> Point { + s.doc().unwrap().world_bounds(id).unwrap().center() +} + +#[test] +fn hovering_a_widget_shows_its_hover_styles_without_an_edit() { + let (mut s, w) = session(); + let kids = node(&s, w).children.clone(); + let (btn, label) = (kids[0], kids[1]); + let undo = undo_len(&s); + hover_over(&mut s, btn); + assert_eq!(fill(&s, btn), DARK, "the hovered button"); + assert!(!node(&s, btn).strokes.is_empty()); + assert!((node(&s, w).opacity - 0.9).abs() < 1e-6, "the widget the pointer is in"); + assert_eq!(fill(&s, label), "111111", "the label isn't under the pointer"); + let itself = s.ds().unwrap().document_itself().clone(); + assert_eq!(itself.node(btn).unwrap().fills[0].solid_color().unwrap().to_hex(), BLUE, "the document itself is unchanged"); + assert_eq!(undo_len(&s), undo, "no undo step"); + // Over the label: the label's style, not the button's. + hover_over(&mut s, label); + assert_eq!((fill(&s, btn), fill(&s, label)), (BLUE.into(), "FFFFFF".into())); + // Away from the widget, and leaving the canvas: as it was. + hover(&mut s, Point::new(-400.0, -400.0)); + assert_eq!((fill(&s, btn), fill(&s, label)), (BLUE.into(), "111111".into())); + assert!((node(&s, w).opacity - 1.0).abs() < 1e-6); + hover_over(&mut s, btn); + s.pointer_left(); + assert_eq!(fill(&s, btn), BLUE); + assert!(!textpreview::active(&s)); +} + +#[test] +fn a_click_on_a_hovered_part_edits_the_document_itself() { + let (mut s, w) = session(); + let btn = node(&s, w).children[0]; + let at = centre(&s, btn); + hover(&mut s, at); + assert_eq!(fill(&s, btn), DARK); + let undo = undo_len(&s); + s.pointer(&PointerEvent::at(PointerKind::Down, at.x, at.y, 1.0)).unwrap(); + s.pointer(&PointerEvent::at(PointerKind::Up, at.x, at.y, 1.0)).unwrap(); + assert_eq!(undo_len(&s), undo + 1, "the click is one undo step"); + // The widget was redrawn from the document itself: no hover style was kept in it. + let btn = node(&s, w).children[0]; + assert_eq!(fill(&s, btn), BLUE); + ex(&mut s, "edit.undo", json!({})); + let btn = node(&s, w).children[0]; + assert_eq!(fill(&s, btn), BLUE, "undo brings back the document, not a hovered one"); + // Hovering again shows it again. + hover(&mut s, at); + assert_eq!(fill(&s, btn), DARK); +} + +#[test] +fn widget_hover_is_a_command_any_other_command_ends() { + let (mut s, w) = session(); + let btn = node(&s, w).children[0]; + assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [btn]}))["hovering"], true); + assert_eq!(fill(&s, btn), DARK); + ex(&mut s, "select.set", json!({"ids": [w]})); + assert_eq!(fill(&s, btn), BLUE, "another command puts the document back first"); + // Layers without a hover style, and layers outside widgets, show nothing. + let r = idv(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10}))); + assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [r]}))["hovering"], false); + assert_eq!(ex(&mut s, "widget.hover", json!({}))["hovering"], false); + // A hover style damaged in a file is ignored. + let doc = s.ds_mut().unwrap().doc_mut(); + doc.get_mut(btn).unwrap().plugin_data.insert("figcraft.widget.hover".into(), "{\"fill\": \"not a colour\"}".into()); + assert_eq!(ex(&mut s, "widget.hover", json!({"ids": [btn]}))["hovering"], false); +} + +#[test] +fn a_widget_doesnt_replace_another_live_preview() { + let (mut s, w) = session(); + let btn = node(&s, w).children[0]; + let other = idv(&ex(&mut s, "shape.rectangle", json!({"x": 400, "y": 0, "width": 10, "height": 10}))); + ex(&mut s, "text.preview", json!({"command": "node.set", "params": {"ids": [other], "props": {"opacity": 0.5}}})); + hover_over(&mut s, btn); + assert!((node(&s, other).opacity - 0.5).abs() < 1e-6, "the preview showing stays"); + assert_eq!(fill(&s, btn), BLUE); +} diff --git a/crates/plugin/src/lib.rs b/crates/plugin/src/lib.rs index 31e5234ba..62ac432bc 100644 --- a/crates/plugin/src/lib.rs +++ b/crates/plugin/src/lib.rs @@ -11,7 +11,7 @@ pub mod widget; pub use manifest::{API_VERSION, Access, MANIFEST_FILE, Manifest, Panel, Param, ParamKind, Permissions, PluginCommand}; pub use sandbox::{ENTRY, Host, IMPORT_MODULE, Instance, Limits, PluginError, RunOutput, UiChange, check_module, run}; -pub use widget::{MenuItem, MenuKind, WidgetKind, WidgetNode, WidgetRender}; +pub use widget::{MenuItem, MenuKind, WidgetHover, WidgetKind, WidgetNode, WidgetRender}; #[cfg(test)] mod bundle_tests; diff --git a/crates/plugin/src/widget.rs b/crates/plugin/src/widget.rs index be202c97d..88cb1dc2a 100644 --- a/crates/plugin/src/widget.rs +++ b/crates/plugin/src/widget.rs @@ -118,10 +118,54 @@ pub struct WidgetNode { /// An image layer's bytes, base64. #[serde(default, skip_serializing_if = "String::is_empty")] pub data: String, + /// How the layer looks while the pointer is over it (hosts that don't know it draw the layer + /// as it is). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub hover: Option, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub children: Vec, } +/// A widget layer's look while the pointer is over it: what it gives replaces the layer's own +/// fill, stroke, opacity or (text) colour, as the reference tool's widgets' hover style does. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct WidgetHover { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub fill: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub stroke: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub opacity: Option, + /// A text layer's colour. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color: Option, +} + +impl WidgetHover { + /// Check and normalise it for a `kind` layer: hex colours, an opacity from 0 to 1, a colour for + /// text and a fill for the rest, and something to show. + pub fn check(&mut self, kind: WidgetKind) -> Result<(), String> { + colour("hover fill", &mut self.fill)?; + colour("hover stroke", &mut self.stroke)?; + colour("hover color", &mut self.color)?; + within("hover opacity", self.opacity, 0.0, 1.0)?; + let text = kind == WidgetKind::Text; + if self.color.is_some() && !text { + return Err("`hover.color` is for text layers (others take `fill`)".into()); + } + if self.fill.is_some() && text { + return Err("a text layer's hover takes `color`, not `fill`".into()); + } + if self.fill.is_some() && kind == WidgetKind::Image { + return Err("an image layer's hover takes no `fill`".into()); + } + if *self == WidgetHover::default() { + return Err("`hover` needs a fill, stroke, opacity or color".into()); + } + Ok(()) + } +} + /// What a widget callback hands back with `widget_render`: the widget's new state (`None`: keep /// it) and its layers, the root a frame. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -322,6 +366,9 @@ impl WidgetNode { only("text", !self.text.is_empty() || self.size.is_some() || self.color.is_some() || self.bold, kind == WidgetKind::Text)?; only("data", !self.data.is_empty(), kind == WidgetKind::Image)?; only("field", !self.field.is_empty(), kind == WidgetKind::Text)?; + if let Some(h) = &mut self.hover { + h.check(kind)?; + } if self.text.chars().count() > MAX_TEXT { return Err(format!("a text is longer than {MAX_TEXT} characters")); } diff --git a/crates/plugin/src/widget_tests.rs b/crates/plugin/src/widget_tests.rs index 1074503d6..3ce8db966 100644 --- a/crates/plugin/src/widget_tests.rs +++ b/crates/plugin/src/widget_tests.rs @@ -158,3 +158,26 @@ fn field_values_are_cleaned_and_bounded() { assert!(clean_field(&"x".repeat(MAX_FIELD_CHARS + 1)).is_err()); assert_eq!(clean_field("").unwrap(), ""); } + +#[test] +fn hover_styles_are_checked_and_normalised() { + let w = parse(json!({"root": {"type": "frame", "hover": {"opacity": 0.8}, "children": [ + {"type": "rectangle", "target": "go", "fill": "#0D99FF", "hover": {"fill": "0b7fd6", "stroke": "#000"}}, + {"type": "text", "text": "Go", "hover": {"color": "#fff"}}]}})) + .unwrap(); + assert_eq!(w.root.hover.as_ref().and_then(|h| h.opacity), Some(0.8)); + let rect = w.root.children[0].hover.clone().unwrap(); + assert_eq!((rect.fill.as_deref(), rect.stroke.as_deref()), (Some("#0B7FD6"), Some("#000000"))); + assert_eq!(w.root.children[1].hover.as_ref().and_then(|h| h.color.as_deref()), Some("#FFFFFF")); + for (bad, why) in [ + (json!({"type": "rectangle", "hover": {}}), "needs a fill"), + (json!({"type": "rectangle", "hover": {"fill": "red"}}), "hex colour"), + (json!({"type": "rectangle", "hover": {"opacity": 1.5}}), "from 0 to 1"), + (json!({"type": "rectangle", "hover": {"color": "#fff"}}), "for text layers"), + (json!({"type": "text", "text": "x", "hover": {"fill": "#fff"}}), "takes `color`"), + ] { + let e = parse(json!({"root": {"type": "frame", "children": [bad]}})).unwrap_err(); + assert!(e.contains(why), "{e}"); + } + assert!(!w.uses_api_4(), "a hover style needs no new API: older hosts draw the layer as it is"); +} diff --git a/docs/gaps.md b/docs/gaps.md index 733a6f99b..970327e95 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (gap 6: plugin widgets have hover styles; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The @@ -19,7 +19,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | 3 | No representative corpus checked against real Figma files | Fidelity | 15–30 | [target-app-parity](target-app-parity.md) | | 4 | Interaction fidelity not compared tool by tool | UI/UX | 40–60 | [ui-parity](ui-parity.md) | | 5 | Localization: English, reviewed Russian and Turkish, plus unreviewed Spanish (and Latin American Spanish), French, German, Brazilian Portuguese, Japanese, Korean, Simplified Chinese, Indonesian and Czech drafts; Turkish and Swedish catalogs; Arabic draft: 2,508 entries, native review and panel mirroring pending | Localization | 29–51 | [localization-parity](localization-parity.md) | -| 6 | Plugins: widgets without hover or timers, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 6–22 | [target-app-parity](target-app-parity.md) | +| 6 | Plugins: widgets without timers, a self-hosted directory but no public catalogue; Figma's plugins don't run | Ecosystem | 6–22 | [target-app-parity](target-app-parity.md) | | 7 | CPU canvas where Figma uses the GPU | Hardware/performance | 30–60 | [hardware-parity](hardware-parity.md) | | 8 | Platform QA: Linux and the browser left (Windows pass done 2026-10-11) | Stability/platforms | 10–20 | [hardware-parity](hardware-parity.md) | | 9 | Figma Draw: pen pressure on macOS/Linux tablets (multi-edit, brushes, pressure, text on a path and pattern fills landed) | Feature | 1–3 | [target-app-parity](target-app-parity.md) | @@ -233,7 +233,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe [`localization-parity.md`](localization-parity.md); how to add one in [`translating.md`](translating.md). -### 6. Plugins: widgets without hover or timers, a self-hosted directory but no public catalogue; Figma's plugins don't run +### 6. Plugins: widgets without timers, a self-hosted directory but no public catalogue; Figma's plugins don't run - **Landed (#101):** sandboxed WebAssembly plugins (wasmi, metered, memory cap, no WASI, also in the browser) acting through the command registry, one undo step per run, manifest permissions, Plugins menu, Run last plugin, Manage plugins, `plugin.*` commands, a Rust SDK and two examples @@ -255,11 +255,14 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe (the value, cleaned and capped, goes into the widget's state and the plugin redraws; one undo step), and a widget can carry a property menu of actions, toggles and colour choices, shown in a small bar while it's selected and driven with `widget.menu`; the Counter example uses both. + Widget hover styles (2026-10-30): a widget layer's `hover` (fill, stroke, opacity, a text's + colour) shows while the pointer is over it on the canvas, as a live preview that is never saved + or synced and that any command ends (`widget.hover` for agents); the Counter's buttons darken. Sliders (2026-10-30): a panel control or command parameter of type `slider` (a number between its `min` and `max`) is drawn as a slider with its value beside it; the plugin is called back once, when the drag ends. - **Missing:** a public catalogue and Community files, publisher accounts and signatures, ratings - and install counts, background update checks, the directory in the browser build; widgets have no hover or + and install counts, background update checks, the directory in the browser build; widgets have no timers, can't open a panel and draw only frames, text, shapes and images; no images in panels. Figma's own plugins are JavaScript against Figma's plugin API and don't run. @@ -568,6 +571,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Gap 6: widget hover styles landed (a layer's `hover` fill, stroke, opacity or text colour shown while the pointer is over it, as a live preview; `widget.hover`); the entry no longer lists hover. Estimate unchanged. | | 2026-10-30 | minor | Gap 1: elbowed and curved connectors without a saved path no longer import as straight lines; they are routed as board connectors are (the shared `figcraft_doc::route_points`). Not checked on real files; estimate unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style` | | 2026-10-30 | minor | Gap 6: plugin panels and command forms take a `slider` control (a number between a required `min` and `max`), drawn as a slider with its value field beside it; the plugin is called back once when a drag ends, one undo step. "Sliders" leaves the missing list; estimate unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go` | | 2026-10-30 | minor | Gap 16: `figcraft-cli connect publish` publishes a repository's `figcraft.connect.json` to a design file (links by component name, warnings for links that don't fit, `--dry-run`, exit 1 when nothing links). The CLI part leaves the missing list; the Dev Mode file-rename check remains; estimate unchanged. Tests: `files::tests::connect_publish_links_components_and_saves_the_file`, the CLI argument tests | diff --git a/docs/plugins.md b/docs/plugins.md index b0ff0e743..7da7ca50c 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -150,6 +150,7 @@ plugins: import them in the script. | `widget.click` | `{id, target, fuel?}` | Clicks part `target` of widget `id` (or of the widget a layer `id` is in), as a click on the canvas would: the plugin is called back and redraws it. One undo step; returns like `widget.insert`. | | `widget.setText` | `{id, field, value, fuel?}` | Sets editable field `field` of widget `id` (or of the widget a layer `id` is in) to the text `value`, as editing it on the canvas would: the value (at most 1,000 characters, cleaned) goes into the state under the field's name and into the field's text, and the plugin is called back. One undo step; returns like `widget.insert`. | | `widget.menu` | `{id, item, value?, fuel?}` | Uses item `item` of widget `id`'s property menu, as its bar would: an action takes no `value`; a toggle takes `true` or `false` (default: the opposite of now); a colour takes one of its options (hex, any case). The plugin is called back. One undo step; returns like `widget.insert`. | +| `widget.hover` | `{ids?}` | Shows the hover styles of widget layers `ids`, as the canvas does while the pointer is over them: a live preview with no undo step, which the next command (or no `ids`) ends. Returns `{hovering}`. | | `widget.list` | `{}` | `{widgets: [{id, plugin, name, page, state, targets, fields, menu}]}`: every widget in the file. | | `plugin.directory.list` | `{server?, query?}` | The plugin directory at `server` (`ws://host:port/`; default: the one browsed last): `{server, plugins: [{id, name, version, description, author, api, access, commands, widget, bytes, sha256, published, versions, installed, update}]}`, the latest version of each plugin whose id, name, description or author contains `query`. `installed` is the installed version (or null), `update` whether the listed one is newer. | | `plugin.directory.install` | `{id, version?, confirm, server?, sha256?}` | Downloads version `version` (default: the latest) and installs it, or updates the installed one. `confirm` must be `true` (installing runs the plugin's code: show its permissions first); without it nothing is downloaded. The download must match the SHA-256 the directory lists (and `sha256`, the one shown, when given). Returns `{installed, name, version, permissions, sha256, path, source, replaced}`. | @@ -402,6 +403,11 @@ with auto layout it hugs its contents on an axis without a size. A text has `tex `color` and `bold` (with a `width` it wraps), and with API 4 may have a `field` (a name like a target's): the text is then an editable field showing its current value. An image has `data`, the image's bytes (PNG, JPEG…) as base64, usually carried in the module itself; without a size it takes the image's. +Any layer, the widget itself too, may have a `hover`: how it looks while the pointer is over +it on the canvas, with `fill` (a text: `color`), `stroke` and `opacity` replacing its own, +for example `"hover": {"fill": "#E0E0E0"}` on a button. It is shown as a live preview, never +saved or sent to a live session, and needs no new API (a FigCraft that doesn't know it draws the +layer as it is). FigCraft checks the whole description (kinds, sizes, numbers, colours, lengths, counts, depth, image sizes) and refuses it if anything is off. diff --git a/docs/roadmap.md b/docs/roadmap.md index 44092430f..4ca8604c4 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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; compact layer trees with fields and outline (M0.9, part of #996); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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; compact layer trees with fields and outline (M0.9, part of #996); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, gap 6: widget hover styles (`hover` on a widget layer: fill, stroke, opacity, text colour) shown while the pointer is over it, as a live preview; `widget.hover`. Tests: `widget_hover_tests` | | 2026-10-30 | minor | M6, gap 1: `.fig` elbowed and curved connectors without a saved path are routed like board connectors (shared `figcraft_doc::route_points`) instead of drawn straight. Tests: `fig::tests::tail` | | 2026-10-30 | minor | M6, gap 6: a `slider` control for plugin panels and command forms (a number between required `min` and `max`; one callback when a drag ends). Tests: `v2_tests`, `plugin_panel_tests` | | 2026-10-30 | minor | M6, gap 16: `figcraft-cli connect publish` publishes a repository's code connect mappings to a `.figcraft` file (`--dry-run`). Tests: `files::tests`, `args` tests | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 33dd78b87..6889c0f74 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-10-29 · **Change:** minor (the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); 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); 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; 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; 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); 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) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); 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); 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; 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; 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); 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) · **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 @@ -648,6 +648,7 @@ Carried over from the ROADMAP's "Shipped so far" (2026-10-10). The test count th | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Gap 6: plugin widgets have hover styles (fill, stroke, opacity, text colour while the pointer is over a layer); timers, panels from widgets and more layer kinds remain. Numbers unchanged. | | 2026-10-30 | minor | M4, #1051: fonts installed on this computer in the browser build. Where the browser lists them (Chromium's Local Font Access, `window.queryLocalFonts`), the Design panel's font menu offers "Use fonts installed on this computer…"; the browser asks for permission, and the fonts join the font list by name, with their styles. A font's bytes are read only when text uses it, one style at a time (its preview in the menu waits until then), and auto-sizing text fits it once it is in; one that can't be read is dropped and its text keeps the fallback. A family the build embeds keeps its faces; the computer's styles it lacks join it. Other browsers show no row (`figcraft_text::add_local_fonts`, `apps/figcraft-web/src/local_fonts.rs`). Tests: `fontdb::local_tests`, `local_fonts_tests`; checked in a browser. Percentages and hours unchanged | | 2026-10-29 | minor | M4, #1219: Thai, Lao, Khmer and Burmese text, written without spaces between words, wraps between words instead of where a line overflows (`พัฒนาซอ|ฟต์แวร์` → `พัฒนา|ซอฟต์แวร์`). `figcraft_text::breaks::word_breaks` finds the word starts inside each run of these scripts with ICU4X's line segmenter (`icu_segmenter` 2.3): its dictionaries on desktop, its LSTM models in the browser (about 0.39 MB of wasm instead of 1.86 MB, which the 24 MiB gate can't take; they split a few compounds the dictionaries keep whole). Where a run meets spaces, Latin or CJK the UAX #14 rules decide as before, and a word wider than its box still takes #1400's emergency break. Text without these scripts does no extra work. Grade unchanged. Evidence: `breaks::tests::thai_lines_break_between_words`, `lao_khmer_and_burmese_lines_break_between_words`, `word_breaks_survive_junk`, `behaviour_tests::thai_lines_break_between_words` (fails on main). | | 2026-10-29 | minor | M8, #998: MCP tools that act on a document (`run_command`, `batch`, `create_node`, `set_properties`, `inspect_document`, `get_node`, `select`, `export`, `screenshot`, `save_file`, `undo`, `redo`) take an optional `document`, an open file's `id` (`doc.list` and the `document.inspect` outline now carry it) or path, and so do the control methods `engine.execute`, `document.inspect`, `node.get` and `ui.render`: the call acts on that file and the tab in front stays in front, so an attached agent keeps editing its own file when the user switches tabs. Calls without it act on the file in front as before; other control methods refuse a `document` behind the tab in front. Numbers unchanged | diff --git a/plugins/examples/counter/src/lib.rs b/plugins/examples/counter/src/lib.rs index 92815c4fb..b666aa9d4 100644 --- a/plugins/examples/counter/src/lib.rs +++ b/plugins/examples/counter/src/lib.rs @@ -50,9 +50,10 @@ impl Counter { } } -/// A square button that calls back with `target`. +/// A square button that calls back with `target`, darker while the pointer is over it. fn button(target: &str, name: &str, label: &str) -> Value { json!({"type": "frame", "name": name, "target": target, "layout": "horizontal", "justify": "center", "width": 32, "height": 32, "radius": 8, "fill": "#F0F0F0", + "hover": {"fill": "#E0E0E0"}, "children": [{"type": "text", "text": label, "size": 16, "bold": true, "color": "#1E1E1E"}]}) } @@ -118,6 +119,7 @@ mod tests { assert_eq!((v["children"][0]["text"].clone(), v["children"][1]["children"][1]["text"].clone()), (json!("Score"), json!("3"))); assert_eq!(view(&c(3), " ")["children"][0]["text"], PLACEHOLDER); assert_eq!(menu(&c(0))[1]["checked"], false); + assert_eq!(v["children"][1]["children"][0]["hover"], json!({"fill": "#E0E0E0"}), "buttons darken under the pointer"); assert!(run(Input::default()).is_err(), "it only runs as a widget"); } } From e4a7e89f394444168f4a25783b2ca5b9f296384a Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 18:00:34 +0700 Subject: [PATCH 31/34] M3: typed and stored variable, collection and mode ids find exactly that object (#1613) (#1635) Builds on #1618. Variables::get and Variables::collection take an exact id before a name; stored mode ids go through Collection::mode_by_id (expressions, the prototype's mode switch); font family variables and the colour picker, prototype editor and panels read stored ids exactly. Ported from fix/stored-ids-exact. --- ROADMAP.md | 3 +- crates/doc/src/proto_expr.rs | 4 +- crates/doc/src/proto_expr_parse.rs | 2 +- crates/doc/src/styles.rs | 24 +++++--- crates/doc/src/tests.rs | 20 +++++++ crates/engine/src/cmd/library.rs | 2 +- crates/engine/src/cmd/styles.rs | 2 +- crates/engine/src/fonts.rs | 2 +- crates/engine/src/lib.rs | 2 + crates/engine/src/proto_run.rs | 12 ++-- crates/engine/src/proto_run_tests.rs | 15 +++-- crates/engine/src/stored_ids_tests.rs | 58 +++++++++++++++++++ crates/engine/src/variables_io_tests.rs | 5 +- crates/format/src/fig/export/assets.rs | 2 +- crates/format/src/rest/proto.rs | 2 +- crates/ui-egui/src/dialogs/variables_panel.rs | 6 +- crates/ui-egui/src/panels/assets_panel.rs | 2 +- crates/ui-egui/src/panels/proto_editor.rs | 14 ++--- crates/ui-egui/src/panels/proto_panel.rs | 6 +- crates/ui-egui/src/panels/selection_colors.rs | 2 +- crates/ui-egui/src/panels/typography.rs | 2 +- crates/ui-egui/src/panels/variable_ui.rs | 4 +- crates/ui-egui/src/present/variables_tests.rs | 7 ++- crates/ui-egui/src/widgets/colorpicker.rs | 2 +- crates/ui-egui/src/widgets/style_picker.rs | 4 +- docs/components-parity.md | 3 +- docs/roadmap.md | 3 +- 27 files changed, 157 insertions(+), 53 deletions(-) create mode 100644 crates/engine/src/stored_ids_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index a4cb0dddd..d6540ba9f 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-10-30 · **Change:** minor (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; 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; 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); 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); 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-10-30 · **Change:** minor (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; 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; 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); 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); 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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. | | 2026-10-30 | minor | M6, gap 6 (first slice): plugin widgets have hover styles, as the reference tool's widgets do. Any layer of a widget description, the widget itself too, may have `hover: {fill?, stroke?, opacity?, color? (text)}`, checked like the rest; on the canvas, while the pointer is over such a layer, it shows that style as a live preview (the text-preview mechanism: no undo step, never saved or sent to a live session), and moving away, leaving the canvas or any command (a click on the part acts on the document itself) puts it back; another live preview showing isn't replaced. `widget.hover {ids}` does the same for scripts and agents. No new plugin API: older FigCraft draws the layer as it is. The Counter example's buttons darken under the pointer. Timers and panels from widgets are still missing. Numbers unchanged. Tests: `widget_hover_tests`, `widget_tests::hover_styles_are_checked_and_normalised`. | | 2026-10-30 | minor | M6, gap 1: `.fig` connectors saved without their path that are elbowed or curved were drawn as straight lines. They now run as FigCraft's board connectors do: in right angles (around the layers they join when the short way would cross them) or along one curve, leaving each layer from the side the file's `magnet` names, else the edge its point lies on, else the side facing the other end. The router (`route_points`) moved from the engine's boards into `figcraft-doc`, unchanged, so the importer and the board connectors share it. Straight connectors are unchanged. Checked by rendering the synthetic fixture with figcraft-cli; not on real files. Numbers unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style`, the elbowed connector in `other_layer_kinds_import_as_the_closest_layers`, the engine's board connector tests. | | 2026-10-30 | minor | M6, gap 6: plugin panels (and plugin command forms) take a `slider` control: a number between a required `min` and `max`, drawn as a slider with the value field beside it, named for screen readers as a slider with its range. Dragging shows the value live and calls the plugin back once, when let go (one undo step); typing in the field or double-clicking the slider (back to the panel's value) do the same. The slider is the one the Type settings' variable axes used, moved into the shared widgets, which also gives those axis sliders a screen-reader name. Numbers unchanged. Tests: `v2_tests::panels_are_validated_and_keep_entered_values`, `plugin_panel_tests::a_slider_calls_the_plugin_back_once_when_let_go`. | diff --git a/crates/doc/src/proto_expr.rs b/crates/doc/src/proto_expr.rs index ca074d277..1638c12ec 100644 --- a/crates/doc/src/proto_expr.rs +++ b/crates/doc/src/proto_expr.rs @@ -110,7 +110,7 @@ impl Expr { let v = vars.get_by_id(variable)?; let c = vars.collection_by_id(&v.collection)?; let mut at = modes.clone(); - at.insert(c.id.clone(), c.find_mode(mode)?.id.clone()); + at.insert(c.id.clone(), c.mode_by_id(mode)?.id.clone()); return vars.resolve(&v.id, &at); } Expr::Op { op, args } => (*op, args), @@ -159,7 +159,7 @@ impl Expr { let var = vars.get_by_id(variable); let mode = var .and_then(|v| vars.collection_by_id(&v.collection)) - .and_then(|c| c.find_mode(mode)) + .and_then(|c| c.mode_by_id(mode)) .map_or("(missing mode)", |m| m.name.as_str()); format!("{}[{}]", Expr::var(variable.clone()).display_at(vars, depth + 1), name_arg(mode)) } diff --git a/crates/doc/src/proto_expr_parse.rs b/crates/doc/src/proto_expr_parse.rs index 660b4a836..a8c26a71f 100644 --- a/crates/doc/src/proto_expr_parse.rs +++ b/crates/doc/src/proto_expr_parse.rs @@ -97,7 +97,7 @@ impl Expr { Expr::InMode { variable, mode } => { let v = vars.get_by_id(variable).ok_or_else(|| format!("No variable `{variable}`"))?; let c = vars.collection_by_id(&v.collection).ok_or_else(|| format!("No variable collection `{}`", v.collection))?; - return c.find_mode(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name)); + return c.mode_by_id(mode).map(|_| v.kind).ok_or_else(|| format!("{} has no mode `{mode}`", c.name)); } Expr::Op { op, args } => (*op, args), }; diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 66326d850..f3893077e 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -223,9 +223,14 @@ impl Collection { self.modes.first().map(|m| &m.id) } - /// The mode with id `key`, else the one named `key`. + /// The mode with id `key`, else the one named `key` (what someone typed). pub fn find_mode(&self, key: &str) -> Option<&Mode> { - self.modes.iter().find(|m| m.id == key).or_else(|| self.modes.iter().find(|m| m.name == key)) + self.mode_by_id(key).or_else(|| self.modes.iter().find(|m| m.name == key)) + } + + /// The mode with id `id` exactly: a stored reference, which no mode's name stands in for (#1613). + pub fn mode_by_id(&self, id: &str) -> Option<&Mode> { + self.modes.iter().find(|m| m.id == id) } /// The mode the collection is in under `modes` (collection → chosen mode): the chosen one if it @@ -356,8 +361,10 @@ pub struct Variables { } impl Variables { - pub fn get(&self, id: &str) -> Option<&Variable> { - self.variables.iter().find(|v| v.id == id || v.name == id) + /// The variable a person named: the one with id `key`, else the one named `key`. A stored + /// reference goes through [`get_by_id`](Self::get_by_id) instead. + pub fn get(&self, key: &str) -> Option<&Variable> { + self.get_by_id(key).or_else(|| self.variables.iter().find(|v| v.name == key)) } /// Look up an already resolved variable id (a stored binding or alias): exact ids only, so a @@ -366,11 +373,14 @@ impl Variables { self.variables.iter().find(|v| v.id == id) } - pub fn collection(&self, id: &str) -> Option<&Collection> { - self.collections.iter().find(|c| c.id == id || c.name == id) + /// The collection a person named: the one with id `key`, else the one named `key`. A stored + /// reference goes through [`collection_by_id`](Self::collection_by_id) instead. + pub fn collection(&self, key: &str) -> Option<&Collection> { + self.collection_by_id(key).or_else(|| self.collections.iter().find(|c| c.name == key)) } - /// Look up a stored collection reference by exact id, without matching collection names. + /// [`get_by_id`](Self::get_by_id) for collections: a stored collection id (a variable's own, a + /// prototype action's, a library record's) never matches another collection's name (#1613). pub fn collection_by_id(&self, id: &str) -> Option<&Collection> { self.collections.iter().find(|c| c.id == id) } diff --git a/crates/doc/src/tests.rs b/crates/doc/src/tests.rs index 1bf115078..a0e3e17b0 100644 --- a/crates/doc/src/tests.rs +++ b/crates/doc/src/tests.rs @@ -282,6 +282,26 @@ fn variables_resolve_stored_ids_never_by_name() { // A deleted id stays unresolved instead of picking up the variable named like it. assert_eq!(v.resolve("VariableID:9", &modes), None); assert_eq!(v.resolve("VariableID:4", &modes), None); + // What a person types: an id wins over a name, and a name still finds its variable (#1613). + assert_eq!(v.get("VariableID:2").map(|x| x.name.as_str()), Some("surface")); + assert_eq!(v.get("surface").map(|x| x.id.as_str()), Some("VariableID:2")); + assert!(v.get_by_id("surface").is_none()); +} + +#[test] +fn collections_and_modes_resolve_stored_ids_never_by_name() { + let mut v = Variables::default(); + let coll = |id: &str, name: &str, modes: Vec| Collection { id: id.into(), name: name.into(), modes, extends: None }; + let mode = |id: &str, name: &str| Mode { id: id.into(), name: name.into() }; + v.collections.push(coll("c1", "c2", vec![mode("m1", "m2")])); + v.collections.push(coll("c2", "Theme", vec![mode("m2", "Dark"), mode("m3", "m9")])); + assert_eq!(v.collection("c2").map(|c| c.name.as_str()), Some("Theme"), "an id wins over a name"); + assert_eq!(v.collection("Theme").map(|c| c.id.as_str()), Some("c2")); + assert!(v.collection_by_id("Theme").is_none() && v.collection_by_id("c9").is_none()); + let theme = v.collection_by_id("c2").unwrap(); + assert_eq!(theme.mode_by_id("m2").map(|m| m.name.as_str()), Some("Dark")); + assert!(theme.mode_by_id("m9").is_none(), "a deleted mode id isn't the mode named like it"); + assert_eq!(theme.find_mode("Dark").map(|m| m.id.as_str()), Some("m2")); } #[test] diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 08d7ff795..dd361ddc0 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -262,7 +262,7 @@ fn set_mode(s: &mut Session, p: &Value) -> Result { .or_else(|| published_collections(&lib).find(|c| c.name == key)) .ok_or_else(|| bad(cmd, format!("\"{}\" publishes no variable collection `{key}`", link.name)))?; let m = req_string(cmd, p, "mode")?; - let mode = coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?; + let mode = coll.find_mode(&m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?; let key = coll.id.clone(); let map = import_variables(s.doc_mut()?, &lib, &BTreeSet::new(), &BTreeSet::from([key.clone()]), false); let local = map.collections.get(&key).cloned().ok_or_else(|| EngineError::Other("the collection didn't import".into()))?; diff --git a/crates/engine/src/cmd/styles.rs b/crates/engine/src/cmd/styles.rs index 7253cf331..99d4521e4 100644 --- a/crates/engine/src/cmd/styles.rs +++ b/crates/engine/src/cmd/styles.rs @@ -981,7 +981,7 @@ pub fn var_set(s: &mut Session, p: &Value) -> Result { let var = doc.variables.get(&v).cloned().ok_or_else(|| bad(cmd, "no such variable"))?; let coll = doc.variables.collection_by_id(&var.collection).cloned().ok_or_else(|| bad(cmd, "variable has no collection"))?; let mode = match string(p, "mode") { - Some(m) => coll.modes.iter().find(|x| x.id == m || x.name == m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?, + Some(m) => coll.find_mode(&m).map(|x| x.id.clone()).ok_or_else(|| bad(cmd, "no such mode"))?, None => coll.default_mode().cloned().ok_or_else(|| bad(cmd, "collection has no modes"))?, }; let raw = get(p, "value").ok_or_else(|| bad(cmd, "invalid value"))?; diff --git a/crates/engine/src/fonts.rs b/crates/engine/src/fonts.rs index d1ba177e6..484e7b88e 100644 --- a/crates/engine/src/fonts.rs +++ b/crates/engine/src/fonts.rs @@ -111,7 +111,7 @@ fn string_values(vars: &Variables, id: &str) -> Vec { if !seen.insert(id.clone()) { continue; } - let Some(var) = vars.get(&id) else { continue }; + let Some(var) = vars.get_by_id(&id) else { continue }; for v in var.values.values() { match v { VariableValue::String(s) => out.push(s.clone()), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index d5913291c..5b6bd5af5 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -523,6 +523,8 @@ mod sketch_import_tests; #[cfg(test)] mod slot_tests; #[cfg(test)] +mod stored_ids_tests; +#[cfg(test)] mod stroke_style_tests; #[cfg(test)] mod style_tests; diff --git a/crates/engine/src/proto_run.rs b/crates/engine/src/proto_run.rs index 7a25fcbd9..f1759e163 100644 --- a/crates/engine/src/proto_run.rs +++ b/crates/engine/src/proto_run.rs @@ -66,15 +66,17 @@ pub fn set_variable(doc: &mut Document, node: NodeId, variable: &str, value: &Va /// on the page's top-level frames that chose another mode of it (modes chosen deeper inside still /// win), then re-apply bindings. False for an unknown collection or mode. pub fn set_mode(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool { - let Some(id) = doc.variables.collection(collection).map(|c| c.id.clone()) else { return false }; - set_mode_by_id(doc, frame, &id, mode) + let Some(c) = doc.variables.collection(collection) else { return false }; + let Some(m) = c.find_mode(mode) else { return false }; + let (collection, mode) = (c.id.clone(), m.id.clone()); + set_mode_by_id(doc, frame, &collection, &mode) } -/// Switch a stored collection id to `mode`, without matching another collection's name. -/// Otherwise behaves like [`set_mode`]. +/// Switch a stored collection id to a stored mode id (a prototype action's), without matching +/// another collection's or mode's name (#1613). Otherwise behaves like [`set_mode`]. pub fn set_mode_by_id(doc: &mut Document, frame: NodeId, collection: &str, mode: &str) -> bool { let Some(c) = doc.variables.collection_by_id(collection) else { return false }; - let Some(mode) = c.find_mode(mode).map(|m| m.id.clone()) else { + let Some(mode) = c.mode_by_id(mode).map(|m| m.id.clone()) else { return false; }; let cid = c.id.clone(); diff --git a/crates/engine/src/proto_run_tests.rs b/crates/engine/src/proto_run_tests.rs index cd2d3b671..b6e2f6529 100644 --- a/crates/engine/src/proto_run_tests.rs +++ b/crates/engine/src/proto_run_tests.rs @@ -315,8 +315,9 @@ fn stored_variable_ids_resolve_only_incoming_action_and_expression_names() { let actions = &doc.node(frame).unwrap().interactions[0].actions; let Action::SetVariable { variable, expr: Some(expr), .. } = &actions[0] else { panic!("no set variable") }; assert_eq!(variable, &target); - // A JSON expression is command input: it still accepts names, even one that resembles an id. - assert_eq!(expr.eval(&doc.variables, &Default::default()), Some(VariableValue::Number(100.0))); + // A JSON expression is command input: it accepts names, but an exact id comes first, so the + // variable renamed to target's id doesn't take its place (#1613). + assert_eq!(expr.eval(&doc.variables, &Default::default()), Some(VariableValue::Number(5.0))); let Action::Conditional { blocks } = &actions[1] else { panic!("no conditional") }; assert_eq!(blocks[0].condition.as_ref().unwrap().eval(&doc.variables, &Default::default()), Some(VariableValue::Boolean(true))); let Action::SetVariable { value: stored, .. } = &blocks[0].actions[0] else { panic!("no nested set variable") }; @@ -332,15 +333,19 @@ fn stored_variable_ids_switch_only_the_prototype_collection() { shadow.id = "shadow".into(); shadow.name = theme.id.clone(); doc.variables.collections.insert(0, shadow); - assert!(crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark")); + let dark = theme.find_mode("Dark").unwrap().id.clone(); + assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark"), "a stored mode is an id, not a name"); + assert!(crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, &dark)); assert_eq!(fill_hex(&doc, r).as_deref(), Some("000000")); let page = doc.page_of(frame).unwrap(); assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow")); - // The public name-accepting wrapper keeps its existing lookup behavior. + // The name-accepting wrapper takes an exact id before a name (#1613). assert!(set_mode(&mut doc, frame, &theme.id, "Light")); + assert!(!doc.node(page).unwrap().var_modes.contains_key("shadow")); + assert!(set_mode(&mut doc, frame, "shadow", "Light"), "the other collection by its id"); assert!(doc.node(page).unwrap().var_modes.contains_key("shadow")); doc.variables.collections.retain(|c| c.id != theme.id); let before = doc.clone(); - assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, "Dark")); + assert!(!crate::proto_run::set_mode_by_id(&mut doc, frame, &theme.id, &dark)); assert_eq!(doc, before); } diff --git a/crates/engine/src/stored_ids_tests.rs b/crates/engine/src/stored_ids_tests.rs new file mode 100644 index 000000000..e2523efc0 --- /dev/null +++ b/crates/engine/src/stored_ids_tests.rs @@ -0,0 +1,58 @@ +//! A variable renamed to another one's id never takes its place (#1613), beside #1618's tests: +//! an id a person types (`variable.bind`, `variable.set`) finds the variable with that exact id +//! before one named like it, and the Tailwind export and expressions read the stored id. Names still +//! work where a person types them. + +use figcraft_doc::VariableValue; +use figcraft_doc::proto::Expr; +use serde_json::{Value, json}; + +use crate::Session; +use crate::components::tests::ex; + +/// The issue's document: Theme with `shadow`, `surface` and `alias` (→ surface), then `shadow` +/// renamed to surface's id with Web code syntax `--shadow`. Ids of shadow, surface and alias. +fn renamed_like_an_id() -> (Session, String, String, String) { + let mut s = Session::new(); + ex(&mut s, "variable.createCollection", json!({"name": "Theme"})); + let id = |v: Value| v["id"].as_str().unwrap().to_string(); + let shadow = id(ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "shadow", "type": "color", "value": "#FF0000"}))); + let surface = id(ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "surface", "type": "color", "value": "#0D99FF"}))); + let alias = id(ex(&mut s, "variable.create", json!({"collection": "Theme", "name": "alias", "type": "color", "value": {"alias": "surface"}}))); + ex(&mut s, "variable.update", json!({"variable": shadow, "name": surface, "codeSyntax": {"WEB": "--shadow"}})); + (s, shadow, surface, alias) +} + +#[test] +fn exported_aliases_point_at_the_stored_id() { + let (mut s, ..) = renamed_like_an_id(); + let css = ex(&mut s, "variable.export", json!({"format": "css"}))["text"].as_str().unwrap().to_string(); + assert!(css.contains("--alias: var(--surface);"), "{css}"); + let tw = ex(&mut s, "variable.export", json!({"format": "tailwind"}))["text"].as_str().unwrap().to_string(); + assert!(tw.contains("var(--color-surface)") && !tw.contains("var(--shadow)"), "{tw}"); +} + +#[test] +fn dev_inspect_names_the_bound_variable() { + let (mut s, _, surface, _) = renamed_like_an_id(); + let r = ex(&mut s, "shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + let r = crate::params::id_of(&r["id"]).unwrap(); + ex(&mut s, "variable.bind", json!({"ids": [r.0], "field": "fill", "variable": surface})); + let doc = s.doc().unwrap(); + let found = crate::devinspect::bound_variable(doc, doc.node(r).unwrap(), "fill").unwrap(); + assert_eq!(found.name, "surface"); +} + +#[test] +fn expressions_read_and_show_the_stored_variable() { + let (mut s, _, surface, _) = renamed_like_an_id(); + ex(&mut s, "variable.addMode", json!({"collection": "Theme", "name": "Dark"})); + ex(&mut s, "variable.set", json!({"variable": surface, "mode": "Dark", "value": "#00FF00"})); + let vars = &s.doc().unwrap().variables; + let theme = vars.collection("Theme").unwrap().clone(); + let dark = theme.find_mode("Dark").unwrap().id.clone(); + let e = Expr::InMode { variable: surface.clone(), mode: dark }; + assert!(matches!(e.eval(vars, &Default::default()), Some(VariableValue::Color(c)) if c.to_hex() == "00FF00")); + assert_eq!(e.display(vars), "surface[Dark]"); + assert_eq!(Expr::var(surface).display(vars), "surface"); +} diff --git a/crates/engine/src/variables_io_tests.rs b/crates/engine/src/variables_io_tests.rs index cb03342fb..57473a057 100644 --- a/crates/engine/src/variables_io_tests.rs +++ b/crates/engine/src/variables_io_tests.rs @@ -258,10 +258,11 @@ fn stored_variable_ids_keep_remapped_import_aliases() { let alias = vars.get("alias").unwrap(); assert_eq!(alias.values.values().next(), Some(&VariableValue::Alias(target.clone()))); assert_eq!(vars.resolve(&alias.id, &Default::default()), Some(VariableValue::Number(5.0))); - // The command's alias argument remains name-accepting, unlike the import's remapped id. + // The command's alias argument accepts names, but an exact id comes first (#1613): the + // variable renamed to target's id doesn't take its place. ex(&mut s, "variable.set", json!({"variable": "alias", "value": {"alias": target}})); let vars = &s.doc().unwrap().variables; - assert_eq!(vars.resolve(&vars.get("alias").unwrap().id, &Default::default()), Some(VariableValue::Number(100.0))); + assert_eq!(vars.resolve(&vars.get("alias").unwrap().id, &Default::default()), Some(VariableValue::Number(5.0))); } #[test] diff --git a/crates/format/src/fig/export/assets.rs b/crates/format/src/fig/export/assets.rs index 63aef407a..3874b073c 100644 --- a/crates/format/src/fig/export/assets.rs +++ b/crates/format/src/fig/export/assets.rs @@ -259,7 +259,7 @@ impl<'d> Exporter<'d> { } } - /// A reference to variable `var` (its stored id) as variable data. + /// A reference to variable `var` (a stored id: no name stands in for it, #1613) as variable data. fn alias(&mut self, var: &str) -> Option> { let found = self.doc.variables.get_by_id(var).and_then(|v| Some((v.kind, *self.var_ids.get(v.id.as_str())?))); let Some((kind, local)) = found else { diff --git a/crates/format/src/rest/proto.rs b/crates/format/src/rest/proto.rs index bbd6287b1..ab9b499e7 100644 --- a/crates/format/src/rest/proto.rs +++ b/crates/format/src/rest/proto.rs @@ -443,7 +443,7 @@ impl Ctx<'_> { let vars = &self.doc.variables; let found = parts.filter(|(id, _)| self.var_kinds.contains_key(*id)).and_then(|(id, mode)| { let v = vars.get_by_id(id)?; - let m = vars.collection_by_id(&v.collection)?.modes.iter().find(|m| m.id == mode)?; + let m = vars.collection_by_id(&v.collection)?.mode_by_id(mode)?; Some(Expr::InMode { variable: v.id.clone(), mode: m.id.clone() }) }); if found.is_none() { diff --git a/crates/ui-egui/src/dialogs/variables_panel.rs b/crates/ui-egui/src/dialogs/variables_panel.rs index 996f42465..96c940865 100644 --- a/crates/ui-egui/src/dialogs/variables_panel.rs +++ b/crates/ui-egui/src/dialogs/variables_panel.rs @@ -556,7 +556,7 @@ fn table(app: &mut FigApp, ui: &mut Ui, view: &View, st: &mut Editor) { crate::widgets::select_all_on_focus(ui, &r, &st.query); let note = Rect::from_min_max(pos2(field.max.x + 16.0, sr.min.y + 8.0), pos2(sr.max.x - 16.0, sr.max.y - 8.0)); let mut bar = ui.new_child(UiBuilder::new().max_rect(note).layout(egui::Layout::right_to_left(egui::Align::Center))); - let text = match (view.library, coll.extends.as_deref().and_then(|p| view.vars.collection(p))) { + let text = match (view.library, coll.extends.as_deref().and_then(|p| view.vars.collection_by_id(p))) { (Some((lib, name)), _) => { let tip = tr("A collection of this file with these modes and variables: override their values per mode and add variables of its own"); if widgets::button(&mut bar, tr("Extend collection"), false, true).on_hover_text(tip).clicked() { @@ -801,7 +801,7 @@ fn value_cell(app: &mut FigApp, ui: &mut Ui, view: &View, v: &Variable, coll: &C } } if let VariableValue::Alias(target) = val { - let full = view.vars.get(target).map(|x| x.name.as_str()).unwrap_or(tr("Missing variable")); + let full = view.vars.get_by_id(target).map(|x| x.name.as_str()).unwrap_or(tr("Missing variable")); let resp = ui.interact(body, id.with("alias"), if editable { Sense::click() } else { Sense::hover() }); let resp = if editable { resp.on_hover_text(tr("Change alias")) } else { resp }; ui.painter().rect_filled(body, CornerRadius::same(5), t.field); @@ -968,7 +968,7 @@ fn create_row(app: &mut FigApp, ui: &mut Ui, view: &View, coll: &Collection, st: /// The details of one of this file's variables: name, description, where pickers offer it and /// whether publishing the file as a library leaves it out. fn details(app: &mut FigApp, ctx: &Context, vars: &Variables, st: &mut Editor) { - let Some(v) = st.details.as_ref().filter(|_| st.library.is_none()).and_then(|id| vars.get(id)).cloned() else { + let Some(v) = st.details.as_ref().filter(|_| st.library.is_none()).and_then(|id| vars.get_by_id(id)).cloned() else { st.details = None; return; }; diff --git a/crates/ui-egui/src/panels/assets_panel.rs b/crates/ui-egui/src/panels/assets_panel.rs index 7592b2574..1dab99f94 100644 --- a/crates/ui-egui/src/panels/assets_panel.rs +++ b/crates/ui-egui/src/panels/assets_panel.rs @@ -904,7 +904,7 @@ fn publish_form(app: &mut FigApp, ui: &mut Ui, w: f32, doc: &Document) { if !all.collections.is_empty() { crate::widgets::sublabels(ui, tr("Variable collections"), None); for c in &all.collections { - let name = doc.variables.collection(c).map(|x| x.name.as_str()).unwrap_or_default(); + let name = doc.variables.collection_by_id(c).map(|x| x.name.as_str()).unwrap_or_default(); if check(ui, format!("v{c}"), def.collections.contains(c), name) { include.push(json!(c)); } diff --git a/crates/ui-egui/src/panels/proto_editor.rs b/crates/ui-egui/src/panels/proto_editor.rs index 9dad94270..c26e05275 100644 --- a/crates/ui-egui/src/panels/proto_editor.rs +++ b/crates/ui-egui/src/panels/proto_editor.rs @@ -445,7 +445,7 @@ pub fn ops_for(kind: Option) -> &'static [ExprOp] { fn with_op(s: &Simple, op: ExprOp, vars: &Variables) -> Simple { let rhs = match (op, &s.rhs) { (ExprOp::Not, _) => Rhs::Nothing, - (_, Rhs::Nothing) => Rhs::Value(vars.get(&s.var).map_or(VariableValue::Boolean(true), |v| start_value(vars, v))), + (_, Rhs::Nothing) => Rhs::Value(vars.get_by_id(&s.var).map_or(VariableValue::Boolean(true), |v| start_value(vars, v))), (_, r) => r.clone(), }; Simple { var: s.var.clone(), op, rhs } @@ -479,7 +479,7 @@ fn condition_editor(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, b: usiz fn builder_row(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, b: usize, s: &Simple) { let vars = &cx.doc.variables; - let var = vars.get(&s.var); + let var = vars.get_by_id(&s.var); let kind = var.map(|v| v.kind); row(ui, 32.0, |ui| { let w = ui.available_width(); @@ -507,7 +507,7 @@ fn builder_row(app: &mut FigApp, ui: &mut Ui, cx: &Cx, slot: &Slot, b: usize, s: Rhs::Nothing => {} Rhs::Variable(other) => { let (rect, _) = ui.allocate_exact_size(vec2(side, FIELD_H), Sense::hover()); - let name = vars.get(other).map_or(tr("Missing variable"), |v| v.name.as_str()); + let name = vars.get_by_id(other).map_or(tr("Missing variable"), |v| v.name.as_str()); match widgets::bound_field(ui, slot.id("proto_cond_rhs_var").with(b), Lead::None, rect, name) { Some(BindEvent::Open(at)) => crate::variable_ui::open_proto(ui, VarSlot { slot: slot.clone(), part: VarPart::Rhs(b) }, kind, at), Some(BindEvent::Detach) => { @@ -569,7 +569,7 @@ fn literal_field(ui: &mut Ui, id: Id, cur: &VariableValue, w: f32) -> Option) { let vars = &cx.doc.variables; - let var = vars.get(variable); + let var = vars.get_by_id(variable); widgets::sublabels(ui, tr("Variable"), None); row(ui, 32.0, |ui| { let name = var.map_or(tr("Choose variable"), |v| v.name.as_str()); @@ -694,19 +694,19 @@ where pub fn pick_variable(app: &mut FigApp, vs: &VarSlot, id: &str) { let Ok(doc) = app.session.ds().map(|d| d.doc.clone()) else { return }; let vars = &doc.variables; - let Some(picked) = vars.get(id) else { return }; + let Some(picked) = vars.get_by_id(id) else { return }; let current = doc.node(vs.slot.node).and_then(|n| n.interactions.get(vs.slot.k)).and_then(|it| action_at(&it.actions, &vs.slot.path)); match (vs.part, current) { (VarPart::SetVariable, Some(Action::SetVariable { variable, value, expr })) => { // Another type starts from the new variable's value. - let same = vars.get(variable).is_some_and(|v| v.kind == picked.kind); + let same = vars.get_by_id(variable).is_some_and(|v| v.kind == picked.kind); let (value, expr) = if same { (value.clone(), expr.clone()) } else { (value_json(&start_value(vars, picked)), None) }; set_action(app, &vs.slot, Action::SetVariable { variable: picked.id.clone(), value, expr }); } (VarPart::Lhs(b), Some(Action::Conditional { blocks })) => { let cur = blocks.get(b).and_then(|x| x.condition.as_ref()).and_then(simple); let next = match cur { - Some(s) if vars.get(&s.var).is_some_and(|v| v.kind == picked.kind) => Simple { var: picked.id.clone(), ..s }, + Some(s) if vars.get_by_id(&s.var).is_some_and(|v| v.kind == picked.kind) => Simple { var: picked.id.clone(), ..s }, _ => Simple { var: picked.id.clone(), op: ExprOp::Equals, rhs: Rhs::Value(start_value(vars, picked)) }, }; set_condition(app, &vs.slot, b, &next.expr()); diff --git a/crates/ui-egui/src/panels/proto_panel.rs b/crates/ui-egui/src/panels/proto_panel.rs index f5334f064..ca1870368 100644 --- a/crates/ui-egui/src/panels/proto_panel.rs +++ b/crates/ui-egui/src/panels/proto_panel.rs @@ -198,14 +198,14 @@ pub(crate) fn action_summary(doc: &Document, a: &Action) -> String { match a { Action::OpenUrl { url } if !url.is_empty() => trf("Open link {url}", &[("url", url)]), Action::SetVariable { variable, expr, .. } => { - let name = vars.get(variable).map_or(variable.as_str(), |v| v.name.as_str()); + let name = vars.get_by_id(variable).map_or(variable.as_str(), |v| v.name.as_str()); match expr { Some(e) => trf("Set {name} to {value}", &[("name", &name), ("value", &e.display(vars))]), None => trf("Set variable {name}", &[("name", &name)]), } } Action::SetVariableMode { collection, mode } => { - let c = vars.collection(collection); + let c = vars.collection_by_id(collection); let mode = c.and_then(|c| c.modes.iter().find(|m| &m.id == mode)).map_or(mode.as_str(), |m| m.name.as_str()); trf("Set {name} to {value}", &[("name", &c.map_or(collection.as_str(), |c| c.name.as_str())), ("value", &mode)]) } @@ -1060,7 +1060,7 @@ pub(crate) fn action_fields(app: &mut FigApp, ui: &mut Ui, doc: &Document, page: } Action::SetVariableMode { collection, mode } => { let colls = &doc.variables.collections; - let current = doc.variables.collection(collection); + let current = doc.variables.collection_by_id(collection); widgets::sublabels(ui, tr("Collection"), None); row(ui, 32.0, |ui| { let resp = widgets::dropdown(ui, current.map_or(tr("Choose collection"), |c| c.name.as_str()), ui.available_width(), None); diff --git a/crates/ui-egui/src/panels/selection_colors.rs b/crates/ui-egui/src/panels/selection_colors.rs index 9d7a058c2..97333367f 100644 --- a/crates/ui-egui/src/panels/selection_colors.rs +++ b/crates/ui-egui/src/panels/selection_colors.rs @@ -82,7 +82,7 @@ pub fn section(app: &mut FigApp, ui: &mut Ui, doc: &Arc, ids: &[NodeId .style .as_ref() .and_then(|id| doc.style(id).map(|s| s.name.as_str())) - .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get(id).map(|v| v.name.as_str()))); + .or_else(|| group.variable.as_ref().and_then(|id| doc.variables.get_by_id(id).map(|v| v.name.as_str()))); if let Some(name) = linked_name { let response = widgets::dropdown(ui, name, width, None); toggle(app, group.targets.clone(), Some(&response)); diff --git a/crates/ui-egui/src/panels/typography.rs b/crates/ui-egui/src/panels/typography.rs index 46ba8ce7b..0f41fd2e3 100644 --- a/crates/ui-egui/src/panels/typography.rs +++ b/crates/ui-egui/src/panels/typography.rs @@ -288,7 +288,7 @@ mod variable_tests { /// The name of the variable `field` of the text layer is bound to. fn bound_to(&self, field: &str) -> Option { let doc = self.app.session.doc().unwrap(); - doc.node(self.text).unwrap().bound.get(field).and_then(|v| doc.variables.get(v)).map(|v| v.name.clone()) + doc.node(self.text).unwrap().bound.get(field).and_then(|v| doc.variables.get_by_id(v)).map(|v| v.name.clone()) } } diff --git a/crates/ui-egui/src/panels/variable_ui.rs b/crates/ui-egui/src/panels/variable_ui.rs index 80e544f9a..93bf6ee9f 100644 --- a/crates/ui-egui/src/panels/variable_ui.rs +++ b/crates/ui-egui/src/panels/variable_ui.rs @@ -52,7 +52,7 @@ pub fn bound_name(doc: &Document, ids: &[NodeId], field: &str) -> Option if !it.all(|v| v.as_ref() == Some(&first)) { return None; } - doc.variables.get(&first).map(|v| v.name.clone()) + doc.variables.get_by_id(&first).map(|v| v.name.clone()) } /// A variable a picker offers: one of this file's, or a published one of an enabled library. @@ -397,7 +397,7 @@ pub fn picker(app: &mut FigApp, ctx: &egui::Context) { // variables never lead back to this file's). 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(variable)), + Target::Alias { variable, .. } => (None, doc.variables.get_by_id(variable)), Target::Proto(_) | Target::Style { .. } => (None, None), }; // A style's fields offer variables scoped to one of the properties the field sets. diff --git a/crates/ui-egui/src/present/variables_tests.rs b/crates/ui-egui/src/present/variables_tests.rs index 3b67139a7..80f47d522 100644 --- a/crates/ui-egui/src/present/variables_tests.rs +++ b/crates/ui-egui/src/present/variables_tests.rs @@ -77,7 +77,9 @@ fn counter_then_conditional_navigation() { fn set_variable_mode_recolours_bound_layers() { let (mut doc, a, _, button) = setup(); let theme = doc.variables.collection("Theme").unwrap().id.clone(); - on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.clone(), mode: "Dark".into() }]); + // Actions store mode ids (commands and imports turn names into them). + let dark = doc.variables.collection("Theme").unwrap().find_mode("Dark").unwrap().id.clone(); + on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.clone(), mode: dark }]); let hex = |p: &Presenter| p.doc.as_ref().and_then(|d| d.node(a)?.fills.first()?.solid_color()).map(|c| c.to_hex()); let mut p = Presenter::new(a); p.sync(&Arc::new(doc), 0.0); @@ -118,7 +120,8 @@ fn stored_variable_ids_keep_player_collection_identity() { if missing { doc.variables.collections.retain(|c| c.id != theme.id); } - on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.id.clone(), mode: "Dark".into() }]); + let dark = theme.find_mode("Dark").unwrap().id.clone(); + on_click(&mut doc, button, vec![Action::SetVariableMode { collection: theme.id.clone(), mode: dark }]); let mut p = Presenter::new(a); p.sync(&Arc::new(doc), 0.0); assert_eq!(p.fire(button, 0, None, 1.0), !missing); diff --git a/crates/ui-egui/src/widgets/colorpicker.rs b/crates/ui-egui/src/widgets/colorpicker.rs index 6f2964795..1f2b577d5 100644 --- a/crates/ui-egui/src/widgets/colorpicker.rs +++ b/crates/ui-egui/src/widgets/colorpicker.rs @@ -97,7 +97,7 @@ fn current_paint(app: &FigApp, target: &PickerTarget) -> Option { } PickerTarget::Effect { ids, index, secondary } => doc.node(*ids.first()?)?.effects.get(*index)?.color(*secondary).map(Paint::solid), PickerTarget::TextRange { id, start, .. } => range_fills(doc, *id, *start)?.into_iter().next(), - PickerTarget::Variable { id, mode } => match doc.variables.get(id)?.values.get(mode)? { + PickerTarget::Variable { id, mode } => match doc.variables.get_by_id(id)?.values.get(mode)? { figcraft_doc::VariableValue::Color(c) => { let mut p = Paint::solid(c.with_alpha(1.0)); p.opacity = c.a; diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index 11708388c..92e809f5e 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -667,7 +667,7 @@ fn binding_cell(ui: &mut Ui, doc: &Document, style: &Style, field: &str, kind: f let t = Tokens::get(ui.ctx()); let row = ui.min_rect(); let row = Rect::from_min_max(pos2(row.min.x, row.max.y - ROW_H), row.max); - let Some(name) = style.bound.get(field).map(|v| doc.variables.get(v).map_or(v.as_str(), |x| x.name.as_str())) else { + let Some(name) = style.bound.get(field).map(|v| doc.variables.get_by_id(v).map_or(v.as_str(), |x| x.name.as_str())) else { let b = Rect::from_center_size(pos2(row.max.x - 13.0, row.center().y), vec2(20.0, 20.0)); if ui.rect_contains_pointer(row) && menu_icon(ui, b, Icon::Variable, tr("Apply variable"), false).clicked() { crate::variable_ui::open_style(ui, &style.id, field, kind, row.left_bottom()); @@ -732,7 +732,7 @@ fn text_binding(ui: &mut Ui, binding: TextBinding<'_>, out: &mut Frame) { /// The text style's variable name for `field`, or None when the field is not bound. fn bound_name(doc: &Document, style: &Style, field: &str) -> Option { - style.bound.get(field).map(|v| doc.variables.get(v).map_or_else(|| v.clone(), |x| x.name.clone())) + style.bound.get(field).map(|v| doc.variables.get_by_id(v).map_or_else(|| v.clone(), |x| x.name.clone())) } /// Redefines the text style `def` through `f`; the edit is queued only when it changes the style. diff --git a/docs/components-parity.md b/docs/components-parity.md index ba7208fa7..406ae1ed8 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -78,6 +78,7 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M3, #1613: a variable, collection or mode named like another one's id no longer takes its place where an id is stored or typed (expressions, prototype mode switches, font family variables, the panels' labels); names still work where a person types them. Grade unchanged. | | 2026-10-30 | minor | M3, #1280: a colour variable can be calculated from others, per mode: `{"alpha": {"variable", "opacity"}}` is that variable's colour with its alpha replaced (replace, not multiply, so re-editing the source's alpha never compounds), `{"mix": {"from", "to", "t"}}` blends two operands (`{"variable"}` or `{"literal"}`) per channel over the stored values in the document's colour profile, alpha included (converting the profile keeps a mix's ends exact and moves its colours in between slightly). Calculations resolve lazily like aliases, so bound layers, mode switches, instances and undo follow their sources; they compose (a calculation of a calculation), a loop is refused when it is set, and one resolve consults at most 32 variables. Files keep 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, so a CSS calculation doesn't follow another collection's `data-` mode); deleting a source leaves its readers on the colour they showed; library import and swap re-point their operands. The panel shows them read-only (swatch and formula) and can swap one for an alias or detach it to its colour. Tests: `engine/src/variables_colour_tests.rs`, `doc/src/tests.rs`. Left: editing calculations in the panel; files using them need this build to open. Numbers unchanged | | 2026-10-30 | minor | M3, follow-up to #1542: stored variable and collection references use exact ids in export, library copies and swaps, imported aliases, alias deletion and cycle checks, Dev Inspect, the library mode picker, and prototype actions and expressions. Interaction edits resolve only incoming names and leave stored references alone. Command arguments still accept names as before. Tests: the `stored_variable_ids_*` tests in the doc, engine, format and ui-egui present and responsive suites. Numbers unchanged | | 2026-10-30 | minor | M8: the component set's Properties section fits the narrowest right panel: variant values wrap onto more lines and every + and − button stays visible. Grade unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4ca8604c4..b5279d693 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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; compact layer trees with fields and outline (M0.9, part of #996); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (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); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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; compact layer trees with fields and outline (M0.9, part of #996); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -76,6 +76,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. | | 2026-10-30 | minor | M6, gap 6: widget hover styles (`hover` on a widget layer: fill, stroke, opacity, text colour) shown while the pointer is over it, as a live preview; `widget.hover`. Tests: `widget_hover_tests` | | 2026-10-30 | minor | M6, gap 1: `.fig` elbowed and curved connectors without a saved path are routed like board connectors (shared `figcraft_doc::route_points`) instead of drawn straight. Tests: `fig::tests::tail` | | 2026-10-30 | minor | M6, gap 6: a `slider` control for plugin panels and command forms (a number between required `min` and `max`; one callback when a drag ends). Tests: `v2_tests`, `plugin_panel_tests` | From dd2a9fd01300c320201d9df3f64d57ba8de588ea Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 18:36:00 +0700 Subject: [PATCH 32/34] Docs: one copy of each row and status item, and a check that keeps it so (#1658) * Docs: one copy of each row and status item, and a check that keeps it so Merge resolutions that kept both sides had left progress-doc rows (the .fig row four times, co-editing, live cursors, Windows, M0.9) and status-line items (302 in all) twice or more. The rows are merged keeping every distinct fact, the newest wording winning (#1649's connector routing included); repeated status items are removed, the first kept. cargo xtask docs, run by ci, fails on a repeated table row or status item. * Docs: remove the status items the merge of main brought back Merging main into #1658 rebuilt the status lines from main's copies, which still listed items twice: ROADMAP 2, docs/roadmap.md 5, docs/gaps.md 43 and docs/target-app-parity.md 190, whose unclosed parenthesis also came back and hid them from cargo xtask docs. They are removed again, the first copy kept, and the parenthesis closed; every distinct item is still present against main. --- ROADMAP.md | 3 +- docs/collaboration-parity.md | 7 +-- docs/file-format-parity.md | 7 +-- docs/gaps.md | 2 +- docs/hardware-parity.md | 5 +- docs/localization-parity.md | 2 +- docs/roadmap.md | 5 +- docs/target-app-parity.md | 2 +- docs/ui-parity.md | 2 +- xtask/src/docs_check.rs | 106 +++++++++++++++++++++++++++++++++++ xtask/src/main.rs | 10 +++- 11 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 xtask/src/docs_check.rs diff --git a/ROADMAP.md b/ROADMAP.md index d6540ba9f..791d9f152 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-10-30 · **Change:** minor (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; 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; 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); 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); 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-10-30 · **Change:** minor (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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Docs (found while landing #1649): merges that kept both sides of a conflict had left rows and status items twice. One row each now for `.fig` in `docs/file-format-parity.md` (four copies: #1234, #1236 and the #1076 ZIP budget kept, with the newer four-values-per-byte budget wording and #1649's connector routing), Real-time co-editing (three: #1069, #1065, #1063) and Live cursors (two: #1380, #1416) in `docs/collaboration-parity.md`, Windows x64 / arm64 in `docs/hardware-parity.md` (#1320, #965) and M0.9 in `docs/roadmap.md` (#1396, #996). The status lines of this file, `docs/roadmap.md`, `docs/gaps.md`, `docs/target-app-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md` and `docs/file-format-parity.md` drop items listed more than once (302 in all, the first kept), and an unclosed parenthesis in target-app-parity's status line is closed. No wording changed otherwise. New `cargo xtask docs`, run by `ci`: a table row that repeats an earlier one (header rows apart) or a status item listed twice fails it. Numbers unchanged. Test: `docs_check::tests`. | | 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. | | 2026-10-30 | minor | M6, gap 6 (first slice): plugin widgets have hover styles, as the reference tool's widgets do. Any layer of a widget description, the widget itself too, may have `hover: {fill?, stroke?, opacity?, color? (text)}`, checked like the rest; on the canvas, while the pointer is over such a layer, it shows that style as a live preview (the text-preview mechanism: no undo step, never saved or sent to a live session), and moving away, leaving the canvas or any command (a click on the part acts on the document itself) puts it back; another live preview showing isn't replaced. `widget.hover {ids}` does the same for scripts and agents. No new plugin API: older FigCraft draws the layer as it is. The Counter example's buttons darken under the pointer. Timers and panels from widgets are still missing. Numbers unchanged. Tests: `widget_hover_tests`, `widget_tests::hover_styles_are_checked_and_normalised`. | | 2026-10-30 | minor | M6, gap 1: `.fig` connectors saved without their path that are elbowed or curved were drawn as straight lines. They now run as FigCraft's board connectors do: in right angles (around the layers they join when the short way would cross them) or along one curve, leaving each layer from the side the file's `magnet` names, else the edge its point lies on, else the side facing the other end. The router (`route_points`) moved from the engine's boards into `figcraft-doc`, unchanged, so the importer and the board connectors share it. Straight connectors are unchanged. Checked by rendering the synthetic fixture with figcraft-cli; not on real files. Numbers unchanged. Tests: `fig::tests::tail::connectors_without_a_saved_path_follow_their_line_style`, the elbowed connector in `other_layer_kinds_import_as_the_closest_layers`, the engine's board connector tests. | diff --git a/docs/collaboration-parity.md b/docs/collaboration-parity.md index b7ffa109c..d1fef81ca 100644 --- a/docs/collaboration-parity.md +++ b/docs/collaboration-parity.md @@ -12,11 +12,8 @@ cursors, selections, text carets, cursor chat and spotlights shown live ([`colla | Feature | Figma | FigCraft | Hours | |---|---|---|---| -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Left: server archives beyond Linux | 25–55 | -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). Left: server archives beyond Linux | 25–55 | -| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | -| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors, selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | -| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars, follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | +| Real-time co-editing | yes | **core built, no networking** (#103): client-scoped ids, per-property change sets with fractional positions, server ordering and validation, optimistic clients, binary messages; randomized multi-client convergence tests over real commands. Live sessions over the network landed (`crates/collab-net`): the WebSocket transport (keep-alive, size and rate limits, reconnect with backoff), hosting from the app (this machine or the local network) with invite links, live cursors with name tags, selections, avatars and follow, `collab.*` commands. Document sync between apps works over them (the host's server orders every batch; guests open the host's file as a new tab). Sessions are per document: each open file has its own or none, several run at once (host one, join another, publish a third) and keep syncing behind the tab in front, and closing a file ends its session only. Revert to saved is refused until the document leaves its live session; its access checks and sync association stay intact. Undo is per person (only your own steps, others' later writes left alone), refused edits roll back with a toast, and a reconnect resumes as the same client with what it missed (or a snapshot) and sends what was made offline. The browser build joins (same messages through the browser's WebSocket API); `figcraft://` invite links open the desktop app and join. Styles, variables, collections, modes and comments made in a session take ids from their creator's space (the server checks it), so concurrent creations all survive; modes, per-mode values and replies inside one entry still resolve last-writer-wins. A standalone `figcraft-server` keeps many files, persists them (a snapshot plus a log, compacted), loses nothing confirmed across a restart (apps resume as the same participants) and takes files published from the app (File ▸ Publish to server…). The desktop app and the CLI open `wss://` links too (TLS through a reverse proxy), and releases ship the server for Linux with a deployment guide (systemd, Caddy, nginx, Docker) and `install.sh`. A server can be open to the public: anyone publishes without the key, within limits per address, and files nobody opens expire. Message limits follow the server's `max_file_mb`, so files above 64 MiB publish and open (the client takes at most 1 GiB from a server, and the server warns when large limits meet open publishing, #705); a damaged generation field in a file's log no longer drops the edits flushed to it (#687). Image entries are checked as they apply: a key must be a plain name (letters, digits, `-`, `_`; it becomes `images/` in a saved file), bytes under a content-hash key must hash to it (lowercase sha256), a side is at most 65 535 px and the decoded bytes at most 256 MiB (what the importers read), a key's bytes can't be replaced and an image a layer shows can't be removed, so no collaborator can poison the shared image store or balloon memory; snapshots and server logs from before these checks open without the images they refuse (#1086). The batches kept for apps that reconnect are bounded in bytes by default (64 MiB; `max_log_mb` on `figcraft-server`), and an app further behind catches up with a snapshot (#1069). Placing a layer among its siblings is a binary search, so large pastes and reorders in one batch no longer take quadratic time (#1065). A refused batch's fixes take time linear in the batch: each deleted layer is restored once and each entry fix looked up once (#1063). Left: server archives beyond Linux | 25–55 | +| Live cursors, selections, viewports; follow / observation mode; spotlight | yes | **done, in live sessions**: named cursors (one name for live sessions and comments: Preferences ▸ Your name, also in the Share popover and the Join dialog, kept in the browser too; a rename shows at once in the sessions you're in, #1380), selection outlines, avatars (an AI agent attached over MCP also shows its avatar and an outline around the layers it last edited or selected while it works, in or out of a session, #1416), follow (click an avatar), others' text carets and selected characters with their names, a note when two people type in one layer; spotlight (your avatar's menu, `collab.spotlight`): everyone follows your view and page, people who join later too, anyone who moves their view stops following and a banner offers to follow again, a newer spotlight takes over. Left: polish (no accounts, so anyone in the session can spotlight) | 2–4 | | Cursor chat, audio, high-fives | yes | **partial, in live sessions**: cursor chat (`/` opens a bubble at your pointer; everyone sees it as you type, it fades a few seconds after the last change, Enter sends and Escape takes it back, 100 characters; `collab.cursorChat` for agents); sent as ephemeral presence the host checks and rate-limits, never stored. No audio, high-fives or emote reactions | 2–5 | | Share links, permissions (view / edit), teams and projects | yes | **partial**: sharing with a join code from the Share popover (online: the file goes to the server in Preferences and the server names its edit, comment and view links with short codes; local network: the app hosts and the code carries its address; one field joins with a code or a link, in the popover and on the home screen, and the Join dialog then shows the join until its file is ready (Cancel), or why it failed (Try again, #1381); the browser build also joins from a link in its URL's fragment, `#join=…`, which never reaches the web host's logs, capped at 6 KiB and checked like a `figcraft://` link (#712; unit-tested, not run in a browser), and shares online and publishes to a server as the desktop app does, though not on the local network (#917; the owner token of a file it puts on an open server lasts as long as the page)); `figcraft-server` share links per file with edit, comment or view access (a viewer sees changes and presence, a "View only" badge and only the moving tools; a commenter also adds, replies to, reacts to and resolves comment threads, with a "Can comment" badge and the comment tool, #1525; the server refuses their other edits), revocable at once, tokens stored as hashes; managed from the app's Share popover (first links), the server's command line or a managing connection. On a server open to the public, whoever published a file manages it with its owner token (links, name, removal: `collab.manageFile`), which the app remembers: Home ▸ Online lists your online files (open again as owner, start and stop sharing, copy the code, rename, remove, forget), with one request per server for what became of them; anyone in a file can report it, and the operator blocks addresses. The person hosting a session, or a file's owner, removes someone from it (right-click their avatar, `collab.removePerson`): they are disconnected and their address can't join that file again while it runs. The list is per computer: no accounts, teams or projects | 8–17 | | Comments: pins on frames and layers, threads, reactions, @mentions, resolve, unread, search and filters, links, in the presenter | yes (shared) | **done, local**: stored in the file, your name from Preferences (an AI agent attached over MCP comments as its own name, marked Agent); a behaviour suite from the documentation (pinning and following layers, threads, own edits and deletes, mentions, reactions, C and Shift+C, stepping, filters, saving, copy and paste); restoring a version keeps them. `comment.list` (CLI, MCP) gives each message its `mentions` (`{name, known, you}`, by the highlighting rule). In a live session threads sync and anyone in it can reply, react and resolve (no accounts: a name is who you are) | (shared once multiplayer lands) | diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b3babfbe5..c17a3ec4f 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-10-30 · **Change:** minor (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); imported and pasted text finds an installed family under the other names its fonts give it, and with other case and spacing (#1292); `.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-10-30 · **Change:** minor (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 @@ -17,10 +17,7 @@ and prototype interactions are still unchecked there. | Format | Figma | FigCraft read | FigCraft write | Fidelity / testing | Hours | |---|---|---|---|---|---| -| `.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), 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). 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; 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). Not read: labels of connectors without a saved snapshot, 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 | -| `.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), 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, 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; 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). Not read: labels of connectors without a saved snapshot, 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 | -| `.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), 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). Budgets come from the file's own size (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), 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). Not read: labels of connectors without a saved snapshot, 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 | -| `.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), 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, 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 (one value per compressed byte, 20M–128M, lower ceilings on 32-bit targets; the layer cap follows), 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). Not read: labels of connectors without a saved snapshot, 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 | +| `.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), 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). Not read: labels of connectors without a saved snapshot, 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, 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 | — | diff --git a/docs/gaps.md b/docs/gaps.md index 970327e95..e2735e7c9 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (gap 6: plugin widgets have hover styles; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; gap 6: plugin widgets have hover styles; gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; 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; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; 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: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index f3d88bd52..2497de9d9 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware and platform parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); 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); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (`doc.list` gives AI agents and plugins the open files' names, not their paths (#1117); a batch keeps one undo snapshot, not one per step (#1099); one depth cap bounds a whole render, pattern sources included, and render threads get 8 MiB stacks (#1082); in the browser an internal error that stops the page keeps its unsaved files, which open again on reload (#1123); 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); large images on the canvas draw from a smaller copy, so two 16k images no longer lag (#1483); macOS: the Quit menu and ⌘Q ask about unsaved changes (#857); egui/eframe 0.37 dependency migration; hidden-window screenshot deadlines; zoom previews; hidden windows answer control requests; macOS file opening, the Windows start-up message, Linux packaging; copy and paste in the browser; Windows QA pass; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; pen pressure for the Pencil and brushes on Windows and from browser styluses; macOS: quitting ends the process once everything is kept, avoiding the Touch Bar teardown crash (#781); Linux and BSD clipboard: images under Wayland, reads time out; minimised windows under Wayland; window controls on Wayland without server decorations; canvas textures on device pixels) · **Target:** Figma Design (desktop app 126.10.7) How FigCraft uses hardware and which platforms it runs on, against Figma. Figma's desktop app is Electron with a GPU helper process; its canvas renders on the GPU (WebGL, with WebGPU rolling out @@ -40,8 +40,7 @@ Nothing has been timed against Figma on the same document (needs `.fig` import). | Platform | Figma | FigCraft | Notes | |---|---|---|---| | macOS | desktop app (Intel and Apple silicon) | universal `.dmg`, signed and notarized (v0.2.0) | where nearly all QA happens. Quitting ends the process once the preferences, recovery copies and live sessions are taken care of: the window's teardown crashed on Macs with a Touch Bar (#781); the Quit menu and ⌘Q ask about unsaved changes like the main window's close button (#857), and like it close the main window only: with several windows open, each quit closes one; a quit that never asked — killed or crashed — still leaves recovery copies for the next start | -| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. The portable zip keeps its settings in `config\` beside `figcraft.exe` (a `portable` file there), taking an installed copy's preferences on its first start (#1320) | -| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) | +| Windows x64 / arm64 | desktop app | `.msi` and portable zip for x64, x86 and arm64 | built in CI; Windows: QA pass done 2026-10-11, findings in PR. The portable zip keeps its settings in `config\` beside `figcraft.exe` (a `portable` file there), taking an installed copy's preferences on its first start (#1320). Draws with Direct3D 12 (FXC shaders) or OpenGL; when the graphics fail before the first frame it starts again on Vulkan, then OpenGL, then the power-saving GPU with lower limits (#965) | | Linux | none (browser only) | AppImage, `.deb`, `.rpm`, Flatpak, tarball, x86_64 and aarch64; riscv64 tarball | CI tests run here. Clipboard (also the BSDs): reads run on a helper thread that gives up after 2 s, and images copied in other apps paste under Wayland through the data-control protocol (#693). Under Wayland the app can't tell its window is minimised, so control requests that need a drawn frame may still wait there (#720). None of this run on a Linux desktop here. On a Wayland compositor without server-side decorations (GNOME) the window gets winit's Adwaita controls (#592: checked by its author in a GNOME Wayland session; KDE not run) | | FreeBSD | none | tarball | | | Web | primary (figma.com) | `figcraft-web` (WASM, WebGPU/WebGL2), web zip | no accessibility tree; file access through the browser; copy and paste through the system clipboard (copies written from the copy event, pastes read from the paste event; no pasting images from other apps); an internal error stops the page (a panic can't be caught there), but its unsaved files are kept in the page's local storage (about 5 MB) and open again on reload (#1123) | diff --git a/docs/localization-parity.md b/docs/localization-parity.md index 7d35cb49f..41e219196 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,6 +1,6 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (German terminology corrections (M10): gradient stops, OpenType stylistic sets and the Home-screen preference; partial review, catalog remains a draft; Arabic draft catalog, count-neutral plurals and web font gate; Thai draft catalog and Thai UI fonts; a missing translation fails no check: `cargo xtask i18n-report` lists them; Vietnamese draft catalog; string counts re-measured: 2,352 entries; Russian and Turkish catalogs reviewed by native speakers, Russian one/few/many plurals; Czech and Indonesian drafts; the Japanese draft completed; counts re-measured; Windows UI language from the first preferred language; draft catalogs catch up: every draft translates every string again; Simplified Chinese draft catalog; macOS finds its downloadable CJK system fonts (#406); string counts re-measured: 2,272 entries, the drafts at 95–99%; Swedish command labels and menu wordings; Russian, Swedish, Turkish, Czech and Indonesian listed; a duplicated paragraph removed) · **Target:** Figma Design (desktop app 126.10.7) FigCraft's interface has a localization layer with an English catalog, a Russian catalog reviewed by a native speaker, and Spanish (with a Latin American overlay), French, German, Brazilian diff --git a/docs/roadmap.md b/docs/roadmap.md index b5279d693..d7904ded8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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; compact layer trees with fields and outline (M0.9, part of #996); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); compact layer trees with fields and outline (M0.9, part of #996); stored variable and collection references keep their identity when another object is named like their id (follow-up to #1542); 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; 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); 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); 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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); 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; compact layer trees with fields and outline (M0.9, part of #996); 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); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); 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; 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -48,8 +48,7 @@ blockers. | Milestone | Status | Left | Hours | |---|---|---|---| | M0 Foundation: workspace, model, renderer, engine, UI shell, app, control channel | done (2026-10-09) | — | — | -| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform; a development example generates CLI completions and man pages, part of #1396) | Package installation of the generated files is still outstanding | — | -| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform). Added since: compact inspection with `fields` and `outline` (part of #996) | — | — | +| M0.9 CLI + MCP, web build, release packaging, PDF export | done (v0.2.0 released 2026-10-10 for every platform; a development example generates CLI completions and man pages, part of #1396). Added since: compact inspection with `fields` and `outline` (part of #996) | Package installation of the generated files is still outstanding | — | | M1 Core editing completeness (tools, panels, menus, clipboard, files) | ~77% | interaction pass (Open recent, the outline mode variants and keyboard box selection landed 2026-10-11; repeat commands preserve the underlying edit and undo steps, #181; the Layout panel's constrain-proportions link scales the other side, #220) | 6–12 + pass | | M2 Layout depth | ~80% | grid `fr` rows, real-file QA | 6–12 | | M3 Components & styles depth | ~78% (extended collections, variables import/export, gradient stop and variant bindings landed 2026-10-11; stored references keep exact variable and collection ids) | real libraries, component playground | 7–14 | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 6889c0f74..344d6d486 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-10-30 · **Change:** minor (plugin widgets have hover styles: a layer's `hover` fill, stroke, opacity or text colour shows while the pointer is over it (gap 6); the browser build uses the fonts installed on the computer once asked, each read when text uses it (#1051); 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); 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; 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; 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); 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) · **Target:** Figma Design (desktop app 126.10.7, macOS) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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 diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 9152466ee..b46288714 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-10-30 · **Change:** minor (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; 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; 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; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; 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) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (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: diff --git a/xtask/src/docs_check.rs b/xtask/src/docs_check.rs new file mode 100644 index 000000000..72d268224 --- /dev/null +++ b/xtask/src/docs_check.rs @@ -0,0 +1,106 @@ +//! Progress docs damaged by merges: a table row that appears twice, or a status line (`**Change:** +//! kind (…; …)`) that lists the same item twice. Both happen when a merge keeps both sides of a +//! conflict; they are never wanted. + +use std::collections::HashMap; +use std::path::Path; + +/// Checks `ROADMAP.md` and `docs/*.md` under `root`; prints each repeat and returns whether there +/// were none. +pub fn check(root: &Path) -> bool { + let mut files = vec![root.join("ROADMAP.md")]; + if let Ok(rd) = std::fs::read_dir(root.join("docs")) { + let mut docs: Vec<_> = rd.flatten().map(|e| e.path()).filter(|p| p.extension().is_some_and(|e| e == "md")).collect(); + docs.sort(); + files.extend(docs); + } + let mut found = Vec::new(); + for f in &files { + let Ok(text) = std::fs::read_to_string(f) else { continue }; + let name = f.strip_prefix(root).map(|r| r.to_string_lossy().replace('\\', "/")).unwrap_or_default(); + found.extend(repeats(&text).into_iter().map(|r| format!("{name}:{r}"))); + } + if found.is_empty() { + eprintln!("docs: ok"); + return true; + } + eprintln!("docs: repeated rows or status items (left by a merge?):\n {}", found.join("\n ")); + false +} + +/// `line: what` for each table row that repeats an earlier one (header rows apart: tables share +/// them) and each status-line item listed twice. +fn repeats(text: &str) -> Vec { + let lines: Vec<&str> = text.lines().collect(); + let separator = |l: &str| l.starts_with('|') && l.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ')); + let mut out = Vec::new(); + let mut rows: HashMap<&str, usize> = HashMap::new(); + for (i, l) in lines.iter().enumerate() { + let header = lines.get(i + 1).is_some_and(|n| separator(n)); + if l.starts_with('|') && !separator(l) && !header { + match rows.get(l) { + Some(first) => out.push(format!("{}: the table row of line {first} again", i + 1)), + None => { + rows.insert(l, i + 1); + } + } + } + if let Some(body) = status_items(l) { + let mut seen = Vec::new(); + for item in body { + if seen.contains(&item) { + out.push(format!("{}: status item listed twice: {}", i + 1, item.chars().take(100).collect::())); + } else { + seen.push(item); + } + } + } + } + out +} + +/// The items of a status line's `**Change:** kind (…)`, split at `; ` outside brackets. `None` +/// for other lines, or when the brackets never close. +fn status_items(line: &str) -> Option> { + let open = line.find("**Change:**").and_then(|i| line[i..].find('(').map(|o| i + o))?; + let mut depth = 0usize; + let mut items = Vec::new(); + let mut start = open + 1; + for (j, c) in line.char_indices().skip_while(|(j, _)| *j < open) { + match c { + '(' | '[' => depth += 1, + ')' | ']' => { + depth = depth.saturating_sub(1); + if depth == 0 { + items.push(&line[start..j]); + return Some(items); + } + } + ';' if depth == 1 && line[j..].starts_with("; ") => { + items.push(&line[start..j]); + start = j + 2; + } + _ => {} + } + } + None +} + +#[cfg(test)] +mod tests { + use super::repeats; + + #[test] + fn repeated_rows_and_status_items_are_found_and_headers_are_not() { + let doc = "**Change:** minor (a (x; y); b; a (x; y)) · **Target:** t\n\n\ + | Item | Status |\n|---|---|\n| one | done |\n| two | done |\n\n\ + | Item | Status |\n|---|---|\n| one | done |\n"; + let found = repeats(doc); + assert_eq!(found.len(), 2, "{found:?}"); + assert!(found[0].starts_with("1: status item listed twice: a (x; y)"), "{found:?}"); + assert!(found[1].starts_with("10: the table row of line 5 again"), "{found:?}"); + // Distinct items, and a parenthesis that never closes, are left alone. + assert!(repeats("**Change:** minor (a; b (c; c)) · x\n| a | b |\n| a | c |\n").is_empty()); + assert!(repeats("**Change:** minor (a; a (never closed\n").is_empty()); + } +} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 2b389f9f0..7ff4bba4f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -1,11 +1,14 @@ //! Project tasks: `cargo xtask `. //! -//! - `ci` fmt check, clippy -D warnings, tests, the translation report, layers, assets, wasm check +//! - `ci` fmt check, clippy -D warnings, tests, the translation report, layers, assets, docs, wasm +//! check //! - `test` the workspace tests (nextest when installed) and doctests //! - `i18n-report` the UI strings, command labels and menu wordings each language lacks (never fails //! on them: a missing translation reads English) //! - `layers` crate dependency layering check //! - `assets` every file under assets/ and docs/ has a row in ATTRIBUTION.md +//! - `docs` no table row twice and no status-line item listed twice in ROADMAP.md and docs/*.md +//! (what a merge that keeps both sides leaves) //! - `parity` menu-catalog coverage → docs/parity-checklist.md //! - `version` print the version, or `version set X.Y.Z` //! - `icon` regenerate the app icon PNGs/ICO from assets/app-icon/figcraft.svg (needs resvg) @@ -16,6 +19,7 @@ //! - `bench` release-build timings at 1k/10k/50k layers (`bench 1000,10000` for other sizes) and //! with a synthetic design system used by 2,000 instances +mod docs_check; mod icon; mod layers; @@ -43,6 +47,7 @@ fn main() -> ExitCode { "i18n-report" => i18n_report(), "layers" => layers::check(&root()), "assets" => assets(), + "docs" => docs_check::check(&root()), "parity" => parity(), "version" => version(args.get(1).map(String::as_str), args.get(2).map(String::as_str)), "icon" => icon::generate(&root()), @@ -50,7 +55,7 @@ fn main() -> ExitCode { "plugins" => plugins(), "bench" => bench(args.get(1).map_or("1000,10000,50000", String::as_str)), _ => { - eprintln!("tasks: ci, test, i18n-report, layers, assets, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]"); + eprintln!("tasks: ci, test, i18n-report, layers, assets, docs, parity, version [set X.Y.Z], icon, wasm, plugins, bench [SIZES]"); task == "help" } }; @@ -64,6 +69,7 @@ fn ci() -> bool { && i18n_report() && layers::check(&root()) && assets() + && docs_check::check(&root()) && wasm() } From 4965620d77995a33560b45060a141de881e3b52a Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 18:54:24 +0700 Subject: [PATCH 33/34] M6: pasted layers keep the colours of their shared paint styles (#1647) * M6: pasted layers keep the colours of their shared paint styles (#1638) A clipboard links a style by the guid it has in its library, which it doesn't hold, and by its library asset, whose copy it does hold. Mapper::resource_id now takes a guid only when the file holds a node by it, else the asset, so .fig import and paste agree; a link to a style that really isn't there keeps the layer's stored colours, with a note. * M6: Swedish catalog: keep both new import notes after the merge --- ROADMAP.md | 3 +- crates/format/src/fig.rs | 2 + crates/format/src/fig/map.rs | 5 +- crates/format/src/fig/map/paint.rs | 13 +++- crates/format/src/fig/paste_style_tests.rs | 82 ++++++++++++++++++++++ crates/format/src/fig/tests.rs | 4 +- crates/ui-egui/locales/ar.toml | 1 + crates/ui-egui/locales/bn.toml | 1 + crates/ui-egui/locales/cs.toml | 1 + crates/ui-egui/locales/de.toml | 1 + crates/ui-egui/locales/en.toml | 1 + crates/ui-egui/locales/es.toml | 1 + crates/ui-egui/locales/fi.toml | 1 + crates/ui-egui/locales/fr.toml | 1 + crates/ui-egui/locales/id.toml | 1 + crates/ui-egui/locales/it.toml | 1 + crates/ui-egui/locales/ja.toml | 1 + crates/ui-egui/locales/ka.toml | 1 + crates/ui-egui/locales/ko.toml | 1 + crates/ui-egui/locales/nl.toml | 1 + crates/ui-egui/locales/pl.toml | 1 + crates/ui-egui/locales/pt-BR.toml | 1 + crates/ui-egui/locales/ru.toml | 1 + crates/ui-egui/locales/sv.toml | 1 + crates/ui-egui/locales/th.toml | 1 + crates/ui-egui/locales/tr.toml | 1 + crates/ui-egui/locales/vi.toml | 1 + crates/ui-egui/locales/zh-Hans.toml | 1 + docs/file-format-parity.md | 5 +- docs/roadmap.md | 3 +- 30 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 crates/format/src/fig/paste_style_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 791d9f152..d66478a40 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, #1638: layers pasted from another design app lost the colours of their shared paint styles: a component's glyphs, and the same glyphs in its instances, came in black. The clipboard links each style by the guid it has in the library it came from, which the clipboard doesn't hold, as well as by its library asset, whose copy it does hold; the `.fig` mapping took the guid and found nothing. Now a guid names a style, variable collection or other resource only when the file holds a node by it, and otherwise the asset it also names finds the file's copy, so `.fig` import and paste agree. A link to a style the file really doesn't hold keeps the colours stored with the layer, as before, now with a note. Tests: `paste_style_tests.rs` (design data built in code, read as paste reads it). | | 2026-10-30 | minor | Docs (found while landing #1649): merges that kept both sides of a conflict had left rows and status items twice. One row each now for `.fig` in `docs/file-format-parity.md` (four copies: #1234, #1236 and the #1076 ZIP budget kept, with the newer four-values-per-byte budget wording and #1649's connector routing), Real-time co-editing (three: #1069, #1065, #1063) and Live cursors (two: #1380, #1416) in `docs/collaboration-parity.md`, Windows x64 / arm64 in `docs/hardware-parity.md` (#1320, #965) and M0.9 in `docs/roadmap.md` (#1396, #996). The status lines of this file, `docs/roadmap.md`, `docs/gaps.md`, `docs/target-app-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md` and `docs/file-format-parity.md` drop items listed more than once (302 in all, the first kept), and an unclosed parenthesis in target-app-parity's status line is closed. No wording changed otherwise. New `cargo xtask docs`, run by `ci`: a table row that repeats an earlier one (header rows apart) or a status item listed twice fails it. Numbers unchanged. Test: `docs_check::tests`. | | 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. | | 2026-10-30 | minor | M6, gap 6 (first slice): plugin widgets have hover styles, as the reference tool's widgets do. Any layer of a widget description, the widget itself too, may have `hover: {fill?, stroke?, opacity?, color? (text)}`, checked like the rest; on the canvas, while the pointer is over such a layer, it shows that style as a live preview (the text-preview mechanism: no undo step, never saved or sent to a live session), and moving away, leaving the canvas or any command (a click on the part acts on the document itself) puts it back; another live preview showing isn't replaced. `widget.hover {ids}` does the same for scripts and agents. No new plugin API: older FigCraft draws the layer as it is. The Counter example's buttons darken under the pointer. Timers and panels from widgets are still missing. Numbers unchanged. Tests: `widget_hover_tests`, `widget_tests::hover_styles_are_checked_and_normalised`. | diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index dfc961bf5..6b2f75f96 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -32,6 +32,8 @@ mod layer_cap_tests; mod map; mod network; #[cfg(test)] +mod paste_style_tests; +#[cfg(test)] mod prop_value_tests; #[cfg(test)] mod slot_tests; diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index ce3ffe16a..9db65e02a 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -351,7 +351,10 @@ impl<'a> Mapper<'a> { /// the reference has neither or the asset names nothing (or ambiguously) in the file. /// Original code for this importer: the field names are those of the schema each file embeds. fn resource_id(&self, v: &Kv) -> Option { - if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE) { + // A guid names the resource when this file holds a node by it. One from another file (the + // library a style or collection came from, as a paste carries) names nothing here: the + // asset it also names finds the file's copy (#1638). + if let Some(g) = v.get("guid").and_then(guid).filter(|g| *g != NO_STYLE && self.nodes.contains_key(g)) { return Some(g); } let asset = v.get("assetRef")?; diff --git a/crates/format/src/fig/map/paint.rs b/crates/format/src/fig/map/paint.rs index 7b2c56f52..5b7121757 100644 --- a/crates/format/src/fig/map/paint.rs +++ b/crates/format/src/fig/map/paint.rs @@ -33,10 +33,17 @@ impl<'a> Mapper<'a> { /// The style node a layer links in `key` (or the older `legacy` field), recorded for the REST /// `styles` map under `slot`. + /// A link to a style the file doesn't hold leaves the layer its own values (the colours it + /// stored with it), with a note. pub(super) fn style(&mut self, v: &View<'a>, key: &str, legacy: &str, slot: &str, out: &mut Obj) -> Option<&'a Kv<'a>> { - let id = self.style_link(v, key, legacy)?; - let node = *self.nodes.get(&id)?; - node.get("styleType").and_then(Kv::str)?; + let found = self.style_link(v, key, legacy).and_then(|id| Some((self.nodes.get(&id).copied()?, id))); + let Some((node, id)) = found.filter(|(n, _)| n.get("styleType").and_then(Kv::str).is_some()) else { + let named = |l: &Kv| l.get("assetRef").is_some() || l.get("guid").and_then(guid).is_some_and(|g| g != NO_STYLE); + if v.get(key).is_some_and(named) || v.get(legacy).and_then(guid).is_some_and(|g| g != NO_STYLE) { + self.warn("links to shared styles that aren't in the file kept the layers' own values"); + } + return None; + }; out.insert(slot.into(), json!(id)); if !self.styles.contains_key(&id) { let name = node.get("name").and_then(Kv::str).unwrap_or("Style"); diff --git a/crates/format/src/fig/paste_style_tests.rs b/crates/format/src/fig/paste_style_tests.rs new file mode 100644 index 000000000..31cb013da --- /dev/null +++ b/crates/format/src/fig/paste_style_tests.rs @@ -0,0 +1,82 @@ +//! Layers pasted from another design app whose paints come from a shared paint style (#1638): the +//! clipboard links the style by the guid it has in the library it came from, which this payload +//! doesn't hold, and by its library asset, whose copy it does hold. The layers (a component's +//! glyph and the same glyph in an instance of it) take the style's colour and stay linked to it. +//! A link to a style the payload doesn't hold at all keeps the colour stored with the layer, with +//! a note. Built in code and read as `edit.pasteDesignLayers` reads a clipboard's design data. + +use figcraft_doc::{Document, Node}; + +use super::kiwi::Kv; +use super::tests::{arr, canvas, e, g, node, o, s, schema, solid, xy}; +use super::try_import; + +/// A style link by `guid` (absent from the payload) and, given, the library asset `key`. +fn link(guid: u64, asset: Option<&str>) -> Kv<'static> { + let mut fields = vec![("guid", g(guid))]; + if let Some(key) = asset { + fields.push(("assetRef", o(vec![("key", s(key)), ("version", s("7"))]))); + } + o(fields) +} + +/// The pasted payload: on the internal page, the file's copy of library style "Red" (#FF4040); +/// on the page, component "Icon" whose "Glyph" stores stale black and links the style, an +/// instance of it, and "Lost", which stores #1F1F1F and links a style the payload doesn't hold. +fn pasted() -> (Document, Vec) { + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node(2, Some((0, "~")), "CANVAS", "Internal", vec![("internalOnly", Kv::Bool(true))]), + node( + 10, + Some((2, "!")), + "RECTANGLE", + "Red", + vec![("styleType", e("FILL")), ("key", s("lib-red")), ("version", s("7")), ("fillPaints", arr(vec![solid(1.0, 0.25, 0.25)]))], + ), + node(20, Some((1, "!")), "SYMBOL", "Icon", vec![("size", xy(24.0, 24.0))]), + node( + 21, + Some((20, "!")), + "RECTANGLE", + "Glyph", + vec![("size", xy(16.0, 16.0)), ("fillPaints", arr(vec![solid(0.0, 0.0, 0.0)])), ("styleIdForFill", link(99, Some("lib-red")))], + ), + node(30, Some((1, "\"")), "INSTANCE", "Icon copy", vec![("size", xy(24.0, 24.0)), ("symbolData", o(vec![("symbolID", g(20))]))]), + node( + 40, + Some((1, "#")), + "RECTANGLE", + "Lost", + vec![("size", xy(16.0, 16.0)), ("fillPaints", arr(vec![solid(0.12, 0.12, 0.12)])), ("styleIdForFill", link(98, None))], + ), + ]; + let bytes = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])); + try_import(&bytes, "Pasted layers").expect("design data").expect("it imports") +} + +fn fill_hex(n: &Node) -> Option { + n.fills.first()?.solid_color().map(|c| c.to_hex()) +} + +#[test] +fn a_library_paint_style_colours_the_component_and_its_instance() { + let (doc, _) = pasted(); + let glyphs: Vec<&Node> = doc.nodes().filter(|n| n.name == "Glyph").collect(); + assert_eq!(glyphs.len(), 2, "the component's glyph and the instance's"); + for glyph in glyphs { + assert_eq!(fill_hex(glyph).as_deref(), Some("FF4040"), "the style's colour, not the stale black"); + let style = glyph.styles.fill.as_deref().and_then(|id| doc.style(id)).expect("linked to the style"); + assert_eq!(style.name, "Red"); + } +} + +#[test] +fn a_style_the_paste_doesnt_hold_keeps_the_stored_colour() { + let (doc, warnings) = pasted(); + let lost = doc.nodes().find(|n| n.name == "Lost").unwrap(); + assert_eq!(fill_hex(lost).as_deref(), Some("1F1F1F"), "the colour stored with the layer, not black"); + assert!(lost.styles.fill.is_none()); + assert!(warnings.iter().any(|w| w.contains("shared styles that aren't in the file")), "{warnings:?}"); +} diff --git a/crates/format/src/fig/tests.rs b/crates/format/src/fig/tests.rs index 5a4b7d702..657f2f81d 100644 --- a/crates/format/src/fig/tests.rs +++ b/crates/format/src/fig/tests.rs @@ -59,7 +59,7 @@ message TextData string characters, uint[] characterStyleIDs, NodeChange[] style message Path WindingRule windingRule, uint commandsBlob message GUIDPath GUID[] guids message SymbolData GUID symbolID, NodeChange[] symbolOverrides -message StyleId GUID guid +message StyleId GUID guid, AssetRef assetRef message PropValue bool boolValue, TextData textValue message PropDef GUID id, string name, PropValue initialValue, PropType type message PropAssignment GUID defID, PropValue value @@ -92,7 +92,7 @@ pub(super) fn f(x: f64) -> Kv<'static> { pub(super) fn u(x: u64) -> Kv<'static> { Kv::Uint(x) } -fn e(n: &'static str) -> Kv<'static> { +pub(super) fn e(n: &'static str) -> Kv<'static> { Kv::Enum(n) } pub(super) fn s(x: impl Into) -> Kv<'static> { diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index d0413f0d1..b47e4f420 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -1729,6 +1729,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "وُضعت الطبقات التي ليس لها موضع عند نقطة أصل العنصر الأب" "layers without a type were skipped" = "تم تخطي الطبقات التي ليس لها نوع" "legacy image filter container is unsupported; filter conversion was skipped" = "حاوية مرشحات الصورة القديمة غير مدعومة؛ تم تخطي تحويل المرشحات" +"links to shared styles that aren't in the file kept the layers' own values" = "احتفظت الروابط إلى أنماط مشتركة غير موجودة في الملف بقيم الطبقات نفسها" "locked" = "مقفل" "malformed image filters container was skipped" = "تم تخطي حاوية مرشحات الصورة غير السليمة" "malformed image paintFilter container was skipped" = "تم تخطي حاوية paintFilter غير السليمة للصورة" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index cbbaa054d..58be29aaa 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -1671,6 +1671,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "অবস্থান ছাড়া লেয়ারগুলো তাদের প্যারেন্টের মূল অবস্থানে রাখা হয়েছে" "layers without a type were skipped" = "ধরন ছাড়া লেয়ারগুলো বাদ দেওয়া হয়েছে" "legacy image filter container is unsupported; filter conversion was skipped" = "লিগ্যাসি ইমেজ ফিল্টার কন্টেইনার অসমর্থিত; ফিল্টার রূপান্তর বাদ দেওয়া হয়েছে" +"links to shared styles that aren't in the file kept the layers' own values" = "ফাইলে নেই এমন শেয়ার করা স্টাইলের লিংক থাকা লেয়ারগুলো নিজেদের মান রেখেছে" "locked" = "লক করা" "malformed image filters container was skipped" = "ত্রুটিপূর্ণ ইমেজ ফিল্টার কন্টেইনার বাদ দেওয়া হয়েছে" "malformed image paintFilter container was skipped" = "ত্রুটিপূর্ণ ইমেজ paintFilter কন্টেইনার বাদ দেওয়া হয়েছে" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 713efa59b..369cad32d 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -1636,6 +1636,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "vrstvy bez pozice byly umístěny do počátku nadřazeného prvku" "layers without a type were skipped" = "vrstvy bez typu byly přeskočeny" "legacy image filter container is unsupported; filter conversion was skipped" = "starší kontejner filtrů obrázku není podporován; převod filtrů byl přeskočen" +"links to shared styles that aren't in the file kept the layers' own values" = "odkazy na sdílené styly, které v souboru nejsou, ponechaly vrstvám jejich vlastní hodnoty" "locked" = "zamknuté" "malformed image filters container was skipped" = "poškozený kontejner filtrů obrázku byl přeskočen" "malformed image paintFilter container was skipped" = "poškozený kontejner paintFilter obrázku byl přeskočen" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index dea591ac6..ef2d925cd 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -1596,6 +1596,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "Ebenen ohne Position wurden am Ursprung ihres übergeordneten Elements platziert" "layers without a type were skipped" = "Ebenen ohne Typ wurden übersprungen" "legacy image filter container is unsupported; filter conversion was skipped" = "der veraltete Bildfiltercontainer wird nicht unterstützt; die Filterumwandlung wurde übersprungen" +"links to shared styles that aren't in the file kept the layers' own values" = "Verweise auf gemeinsame Stile, die nicht in der Datei sind, behielten die eigenen Werte der Ebenen" "locked" = "gesperrt" "malformed image filters container was skipped" = "fehlerhafter Bildfiltercontainer wurde übersprungen" "malformed image paintFilter container was skipped" = "fehlerhafter paintFilter-Container des Bildes wurde übersprungen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 3955b7861..1eb58baeb 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1751,6 +1751,7 @@ name = "English" "layers without a type were skipped" = "layers without a type were skipped" "legacy image filter container is unsupported; filter conversion was skipped" = "legacy image filter container is unsupported; filter conversion was skipped" "linked vector region fill styles keep resolved appearance; live style links were left out" = "linked vector region fill styles keep resolved appearance; live style links were left out" +"links to shared styles that aren't in the file kept the layers' own values" = "links to shared styles that aren't in the file kept the layers' own values" "locked" = "locked" "malformed image filters container was skipped" = "malformed image filters container was skipped" "malformed image paintFilter container was skipped" = "malformed image paintFilter container was skipped" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 33ad0a338..911aaa0e5 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -1717,6 +1717,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "las capas sin posición se colocaron en el origen de su elemento padre" "layers without a type were skipped" = "se omitieron las capas sin tipo" "legacy image filter container is unsupported; filter conversion was skipped" = "el contenedor antiguo de filtros de imagen no es compatible; se omitió la conversión de filtros" +"links to shared styles that aren't in the file kept the layers' own values" = "los vínculos a estilos compartidos que no están en el archivo conservaron los valores propios de las capas" "locked" = "bloqueada" "malformed image filters container was skipped" = "se omitió el contenedor de filtros de imagen mal formado" "malformed image paintFilter container was skipped" = "se omitió el contenedor paintFilter de imagen mal formado" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index 0601e948f..cad77ccc5 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -1676,6 +1676,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "tasot, joilta puuttuu sijainti, sijoitettiin emotasonsa origoon" "layers without a type were skipped" = "tasot, joilta puuttuu tyyppi, ohitettiin" "legacy image filter container is unsupported; filter conversion was skipped" = "vanhaa kuvasuodinsäilöä ei tueta; suotimien muunnos ohitettiin" +"links to shared styles that aren't in the file kept the layers' own values" = "linkit jaettuihin tyyleihin, joita tiedostossa ei ole, säilyttivät tasojen omat arvot" "locked" = "lukittu" "malformed image filters container was skipped" = "virheellinen image filters -säilö ohitettiin" "malformed image paintFilter container was skipped" = "virheellinen image paintFilter -säilö ohitettiin" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index f91c48697..32d6db95b 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -1679,6 +1679,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "les calques sans position ont été placés à l’origine de leur parent" "layers without a type were skipped" = "les calques sans type ont été ignorés" "legacy image filter container is unsupported; filter conversion was skipped" = "l’ancien conteneur de filtres d’image n’est pas pris en charge ; la conversion des filtres a été ignorée" +"links to shared styles that aren't in the file kept the layers' own values" = "les liens vers des styles partagés absents du fichier ont gardé les valeurs propres des calques" "locked" = "verrouillé" "malformed image filters container was skipped" = "le conteneur de filtres d’image mal formé a été ignoré" "malformed image paintFilter container was skipped" = "le conteneur paintFilter d’image mal formé a été ignoré" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index b587ea7cd..add2fe514 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -1640,6 +1640,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "layer tanpa posisi ditempatkan di titik asal induknya" "layers without a type were skipped" = "layer tanpa tipe dilewati" "legacy image filter container is unsupported; filter conversion was skipped" = "kontainer filter gambar versi lama tidak didukung; konversi filter dilewati" +"links to shared styles that aren't in the file kept the layers' own values" = "tautan ke gaya bersama yang tidak ada di file mempertahankan nilai lapisan itu sendiri" "locked" = "terkunci" "malformed image filters container was skipped" = "kontainer filter gambar yang rusak dilewati" "malformed image paintFilter container was skipped" = "kontainer paintFilter gambar yang rusak dilewati" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index bb4ce648e..e4530f004 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -1614,6 +1614,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "i livelli senza posizione sono stati posti all'origine del loro genitore" "layers without a type were skipped" = "i livelli senza tipo sono stati saltati" "legacy image filter container is unsupported; filter conversion was skipped" = "il contenitore di filtri immagine legacy non è supportato; la conversione dei filtri è stata saltata" +"links to shared styles that aren't in the file kept the layers' own values" = "i collegamenti a stili condivisi che non sono nel file hanno mantenuto i valori propri dei livelli" "locked" = "bloccato" "malformed image filters container was skipped" = "il contenitore di filtri immagine malformato è stato saltato" "malformed image paintFilter container was skipped" = "il contenitore paintFilter dell'immagine malformato è stato saltato" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index ad11beb9e..b1bfc0fe6 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -1660,6 +1660,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "位置のないレイヤーは親の原点に配置" "layers without a type were skipped" = "タイプのないレイヤーをスキップ" "legacy image filter container is unsupported; filter conversion was skipped" = "旧形式の画像フィルターコンテナは未対応のため、フィルターの変換をスキップ" +"links to shared styles that aren't in the file kept the layers' own values" = "ファイルにない共有スタイルへのリンクは、レイヤー自身の値を保持しました" "locked" = "ロック中" "malformed image filters container was skipped" = "不正な画像フィルターコンテナをスキップ" "malformed image paintFilter container was skipped" = "不正な画像の paintFilter コンテナをスキップ" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index 18637cbc7..a5919ad87 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -1697,6 +1697,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "layers without a position were placed at their parent's origin" = "პოზიციის გარეშე შრეები მშობლის საწყის წერტილში განთავსდა" "layers without a type were skipped" = "ტიპის გარეშე შრეები გამოტოვებულია" "legacy image filter container is unsupported; filter conversion was skipped" = "გამოსახულების ფილტრების ძველი კონტეინერი მხარდაუჭერელია; ფილტრების გარდაქმნა გამოტოვებულია" +"links to shared styles that aren't in the file kept the layers' own values" = "ფაილში არარსებულ საერთო სტილებზე ბმულებმა შეინარჩუნეს ფენების საკუთარი მნიშვნელობები" "locked" = "ჩაკეტილი" "malformed image filters container was skipped" = "გამოსახულების ფილტრების დაზიანებული კონტეინერი გამოტოვებულია" "malformed image paintFilter container was skipped" = "გამოსახულების paintFilter-ის დაზიანებული კონტეინერი გამოტოვებულია" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index dd5a8b7a3..499d83841 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -1587,6 +1587,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "위치가 없는 레이어는 상위 요소의 원점에 배치됨" "layers without a type were skipped" = "유형이 없는 레이어를 건너뜀" "legacy image filter container is unsupported; filter conversion was skipped" = "레거시 이미지 필터 컨테이너는 지원되지 않아 필터 변환을 건너뜀" +"links to shared styles that aren't in the file kept the layers' own values" = "파일에 없는 공유 스타일에 대한 링크는 레이어 자체 값을 유지했습니다" "locked" = "잠김" "malformed image filters container was skipped" = "잘못된 형식의 이미지 필터 컨테이너를 건너뜀" "malformed image paintFilter container was skipped" = "잘못된 형식의 이미지 paintFilter 컨테이너를 건너뜀" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index 0561c950e..bdca1ff9e 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -1676,6 +1676,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "lagen zonder positie zijn op de oorsprong van het bovenliggende element geplaatst" "layers without a type were skipped" = "lagen zonder type zijn overgeslagen" "legacy image filter container is unsupported; filter conversion was skipped" = "Verouderde container voor beeldfilters wordt niet ondersteund; filterconversie is overgeslagen" +"links to shared styles that aren't in the file kept the layers' own values" = "koppelingen naar gedeelde stijlen die niet in het bestand staan, hielden de eigen waarden van de lagen" "locked" = "vergrendeld" "malformed image filters container was skipped" = "misvormde container met afbeeldingsfilters is overgeslagen" "malformed image paintFilter container was skipped" = "onjuist gevormde image paintFilter-container is overgeslagen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index e95707fc0..d77c05159 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -1686,6 +1686,7 @@ name = "Polski" "layers without a position were placed at their parent's origin" = "warstwy bez określonej pozycji zostały umieszczone w punkcie początkowym rodzica" "layers without a type were skipped" = "warstwy bez typu zostały pominięte" "legacy image filter container is unsupported; filter conversion was skipped" = "przestarzały kontener filtrów obrazu jest nieobsługiwany; konwersja filtrów została pominięta" +"links to shared styles that aren't in the file kept the layers' own values" = "odwołania do współdzielonych stylów, których nie ma w pliku, zachowały własne wartości warstw" "locked" = "Zablokowane" "malformed image filters container was skipped" = "nieprawidłowy kontener filtrów obrazu został pominięty" "malformed image paintFilter container was skipped" = "nieprawidłowy kontener filtrów malowania (paintFilter) został pominięty" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 00e030ea9..98e787a13 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -1585,6 +1585,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "camadas sem posição foram colocadas na origem do elemento pai" "layers without a type were skipped" = "camadas sem tipo foram ignoradas" "legacy image filter container is unsupported; filter conversion was skipped" = "o contêiner legado de filtros de imagem não é compatível; a conversão dos filtros foi ignorada" +"links to shared styles that aren't in the file kept the layers' own values" = "vínculos para estilos compartilhados que não estão no arquivo mantiveram os valores próprios das camadas" "locked" = "bloqueada" "malformed image filters container was skipped" = "o contêiner de filtros de imagem malformado foi ignorado" "malformed image paintFilter container was skipped" = "o contêiner paintFilter de imagem malformado foi ignorado" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index e6f3e0224..8aa5404b9 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -1686,6 +1686,7 @@ name = "Русский" "layers without a position were placed at their parent's origin" = "слои без положения размещены в начале координат родителя" "layers without a type were skipped" = "слои без типа пропущены" "legacy image filter container is unsupported; filter conversion was skipped" = "устаревший контейнер фильтров изображения не поддерживается; преобразование фильтров пропущено" +"links to shared styles that aren't in the file kept the layers' own values" = "ссылки на общие стили, которых нет в файле, сохранили собственные значения слоёв" "locked" = "заблокировано" "malformed image filters container was skipped" = "повреждённый контейнер фильтров изображения пропущен" "malformed image paintFilter container was skipped" = "повреждённый контейнер paintFilter изображения пропущен" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 5d3afad2e..932080ae3 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -1748,6 +1748,7 @@ name = "Svenska" "layers without a type were skipped" = "lager utan typ hoppades över" "legacy image filter container is unsupported; filter conversion was skipped" = "den äldre bildfilterbehållaren stöds inte; filterkonverteringen hoppades över" "linked vector region fill styles keep resolved appearance; live style links were left out" = "länkade fyllningsstilar i vektorregioner behåller sitt beräknade utseende; levande stillänkar utelämnades" +"links to shared styles that aren't in the file kept the layers' own values" = "länkar till delade stilar som inte finns i filen behöll lagrens egna värden" "locked" = "låst" "malformed image filters container was skipped" = "en felaktig bildfilterbehållare hoppades över" "malformed image paintFilter container was skipped" = "en felaktig paintFilter-behållare för bild hoppades över" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 2225fe527..9e7f746f7 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -1665,6 +1665,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "เลเยอร์ที่ไม่มีตำแหน่งถูกวางที่จุดกำเนิดของตัวแม่" "layers without a type were skipped" = "ข้ามเลเยอร์ที่ไม่มีชนิด" "legacy image filter container is unsupported; filter conversion was skipped" = "ไม่รองรับคอนเทนเนอร์ตัวกรองรูปภาพแบบเก่า: ข้ามการแปลงตัวกรอง" +"links to shared styles that aren't in the file kept the layers' own values" = "ลิงก์ไปยังสไตล์ที่ใช้ร่วมกันซึ่งไม่มีในไฟล์ใช้ค่าของเลเยอร์เอง" "locked" = "ล็อกอยู่" "malformed image filters container was skipped" = "ข้ามคอนเทนเนอร์ตัวกรองรูปภาพที่รูปแบบผิด" "malformed image paintFilter container was skipped" = "ข้ามคอนเทนเนอร์ paintFilter ของรูปภาพที่รูปแบบผิด" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index b4176d833..8d6e1ede3 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -1640,6 +1640,7 @@ name = "Türkçe" "layers without a position were placed at their parent's origin" = "konumu olmayan katmanlar üst öğelerinin başlangıç noktasına yerleştirildi" "layers without a type were skipped" = "türü olmayan katmanlar atlandı" "legacy image filter container is unsupported; filter conversion was skipped" = "eski görüntü filtresi kapsayıcısı desteklenmiyor; filtre dönüştürme atlandı" +"links to shared styles that aren't in the file kept the layers' own values" = "dosyada olmayan paylaşılan stillere bağlantılar katmanların kendi değerlerini korudu" "locked" = "kilitli" "malformed image filters container was skipped" = "bozuk görüntü filtreleri kapsayıcısı atlandı" "malformed image paintFilter container was skipped" = "bozuk görüntü paintFilter kapsayıcısı atlandı" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 392f6a059..5e261e0ac 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -1615,6 +1615,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "các lớp không có vị trí đã được đặt tại gốc tọa độ của lớp cha" "layers without a type were skipped" = "đã bỏ qua các lớp không có loại" "legacy image filter container is unsupported; filter conversion was skipped" = "vùng chứa bộ lọc hình ảnh kiểu cũ không được hỗ trợ; đã bỏ qua chuyển đổi bộ lọc" +"links to shared styles that aren't in the file kept the layers' own values" = "các liên kết đến kiểu dùng chung không có trong tệp đã giữ giá trị riêng của các lớp" "locked" = "đã khóa" "malformed image filters container was skipped" = "đã bỏ qua vùng chứa bộ lọc hình ảnh bị lỗi định dạng" "malformed image paintFilter container was skipped" = "đã bỏ qua vùng chứa paintFilter hình ảnh bị lỗi định dạng" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index 6807cbb03..ca0887a37 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -1595,6 +1595,7 @@ status = "draft — needs native review" "layers without a position were placed at their parent's origin" = "没有位置的图层已放在其父级的原点" "layers without a type were skipped" = "已跳过没有类型的图层" "legacy image filter container is unsupported; filter conversion was skipped" = "不支持旧版图片滤镜容器;已跳过滤镜转换" +"links to shared styles that aren't in the file kept the layers' own values" = "指向文件中不存在的共享样式的链接保留了图层自身的值" "locked" = "已锁定" "malformed image filters container was skipped" = "已跳过格式错误的图片滤镜容器" "malformed image paintFilter container was skipped" = "已跳过格式错误的图片 paintFilter 容器" diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index c17a3ec4f..3c319ce0a 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -39,7 +39,7 @@ and prototype interactions are still unchecked there. | BMP | — | yes | | — | | MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 | | Images over 4096 px | downscaled to 4096 | kept | | — | -| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins), **layers copied from another design app, best effort** | Cmd/Ctrl+V reads an image copied in another app (under Wayland through the data-control protocol, not run on Linux here; Linux and BSD clipboard reads give up after 2 s). Copied design layers: the `.fig` canvas in the clipboard's HTML is read like a `.fig` file (with its warnings) and pasted as Paste pastes, in place or into the selected frame (`edit.pasteDesignLayers {html \| data}`, refused over 256 MB before decoding); text copied later pastes as text again. Tested with clipboard HTML made from our own `.fig` export (`design_paste_tests`), never with a real clipboard from other software | with `.fig` | +| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins), **layers copied from another design app, best effort** | Cmd/Ctrl+V reads an image copied in another app (under Wayland through the data-control protocol, not run on Linux here; Linux and BSD clipboard reads give up after 2 s). Copied design layers: the `.fig` canvas in the clipboard's HTML is read like a `.fig` file (with its warnings) and pasted as Paste pastes, in place or into the selected frame (`edit.pasteDesignLayers {html \| data}`, refused over 256 MB before decoding); text copied later pastes as text again. Shared paint, text and effect styles that a pasted layer links by the guid of the library it came from are found by their library asset, so the layers keep the style's colours and their link to it; a link to a style the clipboard doesn't hold keeps the layer's stored colours, with a note (#1638). Tested with clipboard HTML made from our own `.fig` export (`design_paste_tests`) and with design data built in code (`paste_style_tests`), never with a real clipboard from other software | with `.fig` | ## Export @@ -186,6 +186,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Pasted content row: styles a pasted layer links by its library's guid are found by their library asset, so paint-style colours on pasted components and instances no longer import as black; a link to a style that isn't there keeps the stored colour, with a note (#1638). Numbers unchanged | | 2026-10-30 | minor | `.fig` import: elbowed and curved connectors saved without their path were drawn straight; they now run as FigCraft's board connectors do, in right angles or one curve, from the side of each layer they join (the file's `magnet`, else the edge its point is on, else the side facing the other end). The router moved from the engine into `figcraft-doc` (`route_points`) so both use it. Straight connectors are unchanged. Not checked on real files. Tests: `fig::tests::tail` (`other_layer_kinds_import_as_the_closest_layers`, `connectors_without_a_saved_path_follow_their_line_style`) | | 2026-10-30 | minor | M3, #1280: `.fig` export writes a colour calculation (a variable's `{alpha}` or `{mix}` value) as the colour it shows in each mode of its collection, other collections in their default modes; one that resolves to nothing (an operand missing, or more than 32 variables to read) is left out with a warning. Tests: `crates/format/src/fig/assets_tests.rs`. Numbers unchanged. | | 2026-10-30 | minor | Fonts: the missing-font row adds that a family is found under the other names its fonts give it (legacy per-weight names and typographic ones) and with other case and spacing, so a `.fig`, REST or pasted file no longer reports an installed family missing (#1292). Numbers unchanged | diff --git a/docs/roadmap.md b/docs/roadmap.md index d7904ded8..a481b6244 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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); 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; compact layer trees with fields and outline (M0.9, part of #996); 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); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); 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; 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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); 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; compact layer trees with fields and outline (M0.9, part of #996); 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); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); 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; 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, #1638: layers pasted from another design app lost the colours of their shared paint styles: a component's glyphs, and the same glyphs in its instances, came in black. The clipboard links each style by the guid it has in the library it came from, which the clipboard doesn't hold, as well as by its library asset, whose copy it does hold; the `.fig` mapping took the guid and found nothing. Now a guid names a style, variable collection or other resource only when the file holds a node by it, and otherwise the asset it also names finds the file's copy, so `.fig` import and paste agree. A link to a style the file really doesn't hold keeps the colours stored with the layer, as before, now with a note. Tests: `paste_style_tests.rs` (design data built in code, read as paste reads it). | | 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. | | 2026-10-30 | minor | M6, gap 6: widget hover styles (`hover` on a widget layer: fill, stroke, opacity, text colour) shown while the pointer is over it, as a live preview; `widget.hover`. Tests: `widget_hover_tests` | | 2026-10-30 | minor | M6, gap 1: `.fig` elbowed and curved connectors without a saved path are routed like board connectors (shared `figcraft_doc::route_points`) instead of drawn straight. Tests: `fig::tests::tail` | From 01b07af55a0bf5ec218ef94394181b44be05aff7 Mon Sep 17 00:00:00 2001 From: BFlat Date: Fri, 30 Oct 2026 19:34:24 +0700 Subject: [PATCH 34/34] M6: a pasted image the clipboard doesn't carry shows as missing and is named (#1648) * M6: a pasted image the clipboard doesn't carry shows as missing and is named (#1646) The placeholder an import stores for an image it lacks is now striped, not flat grey; the fill keeps the image's name; .fig and paste give a fixed, translated note per image; edit.pasteDesignLayers returns missingImages, the paste's message names those layers, and a paste with notes opens the import report. * M6: the missing-image placeholder's stripes stay clean when scaled, and a .fig gives one note (#1646) --- ROADMAP.md | 3 +- crates/engine/src/cmd/edit.rs | 2 +- crates/engine/src/cmd/edit/clipboard.rs | 27 +++++++++ crates/engine/src/lib.rs | 2 + .../engine/src/paste_missing_image_tests.rs | 54 +++++++++++++++++ crates/format/src/fig.rs | 2 + crates/format/src/fig/map.rs | 4 -- crates/format/src/fig/missing_image_tests.rs | 60 +++++++++++++++++++ crates/format/src/rest.rs | 56 +++++++++++++---- crates/ui-egui/locales/ar.toml | 3 + crates/ui-egui/locales/bn.toml | 3 + crates/ui-egui/locales/cs.toml | 3 + crates/ui-egui/locales/de.toml | 3 + crates/ui-egui/locales/en.toml | 3 + crates/ui-egui/locales/es.toml | 3 + crates/ui-egui/locales/fi.toml | 3 + crates/ui-egui/locales/fr.toml | 3 + crates/ui-egui/locales/id.toml | 3 + crates/ui-egui/locales/it.toml | 3 + crates/ui-egui/locales/ja.toml | 3 + crates/ui-egui/locales/ka.toml | 3 + crates/ui-egui/locales/ko.toml | 3 + crates/ui-egui/locales/nl.toml | 3 + crates/ui-egui/locales/pl.toml | 3 + crates/ui-egui/locales/pt-BR.toml | 3 + crates/ui-egui/locales/ru.toml | 3 + crates/ui-egui/locales/sv.toml | 3 + crates/ui-egui/locales/th.toml | 3 + crates/ui-egui/locales/tr.toml | 3 + crates/ui-egui/locales/vi.toml | 3 + crates/ui-egui/locales/zh-Hans.toml | 3 + crates/ui-egui/src/dialogs/import_report.rs | 11 +++- crates/ui-egui/src/import_report_tests.rs | 12 ++++ crates/ui-egui/src/menus/clipboard.rs | 12 +++- docs/cli.md | 2 +- docs/file-format-parity.md | 4 +- docs/roadmap.md | 3 +- 37 files changed, 293 insertions(+), 27 deletions(-) create mode 100644 crates/engine/src/paste_missing_image_tests.rs create mode 100644 crates/format/src/fig/missing_image_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index d66478a40..aa01f5f6e 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -356,6 +356,7 @@ Ranked; detail and milestones in [`docs/roadmap.md`](docs/roadmap.md). | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, #1646: a layer pasted from another design app whose image the clipboard didn't carry came in flat grey, like a grey the design chose, and the paste said only that some kinds of content were left out. Now the placeholder an import stores for an image it doesn't have is light grey crossed by darker diagonal stripes (REST JSON, `.fig` and paste alike), the fill keeps the image's own name, the `.fig` and paste note is a fixed, translated one per image ("image fills whose image isn't in the file show a missing-image placeholder and keep its name"), `edit.pasteDesignLayers` returns `missingImages` (the names of the pasted layers concerned), the paste's message names them, and a paste with notes opens the import report like an import. Tests: `fig/missing_image_tests.rs`, `paste_missing_image_tests.rs`, `import_report_tests.rs`. | | 2026-10-30 | minor | M6, #1638: layers pasted from another design app lost the colours of their shared paint styles: a component's glyphs, and the same glyphs in its instances, came in black. The clipboard links each style by the guid it has in the library it came from, which the clipboard doesn't hold, as well as by its library asset, whose copy it does hold; the `.fig` mapping took the guid and found nothing. Now a guid names a style, variable collection or other resource only when the file holds a node by it, and otherwise the asset it also names finds the file's copy, so `.fig` import and paste agree. A link to a style the file really doesn't hold keeps the colours stored with the layer, as before, now with a note. Tests: `paste_style_tests.rs` (design data built in code, read as paste reads it). | | 2026-10-30 | minor | Docs (found while landing #1649): merges that kept both sides of a conflict had left rows and status items twice. One row each now for `.fig` in `docs/file-format-parity.md` (four copies: #1234, #1236 and the #1076 ZIP budget kept, with the newer four-values-per-byte budget wording and #1649's connector routing), Real-time co-editing (three: #1069, #1065, #1063) and Live cursors (two: #1380, #1416) in `docs/collaboration-parity.md`, Windows x64 / arm64 in `docs/hardware-parity.md` (#1320, #965) and M0.9 in `docs/roadmap.md` (#1396, #996). The status lines of this file, `docs/roadmap.md`, `docs/gaps.md`, `docs/target-app-parity.md`, `docs/ui-parity.md`, `docs/hardware-parity.md`, `docs/localization-parity.md` and `docs/file-format-parity.md` drop items listed more than once (302 in all, the first kept), and an unclosed parenthesis in target-app-parity's status line is closed. No wording changed otherwise. New `cargo xtask docs`, run by `ci`: a table row that repeats an earlier one (header rows apart) or a status item listed twice fails it. Numbers unchanged. Test: `docs_check::tests`. | | 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. | diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index e4d818439..e24ce080b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -127,7 +127,7 @@ pub fn specs() -> Vec { spec!( "edit.pasteDesignLayers", "Paste design layers", - "{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes", + "{html (the clipboard's HTML) | data (base64 of the clipboard's design data, at most 256 MB), parent?, x?, y?} layers copied from another design app (a .fig canvas in the clipboard's HTML), read like a .fig file (best effort, with `warnings`), pasted as edit.paste pastes; `missingImages`: the names of pasted layers whose image the clipboard didn't carry (they show the missing-image placeholder)", true, paste_design_layers ), diff --git a/crates/engine/src/cmd/edit/clipboard.rs b/crates/engine/src/cmd/edit/clipboard.rs index 9690a1cd2..0c9259d38 100644 --- a/crates/engine/src/cmd/edit/clipboard.rs +++ b/crates/engine/src/cmd/edit/clipboard.rs @@ -134,12 +134,39 @@ pub(super) fn paste_design_layers(s: &mut Session, p: &Value) -> Result { layers_generation, }; let mut out = paste(s, p)?; + let pasted: Vec = out["ids"].as_array().map(|a| a.iter().filter_map(crate::params::id_of).collect()).unwrap_or_default(); + let missing = layers_missing_images(s.doc()?, &pasted); + if !missing.is_empty() { + out["missingImages"] = json!(missing); + } if !other.issues.is_empty() { out["warnings"] = json!(other.issues); } Ok(out) } +/// The most layers [`layers_missing_images`] names. +const MAX_MISSING_NAMED: usize = 20; + +/// The names of the layers under `ids` with an image fill or stroke whose image the paste didn't +/// carry (#1646): it shows the importer's missing-image placeholder, or nothing at all. +fn layers_missing_images(doc: &figcraft_doc::Document, ids: &[NodeId]) -> Vec { + let missing = |p: &figcraft_color::Paint| match &p.kind { + figcraft_color::PaintKind::Image(img) => doc.images.get(&img.image).is_none_or(figcraft_format::rest::is_missing_image), + _ => false, + }; + let mut names: Vec = Vec::new(); + for n in ids.iter().flat_map(|i| doc.subtree(*i)).filter_map(|i| doc.node(i)) { + if n.fills.iter().chain(&n.strokes).any(missing) && !names.contains(&n.name) { + names.push(n.name.clone()); + if names.len() == MAX_MISSING_NAMED { + break; + } + } + } + names +} + pub(super) fn copy(s: &mut Session, p: &Value) -> Result { let ids = req_targets("edit.copy", s, p)?; copy_to_clipboard(s, "edit.copy", &ids)?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5b6bd5af5..199d21fd2 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -429,6 +429,8 @@ mod paint_tests; #[cfg(test)] mod panels_behaviour_tests; #[cfg(test)] +mod paste_missing_image_tests; +#[cfg(test)] mod path_read_caps_tests; #[cfg(test)] mod pdf_print_tests; diff --git a/crates/engine/src/paste_missing_image_tests.rs b/crates/engine/src/paste_missing_image_tests.rs new file mode 100644 index 000000000..01c107b61 --- /dev/null +++ b/crates/engine/src/paste_missing_image_tests.rs @@ -0,0 +1,54 @@ +//! Layers pasted from another design app whose image the clipboard doesn't carry (#1646): the +//! paste names them (`missingImages`), and they keep their image's name. Design data made by our +//! own `.fig` export: its canvas alone, as a clipboard carries it, and the whole file with images. + +use std::io::Read; + +use base64::Engine as _; +use serde_json::{Value, json}; + +use crate::Session; +use crate::params::id_of; + +/// A `.fig` export of a page with "Photo" (an image fill) and "Plain" (a solid fill). +fn exported() -> Vec { + let mut s = Session::new(); + let photo = id_of(&s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap(); + let plain = id_of(&s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 80, "height": 60})).unwrap()["id"]).unwrap(); + let mut png = Vec::new(); + image::RgbaImage::from_pixel(4, 4, image::Rgba([200, 40, 40, 255])) + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let doc = s.doc_mut().unwrap(); + let (hash, _, _) = figcraft_format::add_image(doc, png).unwrap(); + let n = doc.get_mut(photo).unwrap(); + n.name = "Photo".into(); + n.fills = vec![figcraft_color::Paint::image(hash, figcraft_color::ImageScaleMode::Fill)]; + doc.get_mut(plain).unwrap().name = "Plain".into(); + let r = s.execute("file.export", &json!({"format": "fig"})).unwrap(); + base64::engine::general_purpose::STANDARD.decode(r["data"].as_str().unwrap()).unwrap() +} + +/// Just the canvas of a `.fig` file: the design data a clipboard carries, without the images. +fn canvas_only(fig: &[u8]) -> Vec { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(fig)).unwrap(); + let mut out = Vec::new(); + z.by_name("canvas.fig").unwrap().read_to_end(&mut out).unwrap(); + out +} + +fn paste(data: &[u8]) -> Value { + let mut s = Session::new(); + s.execute("edit.pasteDesignLayers", &json!({"data": base64::engine::general_purpose::STANDARD.encode(data)})).unwrap() +} + +#[test] +fn layers_whose_image_the_clipboard_lacks_are_named() { + let fig = exported(); + let r = paste(&canvas_only(&fig)); + assert_eq!(r["missingImages"], json!(["Photo"]), "{r}"); + assert!(r["warnings"].as_array().unwrap().iter().any(|w| w.as_str().unwrap().contains("missing-image placeholder")), "{r}"); + // With its images, nothing is missing. + let r = paste(&fig); + assert!(r.get("missingImages").is_none(), "{r}"); +} diff --git a/crates/format/src/fig.rs b/crates/format/src/fig.rs index 6b2f75f96..55a5d7c34 100644 --- a/crates/format/src/fig.rs +++ b/crates/format/src/fig.rs @@ -30,6 +30,8 @@ pub mod kiwi; #[cfg(test)] mod layer_cap_tests; mod map; +#[cfg(test)] +mod missing_image_tests; mod network; #[cfg(test)] mod paste_style_tests; diff --git a/crates/format/src/fig/map.rs b/crates/format/src/fig/map.rs index 9db65e02a..3d410e07d 100644 --- a/crates/format/src/fig/map.rs +++ b/crates/format/src/fig/map.rs @@ -91,10 +91,6 @@ pub(super) fn adapt_warning(w: String) -> Option { if w.contains("geometry=paths") { return None; } - if w.contains("without image data") { - let count = w.split_whitespace().next().unwrap_or("Some"); - return Some(format!("{count} image fill(s) whose image isn't in the file show a grey placeholder")); - } Some(w.replace("the variables JSON", "the file").replace("the file JSON", "the file")) } diff --git a/crates/format/src/fig/missing_image_tests.rs b/crates/format/src/fig/missing_image_tests.rs new file mode 100644 index 000000000..790628584 --- /dev/null +++ b/crates/format/src/fig/missing_image_tests.rs @@ -0,0 +1,60 @@ +//! Design data whose image fill names an image it doesn't carry, as a clipboard copied from +//! another design app does (#1646): the fill keeps the image's name, so the image can be put back +//! under it; the layer shows the missing-image placeholder (light grey with darker stripes, not a +//! grey a design could have chosen); and the import says so, once per image. Built in code and +//! read as `edit.pasteDesignLayers` reads a clipboard's design data. + +use figcraft_doc::Document; +use figcraft_doc::color::PaintKind; + +use super::kiwi::Kv; +use super::tests::{arr, canvas, e, node, o, schema, xy}; +use super::try_import; +use crate::rest::{is_missing_image, missing_image_placeholder}; + +const HASH: [u8; 20] = [0x5a; 20]; + +fn image_fill() -> Kv<'static> { + o(vec![("type", e("IMAGE")), ("image", o(vec![("hash", Kv::Bytes(HASH.to_vec()))])), ("imageScaleMode", e("FILL"))]) +} + +/// Two layers filled with the same image, which the data doesn't carry. +fn pasted() -> (Document, Vec) { + let nodes = vec![ + node(0, None, "DOCUMENT", "Document", vec![]), + node(1, Some((0, "!")), "CANVAS", "Page", vec![]), + node(10, Some((1, "!")), "RECTANGLE", "Hero", vec![("size", xy(200.0, 100.0)), ("fillPaints", arr(vec![image_fill()]))]), + node(11, Some((1, "\"")), "RECTANGLE", "Hero copy", vec![("size", xy(200.0, 100.0)), ("fillPaints", arr(vec![image_fill()]))]), + ]; + let bytes = canvas(&schema(), &o(vec![("nodeChanges", arr(nodes))])); + try_import(&bytes, "Pasted layers").expect("design data").expect("it imports") +} + +#[test] +fn a_missing_image_keeps_its_name_and_shows_as_missing() { + let (doc, warnings) = pasted(); + let hex: String = HASH.iter().map(|b| format!("{b:02x}")).collect(); + for name in ["Hero", "Hero copy"] { + let layer = doc.nodes().find(|n| n.name == name).unwrap(); + let Some(PaintKind::Image(img)) = layer.fills.first().map(|p| &p.kind) else { panic!("{name}: {:?}", layer.fills) }; + assert_eq!(img.image, hex, "{name}: the image's own name, so it can come back under it"); + assert!(doc.images.get(&img.image).is_some_and(is_missing_image), "{name}: the missing-image placeholder"); + } + let note = "image fills whose image isn't in the file show a missing-image placeholder and keep its name"; + assert_eq!(warnings.iter().filter(|w| w.starts_with(note)).count(), 1, "{warnings:?}"); + assert!(!warnings.iter().any(|w| w.contains("without image data")), "the REST JSON note isn't given too: {warnings:?}"); +} + +#[test] +fn the_placeholder_reads_as_missing_not_as_grey() { + let png = image::load_from_memory(missing_image_placeholder().unwrap()).unwrap().to_rgba8(); + // Two tones (their anti-aliased edges between): darker stripes over a light ground, not one + // flat grey. + let count = |c: u8| png.pixels().filter(|p| p.0 == [c, c, c, 0xFF]).count(); + let all = png.pixels().count(); + let (ground, stripe) = (count(0xE6), count(0xB4)); + assert!(ground > all / 2 && stripe > all / 8 && ground + stripe > all * 3 / 4, "{ground} ground, {stripe} stripe of {all}"); + // A real image of one colour is never taken for the placeholder. + let flat = figcraft_doc::ImageData { bytes: std::sync::Arc::new(vec![1, 2, 3]), width: 64, height: 64 }; + assert!(!is_missing_image(&flat)); +} diff --git a/crates/format/src/rest.rs b/crates/format/src/rest.rs index fb75a8eae..26ab3586a 100644 --- a/crates/format/src/rest.rs +++ b/crates/format/src/rest.rs @@ -7,7 +7,7 @@ //! corners, constraints, auto layout, layout grids, export settings, text with styled runs, //! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and //! component properties (slots included) are imported. Image fills keep their `imageRef` as the -//! image key and show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched). +//! image key and show a missing-image placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched). //! Prototyping comes along: interactions (triggers, actions, transitions, easing), flows, //! scrolling and the prototype device. Variables come from the local variables response //! (`GET /v1/files/:key/variables/local`), embedded next to the file JSON or passed separately @@ -1003,7 +1003,7 @@ impl<'a> Ctx<'a> { } /// The document image key for an `imageRef`: its bytes when the resolver has them, else a - /// grey placeholder stored under the `imageRef` itself when that name can legitimately be an + /// missing-image placeholder stored under the `imageRef` itself when that name can legitimately be an /// archive member (`is_valid_key`) and claims no content hash (`claims_content_hash`: the /// placeholder's bytes don't hash to it); any other name shares the `rest-image-missing` /// placeholder. @@ -1016,6 +1016,11 @@ impl<'a> Ctx<'a> { Some((hash, _, _)) => hash, None => { self.missing_images += 1; + // A design file's (or a paste's) image fill names its image by hash: one note per + // fill, counted, read the same in every language. + if self.fig_sources.is_some() { + self.warn("image fills whose image isn't in the file show a missing-image placeholder and keep its name"); + } // An `imageRef` arrives from an untrusted file, and keys become archive member // names on save: a name that can't legitimately be one must not reach the map, // nor one claiming a content hash the placeholder's bytes don't have. @@ -1024,9 +1029,11 @@ impl<'a> Ctx<'a> { let key = if named { candidate } else { MISSING_IMAGE }; let key = key.to_string(); if !self.doc.images.contains_key(&key) - && let Some(png) = placeholder_png() + && let Some(png) = missing_image_placeholder() { - self.doc.images.insert(key.clone(), ImageData { bytes: Arc::new(png), width: PLACEHOLDER_SIDE, height: PLACEHOLDER_SIDE }); + self.doc + .images + .insert(key.clone(), ImageData { bytes: Arc::new(png.to_vec()), width: PLACEHOLDER_SIDE, height: PLACEHOLDER_SIDE }); } key } @@ -1046,6 +1053,8 @@ impl<'a> Ctx<'a> { /// Cross-references that need every layer imported first. fn finish(&mut self) { + // A `.fig` import (its sources are taken below) noted each missing image already. + let fig = self.fig_sources.is_some(); self.resolve_swap_defaults(); // Variants share their set's property definitions. for (set, defs) in std::mem::take(&mut self.set_defs) { @@ -1079,11 +1088,11 @@ impl<'a> Ctx<'a> { flows.push(Flow { name, start: id, description: String::new() }); } } - if self.missing_images > 0 { + if self.missing_images > 0 && !fig { let n = self.missing_images; let (s, verb) = if n == 1 { ("", "shows") } else { ("s", "show") }; self.warn(format!( - "{n} image{s} without image data {verb} a grey placeholder, named by its imageRef when that can legitimately name a file and by the shared name otherwise (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)" + "{n} image{s} without image data {verb} a missing-image placeholder, named by its imageRef when that can legitimately name a file and by the shared name otherwise (put the downloaded images, named by imageRef, in an `images` folder next to the JSON)" )); } let unused = self.style_meta.keys().filter(|k| !self.doc.styles.iter().any(|s| s.id == **k)).count(); @@ -1465,12 +1474,33 @@ fn sane(p: &BezPath) -> bool { } /// Side of the placeholder image (large enough that scaling it up stays flat). -const PLACEHOLDER_SIDE: u32 = 64; +const PLACEHOLDER_SIDE: u32 = 256; -/// A light-grey PNG standing in for image fills whose bytes aren't available. -fn placeholder_png() -> Option> { - let img = image::RgbaImage::from_pixel(PLACEHOLDER_SIDE, PLACEHOLDER_SIDE, image::Rgba([0xD9, 0xD9, 0xD9, 0xFF])); - let mut out = Vec::new(); - img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; - Some(out) +/// The PNG standing in for image fills whose bytes aren't available (#1646): light grey crossed +/// by darker diagonal stripes, so it reads as an image that is missing, not as a grey the design +/// chose. Made once; [`is_missing_image`] recognises it. +pub fn missing_image_placeholder() -> Option<&'static [u8]> { + static PNG: std::sync::OnceLock>> = std::sync::OnceLock::new(); + PNG.get_or_init(|| { + // Stripes 6 px wide every 18 px across the diagonal, their edges anti-aliased, so they + // stay clean when a fill scales the picture up. + let (period, width) = (18.0f32, 6.0f32); + let (ground, stripe) = ([0xE6u8, 0xE6, 0xE6], [0xB4u8, 0xB4, 0xB4]); + let img = image::RgbaImage::from_fn(PLACEHOLDER_SIDE, PLACEHOLDER_SIDE, |x, y| { + let t = ((x + y) as f32 + 1.0) / std::f32::consts::SQRT_2 % period; + let inside = if t < width { t.min(width - t) + 0.5 } else { 0.5 - (t - width).min(period - t) }; + let k = inside.clamp(0.0, 1.0); + let mix = |i: usize| (f32::from(ground[i]) + (f32::from(stripe[i]) - f32::from(ground[i])) * k).round() as u8; + image::Rgba([mix(0), mix(1), mix(2), 0xFF]) + }); + let mut out = Vec::new(); + img.write_to(&mut std::io::Cursor::new(&mut out), image::ImageFormat::Png).ok()?; + Some(out) + }) + .as_deref() +} + +/// `data` is the placeholder an import stored for an image it didn't have. +pub fn is_missing_image(data: &ImageData) -> bool { + missing_image_placeholder().is_some_and(|png| data.bytes.as_slice() == png) } diff --git a/crates/ui-egui/locales/ar.toml b/crates/ui-egui/locales/ar.toml index b47e4f420..a727770e5 100644 --- a/crates/ui-egui/locales/ar.toml +++ b/crates/ui-egui/locales/ar.toml @@ -811,6 +811,8 @@ status = "draft — needs native review" "Main menu" = "القائمة الرئيسية" "Make a component" = "إنشاء مكوّن" "Make slot" = "إنشاء خانة محتوى" +"Pasted design layers" = "طبقات تصميم ملصقة" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "لُصقت طبقات التصميم؛ لم تحمل الحافظة صور: {layers}" "Scroll wheel zoom" = "التكبير بعجلة التمرير" "Slot name" = "اسم خانة المحتوى" "Mar" = "مارس" @@ -1710,6 +1712,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "تتجاوز قائمة أحجام مسارات الشبكة 1000 إدخال؛ تستخدم المسارات الأحجام الافتراضية المتساوية" "hidden" = "مخفي" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "تعرض تعبئات الصور التي ليست صورتها في الملف عنصرًا نائبًا لصورة مفقودة وتحتفظ باسمها" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "مرشحات الصورة ملتبسة لوجود حاويتي المرشحات معًا؛ تم تخطي تحويل المرشحات" "image filters use FigCraft's approximate renderer" = "تستخدم مرشحات الصورة مصيّر FigCraft التقريبي" "instance swap properties whose default component isn't in the file were skipped" = "تم تخطي خصائص استبدال النسخة المثيلة التي لا يوجد مكوّنها الافتراضي في الملف" diff --git a/crates/ui-egui/locales/bn.toml b/crates/ui-egui/locales/bn.toml index 58be29aaa..c19daa134 100644 --- a/crates/ui-egui/locales/bn.toml +++ b/crates/ui-egui/locales/bn.toml @@ -779,6 +779,8 @@ status = "draft — needs native review" "Main menu" = "প্রধান মেনু" "Make a component" = "একটি কম্পোনেন্ট তৈরি করুন" "Make slot" = "স্লট তৈরি করুন" +"Pasted design layers" = "পেস্ট করা ডিজাইন লেয়ার" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "ডিজাইন লেয়ার পেস্ট হয়েছে; ক্লিপবোর্ডে এগুলোর ছবি ছিল না: {layers}" "Scroll wheel zoom" = "স্ক্রল হুইল দিয়ে জুম" "Slot name" = "স্লটের নাম" "Mar" = "মার্চ" @@ -1654,6 +1656,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "গ্রিড ট্র্যাক সাইজিং তালিকা ১০০০ এন্ট্রি অতিক্রম করেছে; ট্র্যাকগুলো ডিফল্ট সমান সাইজিং ব্যবহার করে" "hidden" = "লুকানো" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "যে ছবি-ফিলের ছবি ফাইলে নেই সেগুলো অনুপস্থিত-ছবির প্লেসহোল্ডার দেখায় এবং তার নাম রাখে" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "ছবির ফিল্টারগুলো দ্ব্যর্থক কারণ উভয় ফিল্টার কন্টেইনার উপস্থিত রয়েছে; ফিল্টার রূপান্তর বাদ দেওয়া হয়েছে" "image filters use FigCraft's approximate renderer" = "ছবির ফিল্টারগুলো FigCraft-এর অনুমানমূলক রেন্ডারার ব্যবহার করে" "instance swap properties whose default component isn't in the file were skipped" = "ইনস্ট্যান্স সোয়াপ প্রপার্টি যার ডিফল্ট কম্পোনেন্ট ফাইলে নেই তা বাদ দেওয়া হয়েছে" diff --git a/crates/ui-egui/locales/cs.toml b/crates/ui-egui/locales/cs.toml index 369cad32d..efd556670 100644 --- a/crates/ui-egui/locales/cs.toml +++ b/crates/ui-egui/locales/cs.toml @@ -764,6 +764,8 @@ status = "draft — needs native review" "Main menu" = "Hlavní nabídka" "Make a component" = "Vytvořit komponentu" "Make slot" = "Vytvořit slot" +"Pasted design layers" = "Vložené návrhové vrstvy" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Návrhové vrstvy vloženy; schránka neobsahovala obrázky těchto vrstev: {layers}" "Scroll wheel zoom" = "Přibližování kolečkem myši" "Slot name" = "Název slotu" "Mar" = "března" @@ -1619,6 +1621,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "seznam velikostí stop mřížky přesahuje 1000 položek; stopy používají výchozí stejnou velikost" "hidden" = "skryté" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "obrazové výplně, jejichž obrázek v souboru není, ukazují zástupný symbol chybějícího obrázku a ponechávají jeho název" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filtry obrázku jsou nejednoznačné, protože jsou přítomny oba kontejnery filtrů; převod filtrů byl přeskočen" "image filters use FigCraft's approximate renderer" = "filtry obrázku používají přibližné vykreslování FigCraftu" "instance swap properties whose default component isn't in the file were skipped" = "vlastnosti výměny instance, jejichž výchozí komponenta není v souboru, byly přeskočeny" diff --git a/crates/ui-egui/locales/de.toml b/crates/ui-egui/locales/de.toml index ef2d925cd..83a5723f7 100644 --- a/crates/ui-egui/locales/de.toml +++ b/crates/ui-egui/locales/de.toml @@ -743,6 +743,8 @@ status = "draft — needs native review" "Main component deleted" = "Hauptkomponente gelöscht" "Main menu" = "Hauptmenü" "Make slot" = "Slot erstellen" +"Pasted design layers" = "Eingefügte Designebenen" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Designebenen eingefügt; die Zwischenablage enthielt die Bilder von {layers} nicht" "Scroll wheel zoom" = "Mit dem Mausrad zoomen" "Slot name" = "Slot-Name" "Margin" = "Rand" @@ -1580,6 +1582,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "die Liste der Rasterspurgrößen hat mehr als 1000 Einträge; die Spuren verwenden die standardmäßige gleiche Größe" "hidden" = "ausgeblendet" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "Bildfüllungen, deren Bild nicht in der Datei ist, zeigen einen Platzhalter für ein fehlendes Bild und behalten seinen Namen" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "Bildfilter sind mehrdeutig, weil beide Filtercontainer vorhanden sind; die Filterumwandlung wurde übersprungen" "image filters use FigCraft's approximate renderer" = "Bildfilter verwenden die näherungsweise Darstellung von FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "Instanztausch-Eigenschaften, deren Standardkomponente nicht in der Datei ist, wurden übersprungen" diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index 1eb58baeb..edb77e903 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -820,6 +820,8 @@ name = "English" "Main menu" = "Main menu" "Make a component" = "Make a component" "Make slot" = "Make slot" +"Pasted design layers" = "Pasted design layers" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Pasted design layers; the clipboard didn't carry the images of: {layers}" "Scroll wheel zoom" = "Scroll wheel zoom" "Slot name" = "Slot name" "Mar" = "Mar" @@ -1729,6 +1731,7 @@ name = "English" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" "hidden" = "hidden" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "image fills whose image isn't in the file show a missing-image placeholder and keep its name" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "image filters are ambiguous because both filter containers are present; filter conversion was skipped" "image filters use FigCraft's approximate renderer" = "image filters use FigCraft's approximate renderer" "instance swap properties whose default component isn't in the file were skipped" = "instance swap properties whose default component isn't in the file were skipped" diff --git a/crates/ui-egui/locales/es.toml b/crates/ui-egui/locales/es.toml index 911aaa0e5..c2eb22203 100644 --- a/crates/ui-egui/locales/es.toml +++ b/crates/ui-egui/locales/es.toml @@ -32,6 +32,8 @@ status = "draft — needs native review" "Connector" = "Conector" "Couldn't download the variables" = "No se pudieron descargar las variables" "Couldn't download {name}" = "No se pudo descargar {name}" +"Pasted design layers" = "Capas de diseño pegadas" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Capas de diseño pegadas; el portapapeles no traía las imágenes de: {layers}" "Relative to" = "Relativa a" "Scroll wheel zoom" = "Zoom con la rueda del ratón" "Trigger layer" = "Capa que la abre" @@ -1698,6 +1700,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "la lista de tamaños de pista de cuadrícula supera las 1000 entradas; las pistas usan el tamaño igual predeterminado" "hidden" = "oculta" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "los rellenos de imagen cuya imagen no está en el archivo muestran un marcador de imagen ausente y conservan su nombre" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "los filtros de imagen son ambiguos porque están los dos contenedores de filtros; se omitió la conversión de filtros" "image filters use FigCraft's approximate renderer" = "los filtros de imagen usan el renderizado aproximado de FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "se omitieron las propiedades de intercambio de instancia cuyo componente predeterminado no está en el archivo" diff --git a/crates/ui-egui/locales/fi.toml b/crates/ui-egui/locales/fi.toml index cad77ccc5..c7832bbdc 100644 --- a/crates/ui-egui/locales/fi.toml +++ b/crates/ui-egui/locales/fi.toml @@ -781,6 +781,8 @@ status = "draft — needs native review" "Main menu" = "Päävalikko" "Make a component" = "Luo komponentti" "Make slot" = "Luo sisältöpaikka" +"Pasted design layers" = "Liitetyt suunnittelutasot" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Suunnittelutasot liitetty; leikepöydällä ei ollut näiden kuvia: {layers}" "Scroll wheel zoom" = "Zoomaus hiiren rullalla" "Slot name" = "Sisältöpaikan nimi" "Mar" = "maalisk." @@ -1659,6 +1661,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ruudukon kokomääritysten luettelo ylittää 1000 kohdetta; osat käyttävät oletuksen mukaista tasakokoa" "hidden" = "kätketty" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "kuvatäytöt, joiden kuvaa ei ole tiedostossa, näyttävät puuttuvan kuvan paikkamerkin ja säilyttävät sen nimen" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "kuvasuotimet ovat monitulkintaisia, koska molemmat suodinsäilöt ovat läsnä; suotimien muunnos ohitettiin" "image filters use FigCraft's approximate renderer" = "kuvasuotimet käyttävät FigCraftin likimääräistä renderöijää" "instance swap properties whose default component isn't in the file were skipped" = "ilmentymän vaihto-ominaisuudet ohitettiin, koska niiden oletuskomponenttia ei ole tiedostossa" diff --git a/crates/ui-egui/locales/fr.toml b/crates/ui-egui/locales/fr.toml index 32d6db95b..c9ce81a1d 100644 --- a/crates/ui-egui/locales/fr.toml +++ b/crates/ui-egui/locales/fr.toml @@ -781,6 +781,8 @@ status = "draft — needs native review" "Main menu" = "Menu principal" "Make a component" = "Créer un composant" "Make slot" = "Créer un emplacement" +"Pasted design layers" = "Calques de design collés" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Calques de design collés ; le presse-papiers ne contenait pas les images de : {layers}" "Scroll wheel zoom" = "Zoom avec la molette" "Slot name" = "Nom de l'emplacement" "Mar" = "mars" @@ -1662,6 +1664,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "la liste de dimensionnement des pistes de grille dépasse 1 000 entrées ; les pistes utilisent le dimensionnement égal par défaut" "hidden" = "masqué" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "les remplissages d'image dont l'image n'est pas dans le fichier affichent un espace réservé d'image manquante et gardent son nom" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "les filtres d’image sont ambigus, car les deux conteneurs de filtres sont présents ; la conversion des filtres a été ignorée" "image filters use FigCraft's approximate renderer" = "les filtres d’image utilisent le rendu approximatif de FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "les propriétés de permutation d’instance dont le composant par défaut n’est pas dans le fichier ont été ignorées" diff --git a/crates/ui-egui/locales/id.toml b/crates/ui-egui/locales/id.toml index add2fe514..f7a9e986a 100644 --- a/crates/ui-egui/locales/id.toml +++ b/crates/ui-egui/locales/id.toml @@ -9,6 +9,8 @@ status = "draft — needs native review" [ui] "Add a connected shape" = "Tambahkan bentuk yang terhubung" +"Pasted design layers" = "Lapisan desain yang ditempel" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Lapisan desain ditempel; papan klip tidak membawa gambar dari: {layers}" "Relative to" = "Relatif terhadap" "Scroll wheel zoom" = "Zoom dengan roda gulir" "Trigger layer" = "Layer pemicu" @@ -1623,6 +1625,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "daftar ukuran track grid melebihi 1000 entri; track memakai ukuran sama besar bawaan" "hidden" = "tersembunyi" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "isian gambar yang gambarnya tidak ada di file menampilkan placeholder gambar hilang dan mempertahankan namanya" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filter gambar ambigu karena kedua kontainer filter ada; konversi filter dilewati" "image filters use FigCraft's approximate renderer" = "filter gambar memakai perenderan perkiraan FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "properti tukar instance yang component bawaannya tidak ada di file dilewati" diff --git a/crates/ui-egui/locales/it.toml b/crates/ui-egui/locales/it.toml index e4530f004..424c88e49 100644 --- a/crates/ui-egui/locales/it.toml +++ b/crates/ui-egui/locales/it.toml @@ -754,6 +754,8 @@ status = "draft — needs native review" "Main menu" = "Menu principale" "Make a component" = "Crea un componente" "Make slot" = "Crea slot" +"Pasted design layers" = "Livelli di design incollati" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Livelli di design incollati; gli appunti non contenevano le immagini di: {layers}" "Scroll wheel zoom" = "Zoom con la rotella del mouse" "Slot name" = "Nome dello slot" "Mar" = "mar" @@ -1597,6 +1599,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "l'elenco di dimensionamento delle tracce della griglia supera 1000 voci; le tracce usano il dimensionamento uniforme predefinito" "hidden" = "nascosto" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "i riempimenti immagine la cui immagine non è nel file mostrano un segnaposto di immagine mancante e ne mantengono il nome" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "i filtri immagine sono ambigui perché sono presenti entrambi i contenitori di filtri; la conversione dei filtri è stata saltata" "image filters use FigCraft's approximate renderer" = "i filtri immagine usano il renderer approssimato di FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "le proprietà di scambio istanza il cui componente predefinito non è nel file sono state saltate" diff --git a/crates/ui-egui/locales/ja.toml b/crates/ui-egui/locales/ja.toml index b1bfc0fe6..79034ebd8 100644 --- a/crates/ui-egui/locales/ja.toml +++ b/crates/ui-egui/locales/ja.toml @@ -775,6 +775,8 @@ status = "draft — needs native review" "Main menu" = "メインメニュー" "Make a component" = "コンポーネントを作成" "Make slot" = "スロットにする" +"Pasted design layers" = "貼り付けたデザインレイヤー" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "デザインレイヤーを貼り付けました。クリップボードに次の画像がありませんでした: {layers}" "Scroll wheel zoom" = "スクロールホイールでズーム" "Slot name" = "スロット名" "Mar" = "3月" @@ -1643,6 +1645,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "グリッドトラックのサイズ指定リストが 1000 項目を超えるため、トラックはデフォルトの均等サイズを使用" "hidden" = "非表示" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "ファイルに画像がない画像塗りは、画像欠落のプレースホルダーを表示し、その名前を保持します" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "両方のフィルターコンテナがあり画像フィルターが曖昧なため、フィルターの変換をスキップ" "image filters use FigCraft's approximate renderer" = "画像フィルターは FigCraft の近似レンダリングで描画" "instance swap properties whose default component isn't in the file were skipped" = "デフォルトのコンポーネントがファイルにないインスタンスの入れ替えプロパティをスキップ" diff --git a/crates/ui-egui/locales/ka.toml b/crates/ui-egui/locales/ka.toml index a5919ad87..4c7e96276 100644 --- a/crates/ui-egui/locales/ka.toml +++ b/crates/ui-egui/locales/ka.toml @@ -788,6 +788,8 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "Main menu" = "მთავარი მენიუ" "Make a component" = "შექმენით კომპონენტი" "Make slot" = "სლოტად გადაქცევა" +"Pasted design layers" = "ჩასმული დიზაინის ფენები" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "დიზაინის ფენები ჩაისვა; ბუფერში არ იყო შემდეგის სურათები: {layers}" "Scroll wheel zoom" = "მასშტაბირება გადახვევის ბორბლით" "Slot name" = "სლოტის სახელი" "Mar" = "მარ" @@ -1680,6 +1682,7 @@ status = "draft — reviewed by a native speaker, awaiting a maintainer's approv "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ბადის ტრეკების ზომების სია 1000 ჩანაწერს აღემატება; ტრეკები ნაგულისხმევ თანაბარ ზომებს იყენებს" "hidden" = "დამალული" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "სურათის შევსებები, რომელთა სურათი ფაილში არ არის, აჩვენებს დაკარგული სურათის ჩანაცვლებას და ინარჩუნებს მის სახელს" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "გამოსახულების ფილტრები ორაზროვანია, რადგან ფილტრების ორივე კონტეინერია მოცემული; ფილტრების გარდაქმნა გამოტოვებულია" "image filters use FigCraft's approximate renderer" = "გამოსახულების ფილტრები FigCraft-ის მიახლოებითი რენდერით გამოისახება" "instance swap properties whose default component isn't in the file were skipped" = "ინსტანსის ჩანაცვლების თვისებები, რომელთა ნაგულისხმევი კომპონენტი ფაილში არ არის, გამოტოვებულია" diff --git a/crates/ui-egui/locales/ko.toml b/crates/ui-egui/locales/ko.toml index 499d83841..0ece525d6 100644 --- a/crates/ui-egui/locales/ko.toml +++ b/crates/ui-egui/locales/ko.toml @@ -739,6 +739,8 @@ status = "draft — needs native review" "Main component deleted" = "메인 컴포넌트가 삭제됨" "Main menu" = "메인 메뉴" "Make slot" = "슬롯으로 만들기" +"Pasted design layers" = "붙여넣은 디자인 레이어" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "디자인 레이어를 붙여넣었습니다. 클립보드에 다음 이미지가 없었습니다: {layers}" "Scroll wheel zoom" = "스크롤 휠로 확대/축소" "Slot name" = "슬롯 이름" "Margin" = "여백" @@ -1571,6 +1573,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "그리드 트랙 크기 목록이 1000개 항목을 넘어 트랙은 기본 균등 크기를 사용함" "hidden" = "숨김" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "파일에 이미지가 없는 이미지 채우기는 누락된 이미지 자리 표시자를 표시하고 그 이름을 유지합니다" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "필터 컨테이너가 둘 다 있어 이미지 필터가 모호하므로 필터 변환을 건너뜀" "image filters use FigCraft's approximate renderer" = "이미지 필터는 FigCraft의 근사 렌더링을 사용함" "instance swap properties whose default component isn't in the file were skipped" = "기본 컴포넌트가 파일에 없는 인스턴스 교체 속성을 건너뜀" diff --git a/crates/ui-egui/locales/nl.toml b/crates/ui-egui/locales/nl.toml index bdca1ff9e..1b3adb098 100644 --- a/crates/ui-egui/locales/nl.toml +++ b/crates/ui-egui/locales/nl.toml @@ -781,6 +781,8 @@ status = "draft — needs native review" "Main menu" = "Hoofdmenu" "Make a component" = "Maak een component" "Make slot" = "Slot maken" +"Pasted design layers" = "Geplakte ontwerplagen" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Ontwerplagen geplakt; het klembord bevatte de afbeeldingen van {layers} niet" "Scroll wheel zoom" = "Zoomen met scrollwiel" "Slot name" = "Slotnaam" "Mar" = "mrt." @@ -1659,6 +1661,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "de lijst met maten voor rastertracks bevat meer dan 1000 items; de tracks gebruiken de standaard gelijke maat" "hidden" = "verborgen" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "afbeeldingsvullingen waarvan de afbeelding niet in het bestand staat, tonen een plaatsaanduiding voor een ontbrekende afbeelding en behouden de naam" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "afbeeldingsfilters zijn dubbelzinnig omdat beide filtercontainers aanwezig zijn; filterconversie is overgeslagen" "image filters use FigCraft's approximate renderer" = "afbeeldingsfilters gebruiken de benaderende renderer van FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "instance-swap-eigenschappen waarvan de standaardcomponent niet in het bestand zit, zijn overgeslagen" diff --git a/crates/ui-egui/locales/pl.toml b/crates/ui-egui/locales/pl.toml index d77c05159..75517084e 100644 --- a/crates/ui-egui/locales/pl.toml +++ b/crates/ui-egui/locales/pl.toml @@ -783,6 +783,8 @@ name = "Polski" "Main menu" = "Menu główne" "Make a component" = "Utwórz komponent" "Make slot" = "Utwórz gniazdo (slot)" +"Pasted design layers" = "Wklejone warstwy projektu" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Wklejono warstwy projektu; schowek nie zawierał obrazów warstw: {layers}" "Scroll wheel zoom" = "Powiększanie kółkiem myszy" "Slot name" = "Nazwa gniazda" "Mar" = "Mar" @@ -1669,6 +1671,7 @@ name = "Polski" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "lista rozmiarów ścieżek siatki przekracza 1000 pozycji; ścieżki używają domyślnego równego rozmiaru" "hidden" = "ukryty" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "wypełnienia obrazem, których obrazu nie ma w pliku, pokazują symbol zastępczy brakującego obrazu i zachowują jego nazwę" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "filtry obrazu są niejednoznaczne, ponieważ obecne są oba kontenery filtrów; konwersja filtrów została pominięta" "image filters use FigCraft's approximate renderer" = "filtry obrazu używają przybliżonego renderera FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "właściwości podmiany instancji, których domyślny komponent nie znajduje się w pliku, zostały pominięte" diff --git a/crates/ui-egui/locales/pt-BR.toml b/crates/ui-egui/locales/pt-BR.toml index 98e787a13..1c20f6ec4 100644 --- a/crates/ui-egui/locales/pt-BR.toml +++ b/crates/ui-egui/locales/pt-BR.toml @@ -738,6 +738,8 @@ status = "draft — needs native review" "Main component deleted" = "Componente principal excluído" "Main menu" = "Menu principal" "Make slot" = "Transformar em slot" +"Pasted design layers" = "Camadas de design coladas" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Camadas de design coladas; a área de transferência não trouxe as imagens de: {layers}" "Scroll wheel zoom" = "Zoom com a roda do mouse" "Slot name" = "Nome do slot" "Margin" = "Margem" @@ -1569,6 +1571,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "a lista de dimensionamento de faixas da grade passa de 1000 entradas; as faixas usam o dimensionamento igual padrão" "hidden" = "oculta" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "preenchimentos de imagem cuja imagem não está no arquivo mostram um marcador de imagem ausente e mantêm seu nome" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "os filtros de imagem são ambíguos porque os dois contêineres de filtro estão presentes; a conversão dos filtros foi ignorada" "image filters use FigCraft's approximate renderer" = "os filtros de imagem usam a renderização aproximada do FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "propriedades de troca de instância cujo componente padrão não está no arquivo foram ignoradas" diff --git a/crates/ui-egui/locales/ru.toml b/crates/ui-egui/locales/ru.toml index 8aa5404b9..ed67090b2 100644 --- a/crates/ui-egui/locales/ru.toml +++ b/crates/ui-egui/locales/ru.toml @@ -783,6 +783,8 @@ name = "Русский" "Main menu" = "Главное меню" "Make a component" = "Создать компонент" "Make slot" = "Сделать слотом" +"Pasted design layers" = "Вставленные слои дизайна" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Слои дизайна вставлены; в буфере обмена не было изображений слоёв: {layers}" "Scroll wheel zoom" = "Масштабирование колесом мыши" "Slot name" = "Имя слота" "Mar" = "мар." @@ -1669,6 +1671,7 @@ name = "Русский" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "список размеров треков сетки превышает 1000 записей; треки получают стандартный равный размер" "hidden" = "скрыто" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "заливки изображением, чьего изображения нет в файле, показывают заглушку отсутствующего изображения и сохраняют его имя" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "фильтры изображения неоднозначны, так как есть оба контейнера фильтров; преобразование фильтров пропущено" "image filters use FigCraft's approximate renderer" = "фильтры изображения отрисовываются приблизительно средствами FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "свойства замены экземпляра, компонент по умолчанию которых отсутствует в файле, пропущены" diff --git a/crates/ui-egui/locales/sv.toml b/crates/ui-egui/locales/sv.toml index 932080ae3..ba46955b1 100644 --- a/crates/ui-egui/locales/sv.toml +++ b/crates/ui-egui/locales/sv.toml @@ -818,6 +818,8 @@ name = "Svenska" "Main menu" = "Huvudmeny" "Make a component" = "Skapa en komponent" "Make slot" = "Gör till plats" +"Pasted design layers" = "Inklistrade designlager" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Designlager inklistrade; urklippet innehöll inte bilderna för: {layers}" "Scroll wheel zoom" = "Zooma med scrollhjulet" "Slot name" = "Platsens namn" "Mar" = "Mar" @@ -1726,6 +1728,7 @@ name = "Svenska" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "listan över storlekar på rutnätsspår har fler än 1000 poster; spåren får standardstorleken med lika stora spår" "hidden" = "dold" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "bildfyllningar vars bild inte finns i filen visar en platshållare för saknad bild och behåller dess namn" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "bildfiltren är tvetydiga eftersom båda filterbehållarna finns; filterkonverteringen hoppades över" "image filters use FigCraft's approximate renderer" = "bildfilter använder FigCrafts ungefärliga rendering" "instance swap properties whose default component isn't in the file were skipped" = "egenskaper för instansbyte vars standardkomponent inte finns i filen hoppades över" diff --git a/crates/ui-egui/locales/th.toml b/crates/ui-egui/locales/th.toml index 9e7f746f7..942483b06 100644 --- a/crates/ui-egui/locales/th.toml +++ b/crates/ui-egui/locales/th.toml @@ -777,6 +777,8 @@ status = "draft — needs native review" "Main menu" = "เมนูหลัก" "Make a component" = "สร้างคอมโพเนนต์" "Make slot" = "สร้างสล็อต" +"Pasted design layers" = "เลเยอร์ดีไซน์ที่วาง" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "วางเลเยอร์ดีไซน์แล้ว คลิปบอร์ดไม่มีภาพของ: {layers}" "Scroll wheel zoom" = "ซูมด้วยล้อเลื่อนเมาส์" "Slot name" = "ชื่อสล็อต" "Mar" = "มี.ค." @@ -1648,6 +1650,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "รายการขนาดแทร็กกริดเกิน 1000 รายการ: แทร็กใช้ขนาดเท่ากันตามค่าเริ่มต้น" "hidden" = "ซ่อนอยู่" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "การเติมภาพที่ภาพไม่อยู่ในไฟล์จะแสดงตัวยึดตำแหน่งภาพที่หายไปและเก็บชื่อไว้" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "ตัวกรองรูปภาพกำกวมเพราะมีคอนเทนเนอร์ตัวกรองทั้งสองแบบ: ข้ามการแปลงตัวกรอง" "image filters use FigCraft's approximate renderer" = "ตัวกรองรูปภาพใช้ตัวเรนเดอร์โดยประมาณของ FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "ข้ามพร็อพเพอร์ตีสลับอินสแตนซ์ที่คอมโพเนนต์เริ่มต้นไม่อยู่ในไฟล์" diff --git a/crates/ui-egui/locales/tr.toml b/crates/ui-egui/locales/tr.toml index 8d6e1ede3..cd0760e48 100644 --- a/crates/ui-egui/locales/tr.toml +++ b/crates/ui-egui/locales/tr.toml @@ -768,6 +768,8 @@ name = "Türkçe" "Main menu" = "Ana menü" "Make a component" = "Bileşen yap" "Make slot" = "Yuva yap" +"Pasted design layers" = "Yapıştırılan tasarım katmanları" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Tasarım katmanları yapıştırıldı; pano şunların görsellerini taşımıyordu: {layers}" "Scroll wheel zoom" = "Kaydırma tekerleğiyle yakınlaştırma" "Slot name" = "Yuva adı" "Mar" = "Mar" @@ -1623,6 +1625,7 @@ name = "Türkçe" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "ızgara izi boyutlandırma listesi 1000 girdiyi aşıyor; izler varsayılan eşit boyutlandırmayı kullanır" "hidden" = "gizli" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "görseli dosyada olmayan görsel dolguları eksik görsel yer tutucusu gösterir ve adını korur" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "iki filtre kapsayıcısı da bulunduğu için görüntü filtreleri belirsiz; filtre dönüştürme atlandı" "image filters use FigCraft's approximate renderer" = "görüntü filtreleri FigCraft'ın yaklaşık işlemesini kullanır" "instance swap properties whose default component isn't in the file were skipped" = "varsayılan bileşeni dosyada olmayan örnek değiştirme özellikleri atlandı" diff --git a/crates/ui-egui/locales/vi.toml b/crates/ui-egui/locales/vi.toml index 5e261e0ac..294efc7f8 100644 --- a/crates/ui-egui/locales/vi.toml +++ b/crates/ui-egui/locales/vi.toml @@ -755,6 +755,8 @@ status = "draft — needs native review" "Main menu" = "Trình đơn chính" "Make a component" = "Tạo thành phần" "Make slot" = "Tạo ô chứa" +"Pasted design layers" = "Các lớp thiết kế đã dán" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "Đã dán các lớp thiết kế; bộ nhớ tạm không có hình ảnh của: {layers}" "Scroll wheel zoom" = "Thu phóng bằng con lăn chuột" "Slot name" = "Tên ô chứa" "Mar" = "Thg 3" @@ -1598,6 +1600,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "danh sách kích thước dải lưới vượt quá 1000 mục; các dải sẽ dùng kích thước đều nhau mặc định" "hidden" = "đã ẩn" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "các phần tô hình ảnh có hình ảnh không có trong tệp hiển thị phần giữ chỗ hình ảnh bị thiếu và giữ tên của nó" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "bộ lọc hình ảnh không rõ ràng do có cả hai vùng chứa bộ lọc; đã bỏ qua chuyển đổi bộ lọc" "image filters use FigCraft's approximate renderer" = "bộ lọc hình ảnh sử dụng bộ kết xuất xấp xỉ của FigCraft" "instance swap properties whose default component isn't in the file were skipped" = "đã bỏ qua các thuộc tính hoán đổi bản thể có thành phần mặc định không nằm trong tệp" diff --git a/crates/ui-egui/locales/zh-Hans.toml b/crates/ui-egui/locales/zh-Hans.toml index ca0887a37..ce472ab76 100644 --- a/crates/ui-egui/locales/zh-Hans.toml +++ b/crates/ui-egui/locales/zh-Hans.toml @@ -744,6 +744,8 @@ status = "draft — needs native review" "Main component deleted" = "主组件已删除" "Main menu" = "主菜单" "Make slot" = "设为插槽" +"Pasted design layers" = "粘贴的设计图层" +"Pasted design layers; the clipboard didn't carry the images of: {layers}" = "已粘贴设计图层;剪贴板未包含以下图层的图像:{layers}" "Scroll wheel zoom" = "滚轮缩放" "Slot name" = "插槽名称" "Margin" = "边距" @@ -1579,6 +1581,7 @@ status = "draft — needs native review" "grid track sizing list exceeds 1000 entries; the tracks use the default equal sizing" = "网格轨道尺寸列表超过 1000 个条目;轨道使用默认的等分尺寸" "hidden" = "已隐藏" "iOS" = "iOS" +"image fills whose image isn't in the file show a missing-image placeholder and keep its name" = "图像不在文件中的图像填充会显示缺失图像占位图并保留其名称" "image filters are ambiguous because both filter containers are present; filter conversion was skipped" = "两个滤镜容器同时存在,图片滤镜有歧义;已跳过滤镜转换" "image filters use FigCraft's approximate renderer" = "图片滤镜使用 FigCraft 的近似渲染" "instance swap properties whose default component isn't in the file were skipped" = "已跳过默认组件不在文件中的实例替换属性" diff --git a/crates/ui-egui/src/dialogs/import_report.rs b/crates/ui-egui/src/dialogs/import_report.rs index 5b5247273..ae72a5431 100644 --- a/crates/ui-egui/src/dialogs/import_report.rs +++ b/crates/ui-egui/src/dialogs/import_report.rs @@ -35,10 +35,15 @@ impl Report { /// a native file's `repairs` are shown by the engine's own message, and an SVG opened without /// notes has nothing to report. `params` are the command's, for the file's name. pub fn from_result(command: &str, params: &Value, result: &Value) -> Option { - if !matches!(command, "file.open" | "file.openRecent" | "file.import") { + if !matches!(command, "file.open" | "file.openRecent" | "file.import" | "edit.pasteDesignLayers") { return None; } let warnings = result.get("warnings").and_then(Value::as_array)?; + // A paste is reported when something was left out or changed, as an import into the page. + let pasted = command == "edit.pasteDesignLayers"; + if pasted && warnings.is_empty() { + return None; + } let file_name = params.get("path").and_then(Value::as_str).and_then(|p| std::path::Path::new(p).file_name()).and_then(|n| n.to_str()); if command != "file.import" && warnings.is_empty() && file_name.is_some_and(|n| n.to_ascii_lowercase().ends_with(".svg")) { return None; @@ -56,14 +61,14 @@ impl Report { .and_then(Value::as_str) .or(file_name) .or_else(|| params.get("name").and_then(Value::as_str)) - .unwrap_or(tr("Imported document")); + .unwrap_or(if pasted { tr("Pasted design layers") } else { tr("Imported document") }); let mut report = Self { name: name.chars().take(512).collect(), pages: count("pages"), layers: count("layers"), images: count("images"), appended, - placed: command == "file.import" && !appended, + placed: (command == "file.import" && !appended) || pasted, uid: 0, notes: Vec::new(), shown: Vec::new(), diff --git a/crates/ui-egui/src/import_report_tests.rs b/crates/ui-egui/src/import_report_tests.rs index 8c10c7f3e..9eaa413f5 100644 --- a/crates/ui-egui/src/import_report_tests.rs +++ b/crates/ui-egui/src/import_report_tests.rs @@ -202,3 +202,15 @@ fn a_file_opened_from_base64_data_has_a_summary_too() { // Shown as a sentence, worked out once when the report opens. assert!(report.shown.iter().any(|n| n == "Layers without a type were skipped"), "{:?}", report.shown); } + +#[test] +fn a_paste_that_left_something_out_is_reported_like_an_import() { + let note = "image fills whose image isn't in the file show a missing-image placeholder and keep its name"; + let result = json!({"ids": [], "warnings": [note], "missingImages": ["Hero"]}); + let report = Report::from_result("edit.pasteDesignLayers", &json!({"html": ""}), &result).unwrap(); + assert_eq!(report.name, "Pasted design layers"); + assert!(report.placed && !report.appended, "placed on the current page"); + assert_eq!(report.notes.len(), 1); + // A paste that left nothing out opens no report. + assert!(Report::from_result("edit.pasteDesignLayers", &json!({}), &json!({"ids": [], "warnings": []})).is_none()); +} diff --git a/crates/ui-egui/src/menus/clipboard.rs b/crates/ui-egui/src/menus/clipboard.rs index dbcba19b6..26c2e4dfe 100644 --- a/crates/ui-egui/src/menus/clipboard.rs +++ b/crates/ui-egui/src/menus/clipboard.rs @@ -1,4 +1,7 @@ +use serde_json::Value; + use super::{FigApp, dev_mode_refuses, json, trn}; +use crate::i18n::trf; /// Run a menu item: some open dialogs or use platform services, the rest go to the engine. /// Paste: the system clipboard wins when it holds something newer than our own copy (layers copied @@ -28,12 +31,17 @@ pub fn paste(app: &mut FigApp, event_text: Option<&str>, at: Option<(f64, f64)>) params["x"] = json!(x); params["y"] = json!(y); } - if let Some(r) = app.run("edit.pasteDesignLayers", params) { + // A paste the user made: what was left out shows in the import report. + if let Some(r) = app.run_user("edit.pasteDesignLayers", params) { // The pasted layers are now our copy: text another app puts on the clipboard later is newer. app.ui.last_copy = Some(app.services.clipboard_get_text().unwrap_or_default()); app.ui.last_copy_generation = app.session.clipboard.layers_generation; + let missing: Vec<&str> = r["missingImages"].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); let left_out = r["warnings"].as_array().map_or(0, Vec::len); - if left_out > 0 { + if !missing.is_empty() { + // Name the layers: an image the clipboard didn't carry is easy to miss among them. + app.toast(trf("Pasted design layers; the clipboard didn't carry the images of: {layers}", &[("layers", &missing.join(", "))])); + } else if left_out > 0 { app.toast(trn("Pasted design layers; {n} kinds of content were left out", left_out, &[])); } } diff --git a/docs/cli.md b/docs/cli.md index 6c2f8aec4..371a48591 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -100,7 +100,7 @@ layers}]` with `--json`); their text renders in a fallback font. Every subcommand that reads a file also takes the public REST API file JSON (the response of `GET /v1/files/:key` or of its nodes endpoint, written to a `.json` file): it is imported on the fly. Image fills come from `images/.png` (or `.jpg`, …) next to the JSON when present, else a -grey placeholder. `.fig` files are read the same way (best effort, images from the file itself), and +missing-image placeholder (light grey with darker diagonal stripes). `.fig` files are read the same way (best effort, images from the file itself), and so are `.sketch` files saved by Sketch 48 or newer; `info` lists what wasn't imported as `note:` lines. diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 3c319ce0a..9eccf4cb6 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-10-30 · **Change:** minor (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-10-30 · **Change:** minor (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 @@ -39,6 +39,7 @@ and prototype interactions are still unchecked there. | BMP | — | yes | | — | | MP4, MOV, WebM (video fills, paid plans) | yes | **no** | shortcut: decoding H.264/HEVC/VP9 needs non-Rust libraries (FFmpeg or each platform's media framework); presenter playback would reuse the animated GIF clock | 4–8 | | Images over 4096 px | downscaled to 4096 | kept | | — | +| Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins), **layers copied from another design app, best effort** | Cmd/Ctrl+V reads an image copied in another app (under Wayland through the data-control protocol, not run on Linux here; Linux and BSD clipboard reads give up after 2 s). Copied design layers: the `.fig` canvas in the clipboard's HTML is read like a `.fig` file (with its warnings) and pasted as Paste pastes, in place or into the selected frame (`edit.pasteDesignLayers {html \| data}`, refused over 256 MB before decoding); text copied later pastes as text again. An image the clipboard doesn't carry keeps its name on the fill and shows the missing-image placeholder (light grey with darker stripes), and the paste names those layers and lists its notes in the import report (#1646). Tested with clipboard HTML made from our own `.fig` export (`design_paste_tests`), never with a real clipboard from other software | with `.fig` | | Pasted content from other apps | images, SVG, text, Figma clipboard | images, SVG, text (newest clipboard content wins), **layers copied from another design app, best effort** | Cmd/Ctrl+V reads an image copied in another app (under Wayland through the data-control protocol, not run on Linux here; Linux and BSD clipboard reads give up after 2 s). Copied design layers: the `.fig` canvas in the clipboard's HTML is read like a `.fig` file (with its warnings) and pasted as Paste pastes, in place or into the selected frame (`edit.pasteDesignLayers {html \| data}`, refused over 256 MB before decoding); text copied later pastes as text again. Shared paint, text and effect styles that a pasted layer links by the guid of the library it came from are found by their library asset, so the layers keep the style's colours and their link to it; a link to a style the clipboard doesn't hold keeps the layer's stored colours, with a note (#1638). Tested with clipboard HTML made from our own `.fig` export (`design_paste_tests`) and with design data built in code (`paste_style_tests`), never with a real clipboard from other software | with `.fig` | ## Export @@ -186,6 +187,7 @@ source paint coordinates. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Pasted content row: an image the clipboard doesn't carry keeps its name and shows a placeholder that reads as missing; the paste names those layers in its message and opens the import report with its notes (#1646). Numbers unchanged | | 2026-10-30 | minor | Pasted content row: styles a pasted layer links by its library's guid are found by their library asset, so paint-style colours on pasted components and instances no longer import as black; a link to a style that isn't there keeps the stored colour, with a note (#1638). Numbers unchanged | | 2026-10-30 | minor | `.fig` import: elbowed and curved connectors saved without their path were drawn straight; they now run as FigCraft's board connectors do, in right angles or one curve, from the side of each layer they join (the file's `magnet`, else the edge its point is on, else the side facing the other end). The router moved from the engine into `figcraft-doc` (`route_points`) so both use it. Straight connectors are unchanged. Not checked on real files. Tests: `fig::tests::tail` (`other_layer_kinds_import_as_the_closest_layers`, `connectors_without_a_saved_path_follow_their_line_style`) | | 2026-10-30 | minor | M3, #1280: `.fig` export writes a colour calculation (a variable's `{alpha}` or `{mix}` value) as the colour it shows in each mode of its collection, other collections in their default modes; one that resolves to nothing (an operand missing, or more than 32 variables to read) is left out with a warning. Tests: `crates/format/src/fig/assets_tests.rs`. Numbers unchanged. | diff --git a/docs/roadmap.md b/docs/roadmap.md index a481b6244..44da562b7 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (paint-style colours on layers pasted from another design app keep their style instead of turning black (#1638); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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); 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; compact layer trees with fields and outline (M0.9, part of #996); 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); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); 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; 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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-10-30 · **Change:** minor (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); Shift+Cmd/Ctrl+Enter on a selected board shape adds the next shape already joined to it, and goes on typing (#1597); 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); 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; compact layer trees with fields and outline (M0.9, part of #996); 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); 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 panic in a grouped closure outside any command rolls the group back like a failed group: documents, selection, undo and redo, history limit and journal as before (#1085); 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); 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; 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); M8: mask groups draw only over the area they can paint, and nested isolated layers share one memory budget (#1061); M2: components can't contain their own instances, directly or through others; `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); M0.9: optional batch continue-on-error with per-step outcomes (#1386); Alt pressed during a drag duplicates (#1385); variables in paint props (M3, #1000); finding layers without selecting them (M0.9, part of #996); the attached MCP server reads the app's replies with a 512 MiB bound (#1113); a failed batch keeps the undo steps it undid (#1410); Arabic draft catalog; Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; 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) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | M6, #1646: a layer pasted from another design app whose image the clipboard didn't carry came in flat grey, like a grey the design chose, and the paste said only that some kinds of content were left out. Now the placeholder an import stores for an image it doesn't have is light grey crossed by darker diagonal stripes (REST JSON, `.fig` and paste alike), the fill keeps the image's own name, the `.fig` and paste note is a fixed, translated one per image ("image fills whose image isn't in the file show a missing-image placeholder and keep its name"), `edit.pasteDesignLayers` returns `missingImages` (the names of the pasted layers concerned), the paste's message names them, and a paste with notes opens the import report like an import. Tests: `fig/missing_image_tests.rs`, `paste_missing_image_tests.rs`, `import_report_tests.rs`. | | 2026-10-30 | minor | M6, #1638: layers pasted from another design app lost the colours of their shared paint styles: a component's glyphs, and the same glyphs in its instances, came in black. The clipboard links each style by the guid it has in the library it came from, which the clipboard doesn't hold, as well as by its library asset, whose copy it does hold; the `.fig` mapping took the guid and found nothing. Now a guid names a style, variable collection or other resource only when the file holds a node by it, and otherwise the asset it also names finds the file's copy, so `.fig` import and paste agree. A link to a style the file really doesn't hold keeps the colours stored with the layer, as before, now with a note. Tests: `paste_style_tests.rs` (design data built in code, read as paste reads it). | | 2026-10-30 | minor | M3, #1613 (after #1618): the rest of the stored-id work. `Variables::get` and `Variables::collection` take the variable or collection with that exact id before one named like it (as `find_mode` does), so typed ids in `variable.bind`, `variable.update`, aliases and the prototype action and expression input can't be taken by a renamed one. Stored mode ids go through the new `Collection::mode_by_id` (expression evaluation, display and type checks, the prototype's mode switch), font family variables read their stored ids exactly, and the colour picker, prototype editor and panels' labels (variables, prototype, selection colours, typography, style picker, assets, variable fields) read exact ids. Tests: `stored_ids_tests.rs` (exported aliases, deletion, Dev Inspect, expressions, mode switch, library import, interaction edits) and the doc crate's lookup test; #1618's expression test now expects the exact id to win. | | 2026-10-30 | minor | M6, gap 6: widget hover styles (`hover` on a widget layer: fill, stroke, opacity, text colour) shown while the pointer is over it, as a live preview; `widget.hover`. Tests: `widget_hover_tests` |