From d86a7ad9d9fed164abc347f93e62899b9d0c7385 Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Tue, 27 Oct 2026 12:55:47 +0300 Subject: [PATCH 1/8] 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 7dccd706..872f3c53 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 7ec63c69..8e920099 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 00000000..3f619ac1 --- /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 60f240cb..2ea51f17 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 811724f2..729611e5 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 fcdb21a3..a685b8fb 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 1b23191e..42c68d39 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 00000000..5d930ba2 --- /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 23d23ec9..420fb4c5 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 afef387d..f2669035 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 00000000..102e232b --- /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 1eb714e9..ae84e231 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 448a9979..4def3afa 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 05dfd1e6..3027d61d 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 14d734ba..afb4e471 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 2/8] 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 b650f08e..70daf090 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 fa5a02d8..9c62bada 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 d06ebd42..7d35cb49 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 3/8] #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 ba72cba9..92bb6b74 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 50e80c92..3a043757 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 4/8] #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 c2def720..4b1f586a 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 ff0cea42..803cbe82 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 3a043757..f139ae91 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 fcde83a5a6d45346a316f45aaae8f4588c131c1c Mon Sep 17 00:00:00 2001 From: Mert Dede Date: Fri, 30 Oct 2026 06:48:01 +0300 Subject: [PATCH 5/8] 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 1e2aa385..dec61e3f 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 a296f681..73bfdfc4 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 697f949d..273ced14 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 4e26b614..02f4ff55 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 889eccb0..c0901989 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 6/8] 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 d5441b28..e40d3ef7 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 ea2757c7..1a8d8b78 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 54615ee0..660b4a83 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 92ea24cc..9f8dbdb7 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 0ed0ef35..5b1cf23c 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 24699ef2..94500eac 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 1dfc1691..f4af1c34 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 fa01e05a..db769a0e 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 0c7edbb2..e562cfd6 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 3f51bb5e..a620b037 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 95a53a9d..9af669cd 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 5ccad426..b88e3159 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 24ecb009..723633f9 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 0648376b..3bbadcc7 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 dbf3ac91..e646e53f 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 5ee590cc..44a762d2 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 9e8567b8..f5b9f775 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 a811806e..e2d8535e 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 3fa374e0..7a25fcbd 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 c59afe91..cd2d3b67 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 7f80a044..9c485362 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 9518911e..806300c7 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 b34294a8..9f1300af 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 bd269556..4bf790fd 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 1de09e89..1cec7eaf 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 233cfc85..1432a8c5 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 99e08cef..7f1d316c 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 61869916..5ba0c5ad 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 fe822a1f..80e544f9 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 5cd4e03e..1b4e0f74 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 23491800..3b67139a 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 f0ead8e9..8ff4f608 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 4cf94617..e5cc5abe 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 889eccb0..b7b1dec9 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 40f83a9a08ec7d601495bd2513e0305a989ced75 Mon Sep 17 00:00:00 2001 From: samhyun Date: Fri, 30 Oct 2026 15:10:48 +0900 Subject: [PATCH 7/8] 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 046ed842..f345e21d 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 1b1d9445..5060d1ca 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 58d791c88781d216eb5f87b4d6301c9057ef0a0c Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 13:34:04 +0700 Subject: [PATCH 8/8] #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 fce7066d..fb18fd5d 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`.