Merge main into #794

This commit is contained in:
bflatastic
2026-10-18 16:13:48 +07:00
12 changed files with 537 additions and 23 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {
spec!(
"variable.import",
"Import variables",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings}",
"{variables (the local variables JSON, as variable.export writes it: an object, JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level)} collections join this file's collection of the same name (values by mode name; new modes and variables are added, a variable of another type is skipped), others are added → {collections (added), variables (added), updated, warnings (also when it holds no variables, or fewer than its collections list)}",
true,
import
),
+4
View File
@@ -1744,6 +1744,8 @@ mod proto_run_tests;
#[cfg(test)]
mod proto_tail_tests;
#[cfg(test)]
mod rest_slot_tests;
#[cfg(test)]
mod retained_walk_tests;
#[cfg(test)]
mod rotated_guides_tests;
@@ -1816,6 +1818,8 @@ mod variables_behaviour_tests;
#[cfg(test)]
mod variables_editor_tests;
#[cfg(test)]
mod variables_import_shapes_tests;
#[cfg(test)]
mod variables_io_tests;
#[cfg(test)]
mod vector_behaviour_tests;
+91
View File
@@ -0,0 +1,91 @@
//! Slots opened from REST file JSON: instances keep their own slot content through edits and
//! moves of the mains, and through saving.
use crate::Session;
use crate::components::tests::{ex, healthy, idv};
use crate::components::*;
use base64::Engine as _;
use figcraft_doc::*;
use serde_json::{Value, json};
fn at(x: f64, y: f64) -> Value {
json!([[1, 0, x], [0, 1, y]])
}
fn text(id: &str, s: &str) -> Value {
json!({"id": id, "name": s, "type": "TEXT", "characters": s, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
}
/// A Chip main, a Container main with an empty "Slot", and an instance of Container whose slot
/// holds a text and a chip.
fn file() -> Value {
let slot = |id: &str, kids: Value| json!({"id": id, "name": "Slot", "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": kids});
let guid = |l: u32| json!({"guid": {"sessionID": 5, "localID": l}});
let chip = json!({"id": "2:3", "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20},
"children": [{"id": "I2:3;1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}]});
json!({"name": "Slots", "document": {"id": "0:0", "type": "DOCUMENT", "children": [{"id": "0:1", "name": "Page", "type": "CANVAS", "children": [
{"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]},
{"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": guid(1)}},
"children": [text("1:4", "Title"), slot("1:5", json!([]))]},
{"id": "2:1", "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(400.0, 0.0), "size": {"x": 300, "y": 160},
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": guid(7)}},
"children": [text("I2:1;1:4", "Title"), slot("I2:1;1:5", json!([text("2:2", "Reports in progress"), chip]))]}
]}]}})
}
fn named(doc: &Document, kind: fn(&NodeKind) -> bool, name: &str) -> NodeId {
doc.nodes().find(|n| n.name == name && kind(&n.kind)).map(|n| n.id).unwrap_or_else(|| panic!("no {name}"))
}
/// The instance's slot and the names of what it holds.
fn slot_content(s: &Session) -> (NodeId, Vec<String>) {
let doc = s.doc().unwrap();
let inst = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Container");
let (slot, _) = slots_of(doc, inst).into_iter().next().expect("the instance has its slot");
let names = doc.node(slot).unwrap().children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect();
(slot, names)
}
#[test]
fn rest_slot_content_survives_main_edits_moves_and_saving() {
let mut s = Session::new();
let data = base64::engine::general_purpose::STANDARD.encode(serde_json::to_vec(&file()).unwrap());
ex(&mut s, "file.open", json!({"data": data, "name": "slots.json"}));
healthy(&s);
let want = vec!["Reports in progress".to_string(), "Chip".to_string()];
let (slot, names) = slot_content(&s);
assert_eq!(names, want);
let doc = s.doc().unwrap();
assert_eq!(slot_name(doc, slot).as_deref(), Some("Slot"));
assert!(slot_edited(doc, slot), "the instance's own content");
let chip = named(doc, |k| matches!(k, NodeKind::Instance { .. }), "Chip");
assert!(!inside_instance(doc, chip), "content stands alone");
// Edit and move both mains (the container's and the one used in the content).
let main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Container");
let chip_main = named(doc, |k| matches!(k, NodeKind::Component { .. }), "Chip");
let title = doc.node(main).unwrap().children[0];
ex(&mut s, "node.set", json!({"id": title, "props": {"fill": "#ff0000"}}));
ex(&mut s, "node.set", json!({"id": main, "props": {"width": 320}}));
let holder = idv(&ex(&mut s, "frame.create", json!({"x": -1000, "y": 0, "width": 900, "height": 900, "name": "Design system"})));
ex(&mut s, "node.reparent", json!({"ids": [main, chip_main], "parent": holder}));
healthy(&s);
assert_eq!(slot_content(&s).1, want, "kept across syncs");
// Saving and opening again keeps it.
let bytes = figcraft_format::save_bytes(s.doc().unwrap(), None).unwrap();
let data = base64::engine::general_purpose::STANDARD.encode(bytes);
let mut again = Session::new();
ex(&mut again, "file.open", json!({"data": data, "name": "slots.figcraft"}));
healthy(&again);
let (slot, names) = slot_content(&again);
assert_eq!(names, want);
assert!(slot_edited(again.doc().unwrap(), slot));
// Resetting the slot shows the main's (empty) default content.
ex(&mut again, "instance.resetSlot", json!({"id": slot}));
assert!(slot_content(&again).1.is_empty());
}
@@ -0,0 +1,60 @@
//! `variable.import` reads `variables` next to `variableCollections`, under `meta` or at the top
//! level, and never imports nothing without saying so (#790).
use serde_json::{Value, json};
use crate::Session;
fn collections() -> Value {
json!({"VC:1": {"id": "VC:1", "name": "Theme", "modes": [{"modeId": "1:0", "name": "Light"}], "defaultModeId": "1:0",
"variableIds": ["VariableID:1"]}})
}
fn variables() -> Value {
json!({"VariableID:1": {"id": "VariableID:1", "name": "bg", "variableCollectionId": "VC:1", "resolvedType": "COLOR",
"valuesByMode": {"1:0": {"r": 1.0, "g": 1.0, "b": 1.0, "a": 1.0}}}})
}
fn import(json: Value) -> Value {
let mut s = Session::new();
match s.execute("variable.import", &json!({"variables": json})) {
Ok(r) => r,
Err(e) => panic!("{e}"),
}
}
#[test]
fn variables_import_reads_every_shape() {
for shape in [
json!({"meta": {"variableCollections": collections(), "variables": variables()}}),
json!({"variableCollections": collections(), "variables": variables()}),
json!({"meta": {"variableCollections": collections()}, "variables": variables()}),
json!({"variableCollections": collections(), "meta": {"variables": variables()}}),
] {
let r = import(shape.clone());
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(1)), "{shape}: {r}");
assert_eq!(r["warnings"], json!([]), "{shape}: {r}");
}
}
#[test]
fn variables_import_never_imports_nothing_silently() {
// Collections without variables: they are added, with a warning saying where variables go.
let r = import(json!({"meta": {"variableCollections": collections()}}));
assert_eq!((r["collections"].as_u64(), r["variables"].as_u64()), (Some(1), Some(0)), "{r}");
assert!(r["warnings"].to_string().contains("no variables found: expected `meta.variables`"), "{r}");
// An empty file's export reads back with the same warning, not an error.
let mut s = Session::new();
let text = s.execute("variable.export", &json!({})).unwrap()["text"].as_str().unwrap().to_string();
let r = s.execute("variable.import", &json!({"variables": text})).unwrap();
assert!(r["warnings"].to_string().contains("no variables found"), "{r}");
// Collections listing variables that aren't there.
let mut colls = collections();
colls["VC:1"]["variableIds"] = json!(["VariableID:1", "VariableID:2"]);
let r = import(json!({"meta": {"variableCollections": colls, "variables": variables()}}));
assert_eq!(r["variables"].as_u64(), Some(1), "{r}");
assert!(r["warnings"].to_string().contains("1 of the 2 variables the collections list in `variableIds` weren't imported"), "{r}");
// No collections anywhere: an error naming the expected shape.
let e = Session::new().execute("variable.import", &json!({"variables": {"variables": variables()}})).unwrap_err().to_string();
assert!(e.contains("(`meta.variableCollections` and `meta.variables`)"), "{e}");
}
+122 -13
View File
@@ -6,8 +6,8 @@
//! (`relativeTransform`, else `absoluteBoundingBox` + `rotation`), paints, strokes, effects,
//! corners, constraints, auto layout, layout grids, export settings, text with styled runs,
//! vectors from `fillGeometry` / `strokeGeometry`, components, component sets, instances and
//! component properties are imported. Image fills keep their `imageRef` as the image key and
//! show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
//! component properties (slots included) are imported. Image fills keep their `imageRef` as the
//! image key and show a grey placeholder unless an [`ImageResolver`] supplies the bytes (nothing is fetched).
//! Prototyping comes along: interactions (triggers, actions, transitions, easing), flows,
//! scrolling and the prototype device. Variables come from the local variables response
//! (`GET /v1/files/:key/variables/local`), embedded next to the file JSON or passed separately
@@ -37,6 +37,8 @@ pub(crate) mod proto;
#[cfg(test)]
mod proto_variable_tests;
#[cfg(test)]
pub(crate) mod slot_tests;
#[cfg(test)]
mod tests;
mod text;
mod variables;
@@ -106,12 +108,25 @@ pub fn is_variables_json(v: &Value) -> bool {
}
/// The collections and variables of a local variables response (or its `meta`), checked like a
/// file's, with the warnings about what was skipped; None when `v` isn't one.
/// file's, with the warnings about what was skipped (and when it holds no variables at all, or
/// fewer than its collections list); None when `v` isn't one.
pub fn read_variables(v: &Value) -> Option<(figcraft_doc::Variables, Vec<String>)> {
let src = variables::source(v)?;
variables::source(v)?;
let mut cx = Ctx::new(Document::new("Variables"), &no_images);
cx.variables(src);
Some((std::mem::take(&mut cx.doc.variables), cx.warnings.finish()))
cx.variables(v);
let vars = std::mem::take(&mut cx.doc.variables);
let mut warnings = cx.warnings.finish();
if vars.variables.is_empty() {
warnings.push("no variables found: expected `meta.variables` (an object of variables by id, next to `meta.variableCollections`)".into());
} else {
let listed = variables::listed_ids(v);
let read: BTreeSet<&str> = vars.variables.iter().map(|x| x.id.as_str()).collect();
let missing = listed.difference(&read).count();
if missing > 0 {
warnings.push(format!("{missing} of the {} variables the collections list in `variableIds` weren't imported", listed.len()));
}
}
Some((vars, warnings))
}
/// Import `bytes` when they are REST API file JSON; `None` when they are something else (the
@@ -191,7 +206,7 @@ fn import_parts(
cx.collect_meta(e);
}
// Variables first: layers link to them as they are built.
for src in [variables::source(v), variables.and_then(variables::source)].into_iter().flatten() {
for src in [Some(v), variables].into_iter().flatten() {
cx.variables(src);
}
if variables.is_some_and(|x| variables::source(x).is_none()) {
@@ -368,6 +383,10 @@ pub(crate) struct Ctx<'a> {
style_meta: BTreeMap<&'a str, &'a Value>,
/// Instances (imported as frames until their main is resolved) with their JSON.
instances: Vec<(NodeId, &'a Value)>,
/// `SLOT` layers (imported as frames until their slot property is resolved) with their JSON.
slots: Vec<(NodeId, &'a Value)>,
/// Slot property defaults (the main's slot content id): (component or set, property) → value.
slot_defaults: BTreeMap<(NodeId, String), &'a Value>,
/// Instance-swap property defaults: (component or set, property, REST id of the default).
swap_defaults: Vec<(NodeId, String, String)>,
/// Property definitions of component sets, shared by their variants.
@@ -402,6 +421,8 @@ impl<'a> Ctx<'a> {
set_meta: BTreeMap::new(),
style_meta: BTreeMap::new(),
instances: Vec::new(),
slots: Vec::new(),
slot_defaults: BTreeMap::new(),
swap_defaults: Vec::new(),
set_defs: BTreeMap::new(),
flows: Vec::new(),
@@ -502,8 +523,10 @@ impl<'a> Ctx<'a> {
sources.record(id, j, primary);
}
}
if ty == "INSTANCE" {
self.instances.push((id, j));
match ty {
"INSTANCE" => self.instances.push((id, j)),
"SLOT" => self.slots.push((id, j)),
_ => {}
}
let kids = children(j);
if kids.is_empty() {
@@ -526,8 +549,8 @@ impl<'a> Ctx<'a> {
let point_count = || num(j, "pointCount").filter(|c| *c >= 3.0).map(|c| c.min(1000.0) as u32);
let has_geometry = !list(j, "fillGeometry").is_empty() || !list(j, "strokeGeometry").is_empty();
Some(match ty {
// Instances start as frames; `finish` links them to their main.
"FRAME" | "INSTANCE" => NodeKind::Frame,
// Instances and slots start as frames; `finish` links them to their main and property.
"FRAME" | "INSTANCE" | "SLOT" => NodeKind::Frame,
"GROUP" => NodeKind::Group,
"SECTION" => NodeKind::Section { contents_hidden: flag(j, "sectionContentsHidden").unwrap_or(false) },
"RECTANGLE" => NodeKind::Rectangle,
@@ -738,7 +761,7 @@ impl<'a> Ctx<'a> {
}
/// Component property definitions (variant ones are implied by the variant names).
fn prop_defs(&mut self, owner: NodeId, j: &Value) -> BTreeMap<String, PropDef> {
fn prop_defs(&mut self, owner: NodeId, j: &'a Value) -> BTreeMap<String, PropDef> {
let mut out = BTreeMap::new();
for (name, d) in j.get("componentPropertyDefinitions").and_then(Value::as_object).into_iter().flatten().take(256) {
let default = d.get("defaultValue");
@@ -752,7 +775,13 @@ impl<'a> Ctx<'a> {
continue;
}
Some("VARIANT") => continue,
Some("SLOT") if self.fig_sources.is_some() => PropDef::Slot { preferred: Vec::new() },
Some("SLOT") => {
if let Some(d) = default {
self.slot_defaults.insert((owner, name.clone()), d);
}
self.slot_notes(d);
PropDef::Slot { preferred: Vec::new() }
}
_ => {
self.warn("unknown component property types were skipped");
continue;
@@ -763,6 +792,20 @@ impl<'a> Ctx<'a> {
out
}
/// Notes on what a slot property sets that the editor doesn't keep: preferred components (keys
/// of library components, not layers of the file) and limits on its content.
fn slot_notes(&mut self, d: &Value) {
if !list(d, "preferredValues").is_empty() {
self.warn("preferred slot component keys cannot be resolved to file nodes and were skipped");
}
// Insertion and empty-slot display only change editing aids, not what the file shows.
let s = d.get("slotSettings");
let limit = |k: &str| s.and_then(|s| num(s, k)).is_some_and(|n| n != 0.0);
if limit("minChildren") || limit("maxChildren") || s.and_then(|s| flag(s, "allowPreferredValuesOnly")) == Some(true) {
self.warn("non-default slot settings are not represented and were skipped");
}
}
/// Shared styles with their values (`styleDefinitions`, written by the `.fig` import: a list of
/// `{id, name, description, styleType, node}`, the node being a layer that shows the style), in
/// the file's order. Layers then link to them; styles that no layer uses come across too.
@@ -971,6 +1014,7 @@ impl<'a> Ctx<'a> {
/// outside components, and instances that would nest a component inside itself (directly or
/// through other components), stay frames.
fn resolve_instances(&mut self) {
let frames: BTreeSet<NodeId> = self.instances.iter().map(|(i, _)| *i).collect();
let mut links: Vec<(NodeId, NodeId, &'a Value)> = Vec::new();
for (inst, j) in std::mem::take(&mut self.instances) {
match text(j, "componentId").and_then(|r| self.component(r)) {
@@ -1000,6 +1044,7 @@ impl<'a> Ctx<'a> {
linked.push((inst, j));
}
}
self.resolve_slots(&frames, &linked);
// Overridden fields the file lists, on the instance and its layers.
for (inst, j) in linked {
for o in list(j, "overrides") {
@@ -1015,6 +1060,70 @@ impl<'a> Ctx<'a> {
}
}
/// `SLOT` layers. In a main component a slot is bound to its slot property: the one the layer
/// references, else the one named like it, else the main's only slot property left. In an
/// instance it follows its main's slot, and holds the instance's own content (kept across syncs)
/// unless the instance's value of the property is the main's default content (without the
/// values to compare, a slot holding layers keeps them as its own). A slot without a property
/// stays a frame. `frames`: every instance
/// layer of the file, `linked`: those that became instances.
fn resolve_slots(&mut self, frames: &BTreeSet<NodeId>, linked: &[(NodeId, &'a Value)]) {
let linked: BTreeMap<NodeId, &Value> = linked.iter().copied().collect();
let mut bound: BTreeSet<(NodeId, String)> = BTreeSet::new();
for (id, j) in std::mem::take(&mut self.slots) {
// The nearest main component or instance above the slot owns it; in an instance whose
// main isn't in the file (a frame), the slot stays a frame too.
let owner = self.doc.ancestors(id).into_iter().find_map(|a| match self.doc.node(a).map(|n| &n.kind) {
Some(NodeKind::Instance { component, .. }) => Some(linked.get(&a).map(|j| (*component, Some(*j)))),
Some(NodeKind::Component { .. }) => Some(Some((a, None))),
_ => frames.contains(&a).then_some(None),
});
let Some(owner) = owner else {
self.warn("slot layers without a matching slot property were imported as frames");
continue;
};
let Some((main, inst)) = owner else { continue };
let Some(NodeKind::Component { props, .. }) = self.doc.node(main).map(|n| &n.kind) else { continue };
let defs: Vec<&String> = props.iter().filter(|(_, d)| matches!(d, PropDef::Slot { .. })).map(|(k, _)| k).collect();
let layer = text(j, "name").unwrap_or_default();
let refs: Vec<&str> =
j.get("componentPropertyReferences").and_then(Value::as_object).into_iter().flatten().filter_map(|(_, v)| v.as_str()).collect();
// An instance's slot is matched against all of its main's slot properties.
let free: Vec<&&String> = defs.iter().filter(|d| inst.is_some() || !bound.contains(&(main, (**d).clone()))).collect();
let name = defs
.iter()
.find(|d| refs.contains(&d.as_str()))
.or_else(|| defs.iter().find(|d| short_prop_name(d) == layer))
.or_else(|| if free.len() == 1 { free.first().copied() } else { None })
.map(|d| (*d).clone());
let Some(n) = self.doc.node(id) else { continue };
let has_content = !n.children.is_empty();
let Some(inst) = inst else {
match name.filter(|p| bound.insert((main, p.clone()))) {
Some(p) => {
if let Some(n) = self.doc.node_mut(id) {
n.prop_refs.insert("slot".into(), p);
}
}
None => self.warn("slot layers without a matching slot property were imported as frames"),
}
continue;
};
let set = self.doc.node(main).and_then(|n| n.parent);
let default =
name.as_ref().and_then(|p| [Some(main), set].into_iter().flatten().find_map(|o| self.slot_defaults.get(&(o, p.clone())).copied()));
let value = name.as_ref().and_then(|p| inst.get("componentProperties")?.get(p)?.get("value"));
let own = name.is_some()
&& match (value, default) {
(Some(v), Some(d)) => v != d,
_ => has_content,
};
if own && let Some(n) = self.doc.node_mut(id) {
n.overrides.insert("slot".into());
}
}
}
/// An instance's property values (variant values are implied by the main it shows).
fn instance_props(&self, j: &Value) -> BTreeMap<String, Value> {
let mut out = BTreeMap::new();
+229
View File
@@ -0,0 +1,229 @@
//! `SLOT` layers of the REST file JSON: bound to their slot property in a main component, and
//! holding each instance's own content (synthetic files).
use super::*;
use serde_json::json;
fn at(x: f64, y: f64) -> Value {
json!([[1, 0, x], [0, 1, y]])
}
pub(crate) fn file(children: Value) -> Value {
json!({"name": "Slots", "schemaVersion": 0, "document": {"id": "0:0", "name": "Document", "type": "DOCUMENT", "children": [
{"id": "0:1", "name": "Page", "type": "CANVAS", "children": children}
]}})
}
fn content_id(local: u32) -> Value {
json!({"guid": {"sessionID": 5, "localID": local}})
}
fn slot(id: &str, name: &str, children: Value) -> Value {
json!({"id": id, "name": name, "type": "SLOT", "relativeTransform": at(10.0, 40.0), "size": {"x": 280, "y": 100}, "children": children})
}
fn label(id: &str, text: &str) -> Value {
json!({"id": id, "name": text, "type": "TEXT", "characters": text, "relativeTransform": at(0.0, 0.0), "size": {"x": 100, "y": 20}})
}
/// A "Chip" component (`1:9`), a "Container" component (`1:2`) with a "Title" text and an empty
/// "Slot" (property `Slot#1:3`, default content 5:1), and the given layers after them.
pub(crate) fn container_file(more: Vec<Value>) -> Value {
let mut layers = vec![
json!({"id": "1:9", "name": "Chip", "type": "COMPONENT", "relativeTransform": at(0.0, 400.0), "size": {"x": 40, "y": 20}, "children": [
{"id": "1:10", "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]}),
json!({"id": "1:2", "name": "Container", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 160},
"componentPropertyDefinitions": {"Slot#1:3": {"type": "SLOT", "defaultValue": content_id(1)}},
"children": [label("1:4", "Title"), slot("1:5", "Slot", json!([]))]}),
];
layers.extend(more);
file(Value::Array(layers))
}
/// An instance `id` of Container at `x` whose slot holds `content` (property value `value`).
pub(crate) fn container(id: &str, x: f64, value: Value, content: Value) -> Value {
json!({"id": id, "name": "Container", "type": "INSTANCE", "componentId": "1:2", "relativeTransform": at(x, 0.0), "size": {"x": 300, "y": 160},
"componentProperties": {"Slot#1:3": {"type": "SLOT", "value": value}},
"children": [label(&format!("I{id};1:4"), "Title"), slot(&format!("I{id};1:5"), "Slot", content)]})
}
pub(crate) fn chip(id: &str) -> Value {
json!({"id": id, "name": "Chip", "type": "INSTANCE", "componentId": "1:9", "relativeTransform": at(0.0, 30.0), "size": {"x": 40, "y": 20}, "children": [
{"id": format!("I{id};1:10"), "name": "Dot", "type": "ELLIPSE", "relativeTransform": at(2.0, 2.0), "size": {"x": 16, "y": 16}}
]})
}
fn by_rest<'d>(doc: &'d Document, v: &Value, rest: &str) -> &'d Node {
// Names repeat across copies: find by position in the file's tree instead.
fn path(v: &Value, rest: &str, out: &mut Vec<usize>) -> bool {
if v.get("id").and_then(Value::as_str) == Some(rest) {
return true;
}
for (i, c) in v.get("children").and_then(Value::as_array).into_iter().flatten().enumerate() {
out.push(i);
if path(c, rest, out) {
return true;
}
out.pop();
}
false
}
let mut p = Vec::new();
assert!(path(&v["document"]["children"][0], rest, &mut p), "no {rest} in the file");
let mut id = doc.pages[0];
for i in p {
id = doc.node(id).unwrap().children[i];
}
doc.node(id).unwrap()
}
fn names(doc: &Document, n: &Node) -> Vec<String> {
n.children.iter().map(|c| doc.node(*c).unwrap().name.clone()).collect()
}
#[test]
fn slot_layers_bind_to_their_property_and_keep_instance_content() {
let v = container_file(vec![
container("2:1", 400.0, content_id(7), json!([label("2:2", "Reports in progress"), chip("2:3")])),
container("3:1", 800.0, content_id(1), json!([])),
]);
let (doc, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.iter().all(|w| !w.contains("SLOT") && !w.contains("slot")), "{warnings:?}");
let main = by_rest(&doc, &v, "1:2");
let NodeKind::Component { props, .. } = &main.kind else { panic!("{:?}", main.kind) };
assert_eq!(props.get("Slot"), Some(&PropDef::Slot { preferred: Vec::new() }), "named without its #id suffix");
let main_slot = by_rest(&doc, &v, "1:5");
assert!(matches!(main_slot.kind, NodeKind::Frame));
assert_eq!(main_slot.prop_refs.get("slot").map(String::as_str), Some("Slot"));
assert!(main_slot.children.is_empty(), "the main's slot stays empty");
let filled = by_rest(&doc, &v, "I2:1;1:5");
assert!(filled.overrides.contains("slot"), "the instance's own content");
assert!(filled.prop_refs.is_empty(), "an instance's slot follows its main's");
assert_eq!(names(&doc, filled), ["Reports in progress", "Chip"]);
let chip = by_rest(&doc, &v, "2:3");
assert!(matches!(chip.kind, NodeKind::Instance { .. }), "instances in slot content link to their own main");
let default = by_rest(&doc, &v, "I3:1;1:5");
assert!(!default.overrides.contains("slot"), "the main's default content follows the main");
}
#[test]
fn slot_ownership_without_values_follows_the_content() {
let mut owned = container("2:1", 400.0, json!(null), json!([label("2:2", "Own")]));
owned["componentProperties"] = json!({});
let mut empty = container("3:1", 800.0, json!(null), json!([]));
empty["componentProperties"] = json!({});
let v = container_file(vec![owned, empty]);
let (doc, _) = import(&v, &no_images).unwrap();
assert!(by_rest(&doc, &v, "I2:1;1:5").overrides.contains("slot"));
assert!(!by_rest(&doc, &v, "I3:1;1:5").overrides.contains("slot"));
}
#[test]
fn slots_match_by_reference_then_name_then_the_one_left() {
let comp = json!({"id": "1:2", "name": "Card", "type": "COMPONENT", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 300},
"componentPropertyDefinitions": {
"Header#1:3": {"type": "SLOT", "defaultValue": content_id(1)},
"Body#1:4": {"type": "SLOT", "defaultValue": content_id(2)},
"Footer#1:5": {"type": "SLOT", "defaultValue": content_id(3)},
"Visible#1:6": {"type": "BOOLEAN", "defaultValue": true}
},
"children": [
{"id": "1:7", "name": "Top", "type": "SLOT", "componentPropertyReferences": {"visible": "Visible#1:6", "slot": "Header#1:3"},
"relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 50}},
{"id": "1:8", "name": "Body", "type": "SLOT", "relativeTransform": at(0.0, 50.0), "size": {"x": 300, "y": 50}},
{"id": "1:9", "name": "Bottom", "type": "SLOT", "relativeTransform": at(0.0, 100.0), "size": {"x": 300, "y": 50}}
]});
let v = file(json!([comp]));
let (doc, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
let bound = |rest: &str| by_rest(&doc, &v, rest).prop_refs.get("slot").cloned();
assert_eq!(bound("1:7").as_deref(), Some("Header"));
assert_eq!(by_rest(&doc, &v, "1:7").prop_refs.get("visible").map(String::as_str), Some("Visible"));
assert_eq!(bound("1:8").as_deref(), Some("Body"));
assert_eq!(bound("1:9").as_deref(), Some("Footer"));
}
#[test]
fn variants_share_their_set_slot_property() {
let variant = |id: &str, name: &str, x: f64| {
json!({"id": id, "name": name, "type": "COMPONENT", "relativeTransform": at(x, 0.0), "size": {"x": 100, "y": 100},
"children": [slot(&format!("{id}s"), "Content", json!([]))]})
};
let set = json!({"id": "1:1", "name": "Panel", "type": "COMPONENT_SET", "relativeTransform": at(0.0, 0.0), "size": {"x": 300, "y": 100},
"componentPropertyDefinitions": {"Size": {"type": "VARIANT", "defaultValue": "Small"}, "Content#2:1": {"type": "SLOT", "defaultValue": content_id(1)}},
"children": [variant("1:2", "Size=Small", 0.0), variant("1:3", "Size=Large", 150.0)]});
let inst = json!({"id": "4:1", "name": "Panel", "type": "INSTANCE", "componentId": "1:3", "relativeTransform": at(0.0, 300.0), "size": {"x": 100, "y": 100},
"componentProperties": {"Content#2:1": {"type": "SLOT", "value": content_id(9)}},
"children": [slot("I4:1;1:3s", "Content", json!([label("4:2", "Mine")]))]});
let v = file(json!([set, inst]));
let (doc, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.is_empty(), "{warnings:?}");
for rest in ["1:2s", "1:3s"] {
assert_eq!(by_rest(&doc, &v, rest).prop_refs.get("slot").map(String::as_str), Some("Content"), "{rest}");
}
assert!(by_rest(&doc, &v, "I4:1;1:3s").overrides.contains("slot"));
}
#[test]
fn nested_instances_own_the_slots_of_their_main() {
// A "Screen" main places a Container whose slot holds a chip; an instance of Screen places
// other content in that nested slot.
let screen = json!({"id": "6:1", "name": "Screen", "type": "COMPONENT", "relativeTransform": at(0.0, 600.0), "size": {"x": 300, "y": 160},
"children": [container("6:2", 0.0, content_id(7), json!([chip("6:3")]))]});
let nested = container("I7:1;6:2", 0.0, content_id(8), json!([label("7:2", "Other")]));
let outer = json!({"id": "7:1", "name": "Screen", "type": "INSTANCE", "componentId": "6:1", "relativeTransform": at(400.0, 600.0), "size": {"x": 300, "y": 160},
"children": [nested]});
let v = container_file(vec![screen, outer]);
let (doc, _) = import(&v, &no_images).unwrap();
let in_main = by_rest(&doc, &v, "I6:2;1:5");
assert!(in_main.overrides.contains("slot") && in_main.prop_refs.is_empty());
assert!(matches!(by_rest(&doc, &v, "6:3").kind, NodeKind::Instance { .. }));
let in_outer = by_rest(&doc, &v, "II7:1;6:2;1:5");
assert!(in_outer.overrides.contains("slot"));
assert_eq!(names(&doc, in_outer), ["Other"]);
}
#[test]
fn slots_outside_components_or_without_a_property_stay_frames() {
let mut no_def = container_file(Vec::new());
no_def["document"]["children"][0]["children"][1].as_object_mut().unwrap().remove("componentPropertyDefinitions");
let loose = slot("8:1", "Loose", json!([label("8:2", "Kept")]));
no_def["document"]["children"][0]["children"].as_array_mut().unwrap().push(loose);
let (doc, warnings) = import(&no_def, &no_images).unwrap();
assert!(warnings.iter().any(|w| w.starts_with("slot layers without a matching slot property were imported as frames (×2)")), "{warnings:?}");
let kept = by_rest(&doc, &no_def, "8:1");
assert!(matches!(kept.kind, NodeKind::Frame) && kept.prop_refs.is_empty());
assert_eq!(names(&doc, kept), ["Kept"]);
assert!(by_rest(&doc, &no_def, "1:5").prop_refs.is_empty());
}
#[test]
fn slot_settings_the_editor_cannot_keep_are_noted() {
let mut v = container_file(Vec::new());
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
json!({"stretchChildOnInsert": true, "displayEmptyByDefault": true, "minChildren": null, "maxChildren": 3});
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["preferredValues"] =
json!([{"type": "COMPONENT", "key": "abc"}]);
let (_, warnings) = import(&v, &no_images).unwrap();
assert!(warnings.iter().any(|w| w == "non-default slot settings are not represented and were skipped"), "{warnings:?}");
assert!(warnings.iter().any(|w| w == "preferred slot component keys cannot be resolved to file nodes and were skipped"), "{warnings:?}");
let mut plain = container_file(Vec::new());
plain["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"]["slotSettings"] =
json!({"stretchChildOnInsert": true, "minChildren": null, "maxChildren": null, "allowPreferredValuesOnly": false});
assert!(import(&plain, &no_images).unwrap().1.is_empty());
}
#[test]
fn junk_slot_json_never_panics() {
for junk in [json!(null), json!(1), json!("x"), json!([]), json!({"guid": null}), json!({"type": "SLOT"})] {
let mut v = container_file(vec![container("2:1", 400.0, junk.clone(), json!([label("2:2", "A")]))]);
v["document"]["children"][0]["children"][1]["componentPropertyDefinitions"]["Slot#1:3"] =
json!({"type": "SLOT", "defaultValue": junk.clone(), "slotSettings": junk.clone(), "preferredValues": junk.clone()});
v["document"]["children"][0]["children"][1]["children"][1]["componentPropertyReferences"] = json!({"slot": junk});
assert!(import(&v, &no_images).is_ok());
}
}
+17 -3
View File
@@ -27,6 +27,16 @@ pub fn source(v: &Value) -> Option<&Value> {
[v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variableCollections").is_some_and(Value::is_object))
}
/// The variable ids the collections of a local variables response list in `variableIds`.
pub(super) fn listed_ids(v: &Value) -> BTreeSet<&str> {
source(v)
.into_iter()
.flat_map(|src| objects(src, "variableCollections", MAX_COLLECTIONS))
.flat_map(|(_, c)| list(c, "variableIds").iter().filter_map(Value::as_str).map(clip_str))
.take(MAX_VARIABLES)
.collect()
}
pub(super) fn kind_of(t: &str) -> Option<VariableType> {
Some(match t {
"COLOR" => VariableType::Color,
@@ -118,14 +128,18 @@ fn objects<'v>(v: &'v Value, key: &str, max: usize) -> impl Iterator<Item = (&'v
}
impl Ctx<'_> {
/// Add the collections and variables of a local variables response (or its `meta`).
pub(super) fn variables(&mut self, src: &Value) {
/// Add the collections and variables of a local variables response (or its `meta`); nothing
/// when `v` isn't one. The `variables` map is read next to `variableCollections`, or from
/// `meta` or the top level when it sits on the other side.
pub(super) fn variables(&mut self, v: &Value) {
let Some(src) = source(v) else { return };
for (key, c) in objects(src, "variableCollections", MAX_COLLECTIONS) {
self.collection(key, c);
}
let holder = [Some(src), v.get("meta"), Some(v)].into_iter().flatten().find(|m| m.get("variables").is_some_and(Value::is_object));
let mut found: Vec<Variable> = Vec::new();
let mut seen: BTreeSet<String> = BTreeSet::new();
for (key, v) in objects(src, "variables", MAX_VARIABLES) {
for (key, v) in holder.into_iter().flat_map(|h| objects(h, "variables", MAX_VARIABLES)) {
if let Some(var) = self.variable(key, v)
&& seen.insert(var.id.clone())
{
+1
View File
@@ -1625,6 +1625,7 @@ name = "English"
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped"
"slot content assignment target is missing or is not slot content" = "slot content assignment target is missing or is not slot content"
"slot content assignment uses the empty GUID sentinel and was skipped" = "slot content assignment uses the empty GUID sentinel and was skipped"
"slot layers without a matching slot property were imported as frames" = "slot layers without a matching slot property were imported as frames"
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "some vector points refer to point settings missing from the file; they use the layer's joins and plain corners"
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "some vector segments refer to style settings missing from the file; they use the layer's stroke"
"stale or invalid grid track sizing entries were ignored" = "stale or invalid grid track sizing entries were ignored"
+1
View File
@@ -1626,6 +1626,7 @@ status = "draft — needs native review"
"set-variable-mode actions on collections or modes that aren't in the variables JSON were skipped" = "se omitieron las acciones «Establecer modo de variable» sobre colecciones o modos que no están en el JSON de variables"
"slot content assignment target is missing or is not slot content" = "el destino de la asignación de contenido de ranura falta o no es contenido de ranura"
"slot content assignment uses the empty GUID sentinel and was skipped" = "la asignación de contenido de ranura usa el GUID vacío como centinela y se omitió"
"slot layers without a matching slot property were imported as frames" = "las capas de ranura sin una propiedad de ranura correspondiente se importaron como marcos"
"some vector points refer to point settings missing from the file; they use the layer's joins and plain corners" = "algunos puntos vectoriales hacen referencia a ajustes de punto que faltan en el archivo; usan las uniones de la capa y esquinas simples"
"some vector segments refer to style settings missing from the file; they use the layer's stroke" = "algunos segmentos vectoriales hacen referencia a ajustes de estilo que faltan en el archivo; usan el trazo de la capa"
"stale or invalid grid track sizing entries were ignored" = "se ignoraron las entradas de tamaño de pista de cuadrícula obsoletas o no válidas"
+3 -2
View File
@@ -1,6 +1,6 @@
# Components, variants, styles and variables parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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-18 · **Change:** minor (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
@@ -69,13 +69,14 @@ updates with overrides kept: `library_tests.rs`, `components_scale_tests.rs`.
| Explicit modes per frame, library collections' modes | done | |
| 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); CSS custom properties export; in the browser the export arrives as a download (import there still needs a file picker) |
| 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 export; in the browser the export arrives as a download (import there still needs a file picker) |
| Expressions in prototype set-variable actions | done | see [prototyping-parity](prototyping-parity.md) |
## Revision history
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | minor | Variables import (#790): a `variables` map outside `meta` (or `meta.variables` next to top-level collections) is read, and an import that finds no variables, or fewer than the collections' `variableIds` list, says so in `warnings` instead of succeeding silently |
| 2026-10-17 | minor | Variant properties reorder by dragging them in the Properties section, one undo step (#679; was "no drag in the panel yet"); a library file saved again right after it was read is read again, so a quick republish isn't missed (#652); the Hidden from publishing row no longer says the Hide when publishing item is missing (it landed 2026-10-12). Numbers unchanged |
| 2026-10-17 | minor | Size and resizing overrides of layers inside instances (#731): a layer in an instance can hug, fill or keep a size of its own (group `resizing`: hug/fill/fixed with min and max; `size`), from the Layout panel, the canvas handles and `node.set`; they were refused, and imported ones were dropped at the first relayout. Kept through main edits of other properties, rebuilds, undo, save, `.fig` and REST import (sizes a `.fig` instance gives its layers and the REST `overrides` fields), `.fig` export and detach; reset per group and with Reset all changes (`instance_resizing_tests`). The row that listed them as not overridable was wrong. Numbers unchanged. |
| 2026-10-14 | minor | Variables export in the browser: Export… ▸ Variables JSON / CSS custom properties did nothing in the web build, which has no save dialogs; it now hands the file over as a download (`Services::downloads` / `download`, `variables_export_ui_tests`). Import in the browser still needs a file picker, as do the other exports there. Numbers unchanged. |
+3 -2
View File
@@ -1,6 +1,6 @@
# File format parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** minor (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); 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) · **Target:** Figma Design (desktop app 126.10.7)
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** minor (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); 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) · **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
@@ -19,7 +19,7 @@ and prototype interactions are still unchecked there.
|---|---|---|---|---|---|
| `.fig` (Figma Design, `com.figma.document`, `application/x-figma`) | native (cloud; "Save local copy") | **yes, best effort** (`file.open`, `file.import`, drop, CLI, MCP) | **experimental** ([below](#export-to-fig-experimental)) | `.fig` files open (best effort): the zip, its deflate/zstd chunks and the kiwi schema are decoded generically and mapped by field name onto the REST importer, so pages, the layer tree, geometry, paints (styles resolved), strokes, effects, text with styled runs, vectors as editable vector networks (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, shapes with text, 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. 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, filled areas with their own 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) and by reading back what the export writes; no real-world files yet | read tail 5–10, write: checks against real files 5–15 |
| `.jam` (FigJam), `.deck` (Slides) | native to other Figma products | no | no | out of scope | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| REST API file JSON (`GET /v1/files/:key`) + local variables JSON + `images/` folder | the API's output | **yes** (`file.open`, `file.import`, CLI) | no | pages, layer tree, geometry, paints, strokes, effects, text runs, vectors, auto layout (sizing modes left out read as hug, as documented; grid track sizes from `gridColumnsSizing` / `gridRowsSizing`: px, fr, auto, `minmax(0, 1fr)`, `repeat()`), constraints, grids, components, sets, instances with overrides (sizes and resizing included), component properties, slots (`SLOT` layers bound to their slot property; an instance's slot keeps its content as the instance's own unless its property value is the main's default content; slot limits and preferred components are warnings), styles, prototyping (incl. conditionals and variable modes), variables with modes, aliases, scopes and bindings, image adjustments (the same tint sign as `.fig`); the import report after opening; warnings for what's skipped (video triggers and actions, modifier keys). Unit tests in `crates/format/src/rest/*_tests.rs` | — |
| `.figcraft` (ours: zip of `document.json` + images, versions inside) | — | yes | yes | streamed JSON, 50k layers save ~0.09 s / load ~0.16 s (`cargo xtask bench`); old files keep opening (`large_tests.rs`) | — |
| Local library files | team libraries in the cloud | yes (enable a `.figcraft` as a library) | yes (publish) | per-asset versions, updates with overrides kept | see [collaboration-parity](collaboration-parity.md) |
@@ -165,6 +165,7 @@ each.
| Date | Change | Summary |
|---|---|---|
| 2026-10-18 | minor | REST file JSON import keeps `SLOT` layers (#792): they were dropped with everything placed in them, which emptied every instance's slot. A slot in a main component becomes a slot frame bound to its `SLOT` property (the one it references, else the one named like it, else the only one left), and an instance's slot holds the instance's own content, kept across syncs, moves of the mains and saving, unless the instance's property value is the main's default content (without values, a slot that holds layers keeps them). Instances in slot content link to their own mains. Slot content limits, preferred-only and preferred components are warnings. Synthetic files only. Numbers unchanged |
| 2026-10-17 | minor | REST file JSON import reads grid track sizes from the CSS track strings `gridColumnsSizing` / `gridRowsSizing` (fixed px, fill fr, hug `auto`, `minmax(0, 1fr)`, `repeat()`; other tracks stay equal) (#465). Numbers unchanged |
| 2026-10-17 | minor | The community batches #727 and #728 in the `.fig` row: instance identity and cached library components, overrides by key, text style precedence, grid track sizes, slot content, labels of connectors saved with their path, skip ink, paragraph options, list start numbers, vertical trim, per-point stroke joins, text on a path, image adjustments (also REST), images in the message; budgets from the file's size; the journal keeps no file bytes; the import report. Not read cut to what's left. A `.fig` copy from Save as. Pasted content: layers copied from another design app, best effort (tested only with clipboard HTML made from our own export), and images under Wayland (not run there). All on synthetic files: summary and hours unchanged |
| 2026-10-17 | minor | Hug on import (#732): auto layout frames a `.fig` records no primary-axis sizing for hug on that axis (auto layout sized that axis by its content before it could be fixed) instead of turning fixed, instances that record no auto layout take their main's (they came in without auto layout and fixed, so longer text overflowed them), REST frames whose sizing modes are left out hug (the documented default), and text hugs by its resize mode (auto width both axes, auto height its height; it came in fixed). Numbers unchanged |
+5 -2
View File
@@ -883,10 +883,13 @@ path?}` writes this file's collections (default: all but library copies) as a lo
response (`format: "json"`, the default) or as CSS custom properties (`format: "css"`: the default
mode on `:root`, each other mode on `[data-<collection>="<mode>"]`, aliases as `var(--name)`), and
returns `{text}` (or writes `path` and returns `{path}`). `variable.import {variables}` (an object,
JSON text or a path) reads one back: a collection joins this file's collection of the same name
JSON text or a path; `variableCollections` and `variables` under `meta` or at the top level) reads
one back: a collection joins this file's collection of the same name
(values matched by mode name, new modes and variables added, a same-named variable of another type
skipped with a warning; library copies and extended collections are never targets), others are
added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`. The
added; aliases are checked for loops; it returns `{collections, variables, updated, warnings}`,
with a warning when the JSON holds no variables (`no variables found: expected meta.variables …`)
or fewer than its collections list in `variableIds`. The
Variables editor's Import… and Export… buttons do the same through file dialogs.
Strokes take colour styles and variables like fills: `style.apply {style, target: "stroke"}` links